nextcloud / nextcloud/suspicious_login
JsonException from KeyValueCache crashes login when caching binary model blob (breaks CalDAV/WebDAV auth)
Nobody has claimed this yet.
- 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
- Local cache backend set to the new
KeyValueCache(Redis/Valkey). - Ensure cached entry for current suspicious_login model id is missing/expired.
- Log in successfully (non-token auth) — e.g. via CalDAV Basic Auth (Thunderbird calendar add).
- 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 wrapsPersistentModel::load(), not the latercache()call.EstimatorService::predict()— catches onlyRuntimeException;JsonExceptionisn't one.LoginClassifier::process()— catches onlyModelNotFoundException/ServiceException.- Bubbles through the login event dispatcher into
Session::completeLogin()→Auth::validateUserPass(), whereAuth::check()finally catches it as genericExceptionand rewraps asServiceUnavailable— 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#61166 —
fix(log): simplify JSON log serialization for invalid UTF-8(draft). Same underlying problem class (malformed UTF-8 breakingjson_encode), fixed elsewhere in core (logging) viaJSON_INVALID_UTF8_SUBSTITUTE, but not yet applied toKeyValueCache::encodeValue(). - nextcloud/server#62104 —
fix(memcache): preserve float values in Redis-based cache backends. Touches the sameKeyValueCache/RedisencodeValue()/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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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