nextcloud / nextcloud/suspicious_login

JsonException from KeyValueCache crashes login when caching binary model blob (breaks CalDAV/WebDAV auth)

Open
#1,143 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

0. Needs triage bug
Dominant language
PHP
Stars
106
Forks
32
Avg merge
8h 33m
Merged PRs (30d)
10

Description

I've been facing this issue for a while now, and with having a little bit of free time I decided to actually give claude code a go and debug it, using the git repo of nextcloud-server and this one. It is an actual reproducible bug.

Summary

Caching raw binary model data via generic ICache::set() throws uncaught JsonException on Nextcloud's new Redis/Valkey KeyValueCache backend, crashing login. Surfaces as Sabre\DAV\Exception\ServiceUnavailable on WebDAV/CalDAV requests (e.g. Thunderbird calendar setup), but root trigger is any successful non-token login with a cache-miss on the model.

Environment

  • Nextcloud: 34.0.3.2
  • App: suspicious_login
  • Client: Thunderbird 155.0.1 (CalDAV), but not client-specific
  • Local cache backend: Redis/Valkey via OC\Memcache\KeyValueCache (Predis-based, core, @since 34.0.2)

Steps to reproduce

  1. Local cache backend set to the new KeyValueCache (Redis/Valkey).
  2. Ensure cached entry for current suspicious_login model id is missing/expired.
  3. Log in successfully (non-token auth) — e.g. via CalDAV Basic Auth (Thunderbird calendar add).
  4. Request fails with 503.

Workaround: occ app:disable suspicious_login.

Minimal reproduction, isolated from Nextcloud/Redis — just the core encodeValue() logic fed a value shaped like what ModelStore::cache() passes it:

<?php
// Minimal repro of the crash — mirrors OC\Memcache\KeyValueCache::encodeValue()
// (lib/private/Memcache/KeyValueCache.php:244-246) fed a value shaped like what
// OCA\SuspiciousLogin\Service\ModelStore::cache() passes to ICache::set()
// (lib/Service/ModelStore.php:77-83): a gzip-compressed, non-UTF8 binary blob.

function encodeValue(mixed $value): string {
    return is_int($value) ? (string)$value : json_encode($value, JSON_THROW_ON_ERROR);
}

$serializedModel = gzencode('fake Rubix ML PersistentModel payload'); // raw binary, not valid UTF-8

encodeValue($serializedModel);
// Fatal error: Uncaught JsonException: Malformed UTF-8 characters, possibly incorrectly encoded

Run with php repro.php (or php -r '...') — throws immediately, no Nextcloud or Redis instance needed. gzencode() output reliably contains invalid-UTF8 byte sequences, and JSON_THROW_ON_ERROR turns the resulting JSON_ERROR_UTF8 into an uncaught JsonException, matching the trace below.

Observed error

Sabre\DAV\Exception\ServiceUnavailable: JsonException: Malformed UTF-8 characters, possibly incorrectly encoded
  at apps/dav/lib/Connector/Sabre/Auth.php:118 (catch (Exception $e) inside check())
  PUT /remote.php/dav/calendars/<user>/personal/

Debug log shows underlying trace:

lib/private/Memcache/KeyValueCache.php:245  json_encode(null, 4194304)
lib/private/Memcache/KeyValueCache.php:69   encodeValue()  (called from ::set)
lib/Service/ModelStore.php:82  $cache->set("suspicious_login_model_<id>", <binary>)

Root cause

ModelStore::load() (lib/Service/ModelStore.php:107-109) reads the gzip-compressed, RBX-serialized Rubix ML model file (raw binary) from AppData, then calls ModelStore::cache() (lines 77-83) to populate local cache. cache() passes that binary straight to ICache::set() with no encoding.

On the new core KeyValueCache backend, encodeValue() (lib/private/Memcache/KeyValueCache.php:69) does:

protected static function encodeValue(mixed $value): string {
    return is_int($value) ? (string)$value : json_encode($value, JSON_THROW_ON_ERROR);
}

Gzip binary isn't valid UTF-8, so json_encode(..., JSON_THROW_ON_ERROR) throws JsonException. That throw is never caught on the way up:

  • ModelStore::cache() — no try/catch.
  • ModelStore::load() — try/catch only wraps PersistentModel::load(), not the later cache() call.
  • EstimatorService::predict() — catches only RuntimeException; JsonException isn't one.
  • LoginClassifier::process() — catches only ModelNotFoundException/ServiceException.
  • Bubbles through the login event dispatcher into Session::completeLogin()Auth::validateUserPass(), where Auth::check() finally catches it as generic Exception and rewraps as ServiceUnavailable — the 503 seen by the client.

Worked by accident on older cache backends (plain \Redis wrapper, APCu) that pass values through as raw strings/native serialize(), tolerant of binary content. Breaks specifically on the new KeyValueCache backend, which JSON-encodes unconditionally.

Suggested fix

In lib/Service/ModelStore.php:

  • cache() (lines 77-83): base64_encode($serialized) before $cache->set().
  • getCached() (lines 66-75): base64_decode() after $cache->get().

Willing to open a PR with this fix plus a unit test round-tripping a binary (non-UTF8) payload through cache()/getCached(), if maintainers agree on direction.

Server configuration

Web server: Nginx

Database: MariaDB

PHP version: 8.5

Related

A related core issue likely exists in nextcloud/server's KeyValueCache::encodeValue() (throwing uncaught JsonException for any non-UTF8-safe value passed to a generic ICache::set(), which never required UTF-8-safe values) — not yet filed separately; flagging here so maintainers can decide whether to also raise that upstream, or note it as core context for this fix.

Searched existing issues/PRs for duplicates — found none reporting this exact crash, but two open core PRs touch the same code path and are worth linking as context:

  • nextcloud/server#61166fix(log): simplify JSON log serialization for invalid UTF-8 (draft). Same underlying problem class (malformed UTF-8 breaking json_encode), fixed elsewhere in core (logging) via JSON_INVALID_UTF8_SUBSTITUTE, but not yet applied to KeyValueCache::encodeValue().
  • nextcloud/server#62104fix(memcache): preserve float values in Redis-based cache backends. Touches the same KeyValueCache/Redis encodeValue()/decodeValue() methods (different bug: int/float coercion), so a natural place to also land binary/UTF-8 safety if the fix belongs in core rather than (or in addition to) this app.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with lib/Service/ModelStore.php, especially cache(), getCached(), and load(), then compare their binary payload handling with encodeValue() in lib/private/Memcache/KeyValueCache.php. Run the supplied PHP reproduction first and inspect the existing ModelStore tests. Done means binary model data can pass through the cache without a JsonException and the login path no longer returns the reported 503.

Written by the indexing model from the issue text.

Assessment

Tech stack
php, redis
Domain
authentication, backend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
74/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.