From 5349c8e666cdf6c70a192e80edbfd39471c238ec Mon Sep 17 00:00:00 2001 From: Steve Krisjanovs Date: Wed, 1 Jul 2026 10:55:47 -0400 Subject: [PATCH] fix(security): stop caching tokens.json/users.json in memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both FileTokenStore and FileUserStore cached their JSON file's contents in memory on first read and never invalidated it. Their own doc comments stated the assumption plainly: "the daemon is single-process". That's false in practice — `llmux token create`/`revoke`, `user reset-passphrase`, etc. all run as separate short-lived CLI processes against the same tokens.json/users.json files while a daemon may already be running. Consequence, confirmed live while verifying fix/loopback-auth-bypass: a token minted via the CLI while a daemon was already running was NOT recognized by that daemon until restart, and revoking a token via the CLI did NOT take effect against an already-running daemon until restart either — the exact opposite of what revocation is supposed to guarantee (immediate access cutoff). Mutations made through the daemon's own in-process handlers (e.g. the web UI's revoke button) worked fine, since they share the same cache instance — only cross-process mutations were invisible. Fix: stop caching. load() now always re-reads and re-parses the file. Both files are a handful of tokens/users — re-parsing on every call is cheap next to the rest of a request, and it's a much smaller, more correct fix than adding cross-process invalidation (file watching, a reload signal, etc.) for what a single readFileSync already solves. Verified end-to-end against a running daemon: minted a token via a separate CLI process — immediately usable (200) against the already- running daemon. Revoked that same token via another separate CLI process — the running daemon immediately rejected it (401), no restart. Normal auth (GET /, GET /account with a valid bearer token) confirmed unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Au8T9RPCb6wbgiEecQrBfZ --- packages/llmux/src/v2/auth/tokens.ts | 22 +++++++++++----------- packages/llmux/src/v2/auth/users.ts | 23 ++++++++++++----------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/llmux/src/v2/auth/tokens.ts b/packages/llmux/src/v2/auth/tokens.ts index 50942f0..7464683 100644 --- a/packages/llmux/src/v2/auth/tokens.ts +++ b/packages/llmux/src/v2/auth/tokens.ts @@ -120,15 +120,21 @@ function isExpired(token: IdentityToken, now: Date = new Date()): boolean { /** * File-backed token store. Reads/writes config.dataDir/tokens.json. * - * Concurrency model matches FileUserStore — single-process daemon, in- - * process write mutex, in-memory cache. + * Concurrency model matches FileUserStore — in-process write mutex, no + * in-memory cache. load() re-reads the file on every call: the daemon is + * NOT the only process that touches tokens.json — `llmux token create` / + * `revoke` run as separate short-lived CLI processes against the same + * file while a daemon may already be running. An in-memory cache here + * meant a revoke issued via the CLI silently didn't take effect against + * an already-running daemon until it was restarted (confirmed live while + * verifying fix/loopback-auth-bypass). The file is a handful of tokens — + * re-parsing it on every call is cheap next to the rest of a request. * * lastUsedAt persistence: updated on every successful validate. At the * daemon's expected request rate this is fine; if it ever becomes a * hotspot we can debounce writes to once-per-N-seconds-per-token. */ export class FileTokenStore implements TokenStore { - private cache: IdentityToken[] | undefined; private writeQueue: Promise = Promise.resolve(); constructor(private storePath: string) {} @@ -247,11 +253,7 @@ export class FileTokenStore implements TokenStore { // ── private ───────────────────────────────────────────────────────────── private load(): IdentityToken[] { - if (this.cache) return this.cache; - if (!existsSync(this.storePath)) { - this.cache = []; - return this.cache; - } + if (!existsSync(this.storePath)) return []; const raw = readFileSync(this.storePath, 'utf-8'); let parsed: unknown; try { @@ -262,8 +264,7 @@ export class FileTokenStore implements TokenStore { if (!Array.isArray(parsed)) { throw new Error(`tokens.json: expected array at ${this.storePath}`); } - this.cache = parsed as IdentityToken[]; - return this.cache; + return parsed as IdentityToken[]; } private save(tokens: IdentityToken[]): void { @@ -272,7 +273,6 @@ export class FileTokenStore implements TokenStore { writeFileSync(tmp, JSON.stringify(tokens, null, 2) + '\n', { mode: 0o600 }); renameSync(tmp, this.storePath); try { chmodSync(this.storePath, 0o600); } catch { /* best-effort */ } - this.cache = tokens; } private withLock(fn: () => Promise): Promise { diff --git a/packages/llmux/src/v2/auth/users.ts b/packages/llmux/src/v2/auth/users.ts index 7ad0282..2de5c6b 100644 --- a/packages/llmux/src/v2/auth/users.ts +++ b/packages/llmux/src/v2/auth/users.ts @@ -125,11 +125,18 @@ function validatePassphrase(passphrase: string): void { * File-backed user store. Reads/writes /var/lib/llmux/users.json (or * config.dataDir/users.json). * - * Concurrency model: in-process mutex on writes (the daemon is single-process). - * Reads load + cache; writes invalidate the cache. + * Concurrency model: in-process mutex on writes, no in-memory cache. + * load() re-reads the file on every call: the daemon is NOT the only + * process that touches users.json — CLI commands (`user reset-passphrase`, + * etc.) run as separate short-lived processes against the same file while + * a daemon may already be running. A cache here meant a passphrase reset + * or demotion issued via the CLI silently didn't take effect against an + * already-running daemon until it was restarted (confirmed live while + * verifying fix/loopback-auth-bypass, alongside the identical bug in + * FileTokenStore). The file is a handful of users — re-parsing it on + * every call is cheap next to the rest of a request. */ export class FileUserStore implements UserStore { - private cache: User[] | undefined; private writeQueue: Promise = Promise.resolve(); constructor(private storePath: string) {} @@ -213,11 +220,7 @@ export class FileUserStore implements UserStore { // ── private ───────────────────────────────────────────────────────────── private load(): User[] { - if (this.cache) return this.cache; - if (!existsSync(this.storePath)) { - this.cache = []; - return this.cache; - } + if (!existsSync(this.storePath)) return []; const raw = readFileSync(this.storePath, 'utf-8'); let parsed: unknown; try { @@ -228,8 +231,7 @@ export class FileUserStore implements UserStore { if (!Array.isArray(parsed)) { throw new Error(`users.json: expected array at ${this.storePath}`); } - this.cache = parsed as User[]; - return this.cache; + return parsed as User[]; } private save(users: User[]): void { @@ -238,7 +240,6 @@ export class FileUserStore implements UserStore { writeFileSync(tmp, JSON.stringify(users, null, 2) + '\n', { mode: 0o600 }); renameSync(tmp, this.storePath); try { chmodSync(this.storePath, 0o600); } catch { /* best-effort */ } - this.cache = users; } /**