From d26d5653dd2a2181a6e31ab8c63c0832d6361e1a Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 7 Nov 2026 12:06:17 +0000 Subject: [PATCH 1/2] fix(hub): expire session tokens on time and don't count an expired code as a guess devframe's verifyAuthToken trusts any token left in the store, so a token's end depends on our prune. It ran hourly, so a token kept working for up to an hour past its 7 days, and it only deleted from storage, so a connection that was already trusted stayed trusted. The prune now runs when the next token expires (at least every minute) and revokes the live connections of each dropped token with devframe's revokeActiveConnectionsForToken. devframe also answers null for the right one-time code sent after it expired, and the guard counted that as a wrong guess, so a slow developer drifted toward the backoff and the lock. An attempt that carries exactly the current code is now left uncounted; every other code still counts, also after expiry. --- apps/docs/src/content/security.md | 4 +- packages/devtools/CHANGELOG.md | 2 + .../devtools/src/__tests__/auth-guard.test.ts | 143 +++++++++++++++++- packages/devtools/src/auth-guard.ts | 43 +++++- packages/devtools/src/auth-store.ts | 111 +++++++++++--- 5 files changed, 275 insertions(+), 28 deletions(-) diff --git a/apps/docs/src/content/security.md b/apps/docs/src/content/security.md index 9413088a..f0ca4554 100644 --- a/apps/docs/src/content/security.md +++ b/apps/docs/src/content/security.md @@ -92,9 +92,11 @@ Every server that asks for the code (the Vite plugin, the Express hub and the CL A refused code is never compared with the real one. devframe gives you a new code after 5 wrong ones, so a client that keeps guessing can change the code you are typing at most twice a minute. If the terminal says the server stopped accepting codes and you didn't type them, something on your machine or network is guessing the code. Restart the dev server to get a new code. +A code lasts 5 minutes. If you type the right code after it expires, the server refuses it without counting it as a wrong code, and devframe gives you a new one. Only the code that is current when the attempt arrives is left uncounted, so a client that guesses gets no free attempts. + #### Trusted browsers -A browser that exchanges the code gets a session token, so it doesn't need the code again after a reload. The server keeps those tokens in `~/.pangular-inspector/auth//auth.json`, one folder per project (named after a hash of its path), so a token from one project doesn't open another project's server. The folder is readable by you only (mode `0700`), and the file too (`0600`). A token expires 7 days after the code was exchanged. At startup the server drops entries in the file that aren't token records, and it only trusts a token that has its own entry, so a name such as `constructor` or `toString` is never a token. To sign out every browser, delete the project's folder, or all of `~/.pangular-inspector/auth`, and restart the server. +A browser that exchanges the code gets a session token, so it doesn't need the code again after a reload. The server keeps those tokens in `~/.pangular-inspector/auth//auth.json`, one folder per project (named after a hash of its path), so a token from one project doesn't open another project's server. The folder is readable by you only (mode `0700`), and the file too (`0600`). A token expires 7 days after the code was exchanged. At that moment the server drops it, and a page or panel still connected with it is no longer trusted. At startup the server drops entries in the file that aren't token records, and it only trusts a token that has its own entry, so a name such as `constructor` or `toString` is never a token. To sign out every browser, delete the project's folder, or all of `~/.pangular-inspector/auth`, and restart the server. Version 0.0.7 and earlier kept the tokens in `~/.devframes/devframe/auth.json` (the Express hub and the Vite plugin) or `~/.pangular/devframe/auth.json` (the CLI), readable by other users and without expiry. Newer versions don't read those files. Delete them if no other devframe tool uses them. diff --git a/packages/devtools/CHANGELOG.md b/packages/devtools/CHANGELOG.md index 537ea615..850015e8 100644 --- a/packages/devtools/CHANGELOG.md +++ b/packages/devtools/CHANGELOG.md @@ -106,6 +106,8 @@ What remains: `/config` exports `DEFAULT_POPUP_HOTKEY`, `PANGULAR_ACTIONS`, `PAN - Keep the Express hub and the overlay off in production unless `enabled: true` is passed to the hub. - Back off and lock the server after wrong one-time codes, and never compare a refused code with the real one. - Keep trusted session tokens in a `0600` file per project, and expire them after 7 days. +- Expire a session token at the moment it turns 7 days old, not at the next hourly check, and stop trusting the connections that still use it. devframe trusts any token left in the store and kept already trusted connections after their token was dropped. +- Don't count the right one-time code sent after it expired as a wrong guess. A slow developer no longer drifts toward the backoff and the lock. Wrong codes still count, also after the code expired. - Stop `server.allowedHosts: true` from turning off the origin checks, and warn about it. - Mask URL passwords and `Authorization` credentials (`Basic`, `Digest`, `Token`) in everything sent to the panel and to agents. - Mask more secrets: OpenAI, Anthropic, GitLab, npm, Hugging Face and SendGrid keys, Slack webhook URLs, the `Ocp-Apim-Subscription-Key` header, secret values that run past separators, secret XML elements and attributes, and token-like positional parts of TanStack Query keys and mutation variables. diff --git a/packages/devtools/src/__tests__/auth-guard.test.ts b/packages/devtools/src/__tests__/auth-guard.test.ts index 14180b68..8ac1642f 100644 --- a/packages/devtools/src/__tests__/auth-guard.test.ts +++ b/packages/devtools/src/__tests__/auth-guard.test.ts @@ -12,11 +12,13 @@ import { import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { connectDevframe } from 'devframe/client'; -import { getTempAuthCode } from 'devframe/node/auth'; +import { getTempAuthCode, verifyAuthToken } from 'devframe/node/auth'; +import { getInternalContext } from 'devframe/node/hub-internals'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { CodeGuard, guardedExchange } from '../auth-guard.ts'; import { authStoreDir, + expireAuthTokens, hardenTokenStore, pruneExpiredTokens, AUTH_TOKEN_TTL_MS, @@ -121,6 +123,53 @@ describe('guardedExchange', () => { expect(onTrusted).toHaveBeenCalledOnce(); }); + describe("with devframe's exchange", () => { + // devframe's one-time code lives 5 minutes. + const CODE_TTL_MS = 5 * 60_000; + let dir = ''; + afterEach(() => { + vi.useRealTimers(); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + function devframeContext(meta: object) { + dir = mkdtempSync(join(tmpdir(), 'pangular-code-')); + return { ...context(meta), host: { getStorageDir: () => dir } }; + } + + it('does not count a right code that expired before it arrived as a wrong guess', () => { + vi.useFakeTimers({ toFake: ['Date'] }); + const meta = {}; + const guard = new CodeGuard({ warn: vi.fn() }); + const handler = guardedExchange(devframeContext(meta) as never, guard); + // A slow developer types the code just after it expires, more often than the lock allows. + for (let i = 0; i < 60; i++) { + const code = getTempAuthCode(); + vi.setSystemTime(Date.now() + CODE_TTL_MS + 1); + expect(handler({ code, ua: '', origin: '' }).authToken).toBeNull(); + } + expect(guard.locked).toBe(false); + expect(guard.check(meta)).toBe('ok'); + expect(handler({ code: getTempAuthCode(), ua: '', origin: '' }).authToken).toBeTruthy(); + }); + + it('still counts a wrong code, also one sent after the code expired', () => { + vi.useFakeTimers({ toFake: ['Date'] }); + const meta = {}; + const guard = new CodeGuard({ warn: vi.fn() }); + const handler = guardedExchange(devframeContext(meta) as never, guard); + const wrong = (code: string) => (code === '000000' ? '000001' : '000000'); + handler({ code: wrong(getTempAuthCode()), ua: '', origin: '' }); + for (let i = 0; i < 2; i++) { + const code = getTempAuthCode(); + vi.setSystemTime(Date.now() + CODE_TTL_MS + 1); + handler({ code: wrong(code), ua: '', origin: '' }); + } + // Three wrong guesses start the wait. + expect(guard.check(meta)).toBe('backoff'); + }); + }); + it('answers null for a call without a session or a code', () => { const exchange = vi.fn(() => 'tok'); const ctx = { rpc: { ...context({}).rpc, getCurrentRpcSession: () => undefined } }; @@ -173,6 +222,78 @@ describe('pruneExpiredTokens', () => { }); }); +describe('expireAuthTokens', () => { + let dir = ''; + afterEach(() => { + vi.useRealTimers(); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + function fakeContext(meta: Record) { + dir = mkdtempSync(join(tmpdir(), 'pangular-expiry-')); + return { + mode: 'dev', + host: { getStorageDir: () => dir }, + // devframe's RPC host: the live connections, each with its session meta. + rpc: { _rpcGroup: { clients: [{ $meta: meta }] }, broadcast: vi.fn(async () => undefined) }, + }; + } + + it('drops a token at its exact expiry and untrusts the connections that use it', () => { + vi.useFakeTimers(); + const start = Date.UTC(2026, 0, 1); + vi.setSystemTime(start); + const meta: Record = { id: 'a', isTrusted: true, clientAuthToken: 'tok' }; + const ctx = fakeContext(meta); + const storage = getInternalContext(ctx as never).storage.auth; + const onPruned = vi.fn(); + expireAuthTokens(ctx, onPruned); + // Issued half an hour after the server started, so off the hourly beat. + vi.advanceTimersByTime(30 * 60_000); + const issuedAt = Date.now(); + storage.mutate((state) => { + state.trusted['tok'] = { authToken: 'tok', ua: '', origin: '', timestamp: issuedAt }; + }); + + vi.advanceTimersByTime(AUTH_TOKEN_TTL_MS - 1); + expect(storage.value().trusted['tok']).toBeDefined(); + expect(meta['isTrusted']).toBe(true); + + vi.advanceTimersByTime(1); + expect(Date.now()).toBe(issuedAt + AUTH_TOKEN_TTL_MS); + expect(storage.value().trusted['tok']).toBeUndefined(); + expect(verifyAuthToken('tok', { meta: {} } as never, storage)).toBe(false); + expect(meta['isTrusted']).toBe(false); + expect(meta['clientAuthToken']).toBeUndefined(); + expect(ctx.rpc.broadcast).toHaveBeenCalledOnce(); + expect(onPruned).toHaveBeenCalledOnce(); + }); + + it('untrusts a live connection once the prune has dropped its token', () => { + vi.useFakeTimers(); + vi.setSystemTime(Date.UTC(2026, 0, 1)); + const meta: Record = { id: 'a', isTrusted: true, clientAuthToken: 'tok' }; + const other: Record = { id: 'b', isTrusted: true, clientAuthToken: 'kept' }; + const ctx = fakeContext(meta); + ctx.rpc._rpcGroup.clients.push({ $meta: other }); + const storage = getInternalContext(ctx as never).storage.auth; + storage.mutate((state) => { + state.trusted['tok'] = { authToken: 'tok', ua: '', origin: '', timestamp: Date.now() }; + }); + expireAuthTokens(ctx); + vi.advanceTimersByTime(3 * 60 * 60_000); + storage.mutate((state) => { + state.trusted['kept'] = { authToken: 'kept', ua: '', origin: '', timestamp: Date.now() }; + }); + // Two hours past the first token's expiry, so any schedule has pruned it by now. + vi.advanceTimersByTime(AUTH_TOKEN_TTL_MS - 60 * 60_000); + expect(storage.value().trusted['kept']).toBeDefined(); + expect(storage.value().trusted['tok']).toBeUndefined(); + expect(meta['isTrusted']).toBe(false); + expect(other['isTrusted']).toBe(true); + }); +}); + describe('initPangularHub one-time code', () => { const cleanups: (() => Promise | void)[] = []; afterEach(async () => { @@ -258,6 +379,26 @@ describe('initPangularHub one-time code', () => { expect(existsSync(join(home, '.devframes', 'devframe', 'auth.json'))).toBe(false); }); + it('stops trusting a connected client when its token expires', async () => { + // The token has under two seconds left when the server starts. + const issuedAt = Date.now() - AUTH_TOKEN_TTL_MS + 1500; + const { connect } = await serve((home) => { + const dir = authStoreDir(process.cwd(), home); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'auth.json'), + JSON.stringify({ + trusted: { 'expiring-token': { authToken: 'expiring-token', timestamp: issuedAt } }, + }), + ); + }); + const client = await connect('expiring-token'); + expect(await client.ensureTrusted(1000)).toBe(true); + await vi.waitFor(() => expect(client.isTrusted).toBe(false), { timeout: 4000 }); + const again = await connect('expiring-token'); + expect(await again.ensureTrusted(500).catch(() => false)).toBe(false); + }); + it('drops expired tokens at startup and keeps the rewritten file private', async () => { const old = Date.now() - AUTH_TOKEN_TTL_MS - 1000; let file = ''; diff --git a/packages/devtools/src/auth-guard.ts b/packages/devtools/src/auth-guard.ts index f42d68c2..24cf7d5b 100644 --- a/packages/devtools/src/auth-guard.ts +++ b/packages/devtools/src/auth-guard.ts @@ -16,7 +16,13 @@ // // Attempts the guard refuses are not checked, so they don't rotate the code // the developer is typing either. -import { exchangeTempAuthCode } from 'devframe/node/auth'; +// +// devframe answers `null` both for a wrong code and for the right code sent +// after it expired. Only a wrong code counts: an attempt is left uncounted +// when it carries exactly the code that was current when it arrived, which +// only the developer (or a right guess) can send, so no guess goes uncounted. +import { createHash, timingSafeEqual } from 'node:crypto'; +import { exchangeTempAuthCode, getTempAuthCode } from 'devframe/node/auth'; import { getInternalContext } from 'devframe/node/hub-internals'; const EXCHANGE = 'anonymous:devframe:auth:exchange'; @@ -142,13 +148,34 @@ interface GuardContext { }; } -/** devframe's exchange: checks the code, and on a match stores and returns a new token. */ -export type CodeExchange = (params: ExchangeParams, session: AuthSession) => string | null; +/** + * devframe's exchange: checks the code, and on a match stores and returns a + * new token. `'expired'` means the code was the current one but had expired, + * so devframe rotated it; `null` means a wrong code. + */ +export type CodeExchange = ( + params: ExchangeParams, + session: AuthSession, +) => string | null | 'expired'; + +const digest = (value: string) => createHash('sha256').update(value).digest(); + +/** Compares two codes in constant time, whatever their lengths. */ +function sameCode(a: string, b: string): boolean { + return timingSafeEqual(digest(a), digest(b)); +} function defaultExchange(ctx: GuardContext): CodeExchange { return (params, session) => { const storage = getInternalContext(ctx as never).storage.auth; - return exchangeTempAuthCode(params.code, session as never, params, storage); + // The code devframe checks against. Reading it changes nothing: devframe + // only creates one when there is none, as the exchange would. + const current = getTempAuthCode(); + const authToken = exchangeTempAuthCode(params.code, session as never, params, storage); + if (authToken) return authToken; + // devframe accepts the current code unless it has expired, so a refused + // current code was an expired one. + return sameCode(params.code, current) ? 'expired' : null; }; } @@ -165,15 +192,17 @@ export function guardedExchange( if (typeof params?.code !== 'string' || guard.check(session.meta) !== 'ok') { return { authToken: null }; } - const authToken = exchange( + const result = exchange( { code: params.code, ua: String(params.ua ?? ''), origin: String(params.origin ?? '') }, session, ); - if (authToken) { + // The right code, too late: not a guess, so neither counted nor trusted. + if (result === 'expired') return { authToken: null }; + if (result) { guard.succeeded(session.meta); onTrusted?.(); } else guard.failed(session.meta); - return { authToken }; + return { authToken: result }; }; } diff --git a/packages/devtools/src/auth-store.ts b/packages/devtools/src/auth-store.ts index bbf4cc7c..ee60c93f 100644 --- a/packages/devtools/src/auth-store.ts +++ b/packages/devtools/src/auth-store.ts @@ -10,7 +10,10 @@ // - `~/.pangular-inspector/auth//`, in a folder only the user // can read (`0700`), with the file itself kept at `0600`. One project's // tokens don't open another project's server. -// - Tokens older than `AUTH_TOKEN_TTL_MS` are dropped at startup and every hour. +// - Tokens older than `AUTH_TOKEN_TTL_MS` are dropped at startup and at the +// moment they expire, and connections that used them stop being trusted. +// devframe's `verifyAuthToken` only checks that a token is in the store, so +// removing it on time is what ends it. // // The "global" scope only holds `auth.json` (and settings stored under the // `devframe:settings:global:` keys, which Pangular Inspector doesn't use), so @@ -19,13 +22,19 @@ import { createHash } from 'node:crypto'; import { chmodSync, existsSync, mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; +import { revokeActiveConnectionsForToken } from 'devframe/node/auth'; import { getInternalContext, internalContextMap } from 'devframe/node/hub-internals'; type StorageScope = 'workspace' | 'project' | 'global'; /** How long a token from the one-time code stays trusted: 7 days. */ export const AUTH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; -const PRUNE_EVERY_MS = 60 * 60 * 1000; +/** + * The longest wait between two prunes. The next prune runs when the oldest + * token expires, but no later than this: Node's timers don't count time the + * machine sleeps, so a long wait could end well after the expiry. + */ +const MAX_PRUNE_WAIT_MS = 60 * 1000; /** The folder that holds every project's token store. */ export function authStoreRoot(home: string = homedir()): string { @@ -124,6 +133,8 @@ interface TrustedRecord { interface AuthStorage { value(): { trusted?: Record }; mutate(fn: (state: { trusted: Record }) => void): void; + /** devframe's storage is a shared state, which emits `updated` after each change. */ + on?(event: 'updated', fn: () => void): unknown; } function isTokenRecord(token: string, record: unknown): boolean { @@ -156,29 +167,54 @@ export function hardenTokenStore(storage: AuthStorage): number { return entries.length - kept.length; } -/** Drops tokens older than `ttl`. Returns how many it dropped. */ -export function pruneExpiredTokens( - storage: AuthStorage, - now = Date.now(), - ttl = AUTH_TOKEN_TTL_MS, -): number { +function isExpired(record: TrustedRecord | undefined, now: number, ttl: number): boolean { + return typeof record?.timestamp !== 'number' || now - record.timestamp >= ttl; +} + +/** Drops tokens that are `ttl` old or older, and ones without a time. Returns the dropped tokens. */ +function dropExpiredTokens(storage: AuthStorage, now: number, ttl: number): string[] { const trusted = storage.value().trusted ?? {}; const expired = Object.entries(trusted) - .filter(([, record]) => typeof record?.timestamp !== 'number' || now - record.timestamp > ttl) + .filter(([, record]) => isExpired(record, now, ttl)) .map(([token]) => token); - if (!expired.length) return 0; + if (!expired.length) return expired; storage.mutate((state) => { for (const token of expired) delete state.trusted[token]; }); - return expired.length; + return expired; +} + +/** Drops tokens that are `ttl` old or older. Returns how many it dropped. */ +export function pruneExpiredTokens( + storage: AuthStorage, + now = Date.now(), + ttl = AUTH_TOKEN_TTL_MS, +): number { + return dropExpiredTokens(storage, now, ttl).length; +} + +/** How long until the next token expires, at most `MAX_PRUNE_WAIT_MS`. */ +function nextPruneDelay(storage: AuthStorage, now = Date.now(), ttl = AUTH_TOKEN_TTL_MS): number { + let delay = MAX_PRUNE_WAIT_MS; + for (const record of Object.values(storage.value().trusted ?? {})) { + if (typeof record?.timestamp === 'number') { + delay = Math.min(delay, record.timestamp + ttl - now); + } + } + return Math.max(1, delay); } const pruning = new WeakSet(); /** - * Hardens the context's token store (`hardenTokenStore`), then prunes it now - * and every hour. Safe to call more - * than once per context. Skipped for a static build, which has no tokens. + * Hardens the context's token store (`hardenTokenStore`), then prunes it now, + * when each token expires and at least every minute. A pruned token's live + * connections stop being trusted (devframe's `revokeActiveConnectionsForToken`, + * which also tells their clients). Safe to call more than once per context. + * Skipped for a static build, which has no tokens. + * + * devframe gives the context no dispose hook, so the timer is `unref`'d and + * holds the context weakly: once the context is gone, the timer stops. */ export function expireAuthTokens( ctx: unknown, @@ -196,10 +232,47 @@ export function expireAuthTokens( } // devframe rewrites the file after a change, with the default mode. hardenTokenStore(storage); - const prune = () => { - if (pruneExpiredTokens(storage, Date.now(), ttl)) onPruned(); + const ref = new WeakRef(context); + let timer: ReturnType | undefined; + let busy = false; + const schedule = (store: AuthStorage) => { + if (timer) clearTimeout(timer); + timer = setTimeout(run, nextPruneDelay(store, Date.now(), ttl)); + timer.unref?.(); + }; + const prune = (live: object, store: AuthStorage) => { + let dropped: string[] = []; + busy = true; + try { + dropped = dropExpiredTokens(store, Date.now(), ttl); + } finally { + busy = false; + // Even if this prune failed, the next one runs. + schedule(store); + } + for (const token of dropped) { + // Flips `isTrusted` off synchronously, then tells the clients. + void revokeActiveConnectionsForToken(live as never, token).catch(() => undefined); + } + if (dropped.length) onPruned(); }; - prune(); - const timer = setInterval(prune, PRUNE_EVERY_MS); - timer.unref?.(); + function run() { + timer = undefined; + const live = ref.deref(); + if (!live) return; + let store: AuthStorage; + try { + store = getInternalContext(live as never).storage.auth as unknown as AuthStorage; + } catch { + return; + } + prune(live, store); + } + // A new token, or one revoked elsewhere, moves the next expiry. + storage.on?.('updated', () => { + if (busy) return; + const live = ref.deref(); + if (live) schedule(getInternalContext(live as never).storage.auth as unknown as AuthStorage); + }); + prune(context, storage); } From 431eafc691daaec0e9872d2e9a01c9fc0adc81fa Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 7 Nov 2026 12:29:41 +0000 Subject: [PATCH 2/2] test(hub): give the expiring-token test more room under load --- packages/devtools/src/__tests__/auth-guard.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/devtools/src/__tests__/auth-guard.test.ts b/packages/devtools/src/__tests__/auth-guard.test.ts index 8ac1642f..50189e54 100644 --- a/packages/devtools/src/__tests__/auth-guard.test.ts +++ b/packages/devtools/src/__tests__/auth-guard.test.ts @@ -380,8 +380,8 @@ describe('initPangularHub one-time code', () => { }); it('stops trusting a connected client when its token expires', async () => { - // The token has under two seconds left when the server starts. - const issuedAt = Date.now() - AUTH_TOKEN_TTL_MS + 1500; + // The token has four seconds left, room for the hub to start and the client to be trusted. + const issuedAt = Date.now() - AUTH_TOKEN_TTL_MS + 4000; const { connect } = await serve((home) => { const dir = authStoreDir(process.cwd(), home); mkdirSync(dir, { recursive: true }); @@ -394,10 +394,10 @@ describe('initPangularHub one-time code', () => { }); const client = await connect('expiring-token'); expect(await client.ensureTrusted(1000)).toBe(true); - await vi.waitFor(() => expect(client.isTrusted).toBe(false), { timeout: 4000 }); + await vi.waitFor(() => expect(client.isTrusted).toBe(false), { timeout: 6000 }); const again = await connect('expiring-token'); expect(await again.ensureTrusted(500).catch(() => false)).toBe(false); - }); + }, 15_000); it('drops expired tokens at startup and keeps the rewritten file private', async () => { const old = Date.now() - AUTH_TOKEN_TTL_MS - 1000;