From dafbf143a5a41ccd0aaad1f7bc07933619e32476 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Mon, 24 Aug 2026 13:05:29 -0700 Subject: [PATCH 1/6] backport GHSA-xf67 login-CSRF fix to 1.x (Harper 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 (login-CSRF, the primary vuln): add stable per-browser secret cookie binding to the human login flow, adapted for 1.x (no MCP machinery). Helpers land in a new self-contained src/lib/browserBinding.ts module. handleLogin mints or reuses one __Host-oauth_browser cookie per browser, stores hash(secret) as browserNonceHash in the CSRF state. handleCallback verifies the hash constant-time before any upstream code exchange or session write; tokens without the hash (pre-upgrade in-flight) pass through (in-flight tolerance). The existing #185 session↔state binding is preserved unchanged. F2 (CRLF log injection, CWE-117, low severity): JSON.stringify the error and error_description callback params before logging them. Also tighten engines.harperdb from >=4.6.0 to >=4.6.0 <5.0.0 so 1.x is not installed against Harper 5, where the session model differs. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- src/lib/browserBinding.ts | 91 ++++++++++++++++++++++ src/lib/handlers.ts | 44 ++++++++++- test/lib/browserBinding.test.js | 89 +++++++++++++++++++++ test/lib/handlers.test.js | 132 ++++++++++++++++++++++++++++++++ 5 files changed, 355 insertions(+), 3 deletions(-) create mode 100644 src/lib/browserBinding.ts create mode 100644 test/lib/browserBinding.test.js diff --git a/package.json b/package.json index 5d358e9..8150f1d 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "typescript": "^5.3.3" }, "peerDependencies": { - "harperdb": ">=4.6.0" + "harperdb": ">=4.6.0 <5.0.0" }, "engines": { "node": ">=20", diff --git a/src/lib/browserBinding.ts b/src/lib/browserBinding.ts new file mode 100644 index 0000000..babd06d --- /dev/null +++ b/src/lib/browserBinding.ts @@ -0,0 +1,91 @@ +/** + * OAuth browser binding — stable per-browser secret cookie. + * + * Binds the human login flow to the browser that initiated it, closing the + * login-CSRF shape where an attacker-minted state+code is delivered to a + * victim's browser to silently log them in as the attacker. Session binding + * (the #185 fix) only covers flows started while already logged in because + * Harper 4.7.29 gives logged-out requests no session id; this module closes + * the gap for the primary login path. + * + * Mechanism: on login initiation, read or generate one stable + * `__Host-oauth_browser` cookie. Its SHA-256 hash is stored server-side in + * the CSRF state (`browserNonceHash`). The callback re-reads the cookie and + * constant-time-checks the hash before any upstream code exchange. Only the + * hash leaves the server; the cookie value never appears in state. + * + * `__Host-` cookie hardening: accepted only when `Secure` + `Path=/` + no + * `Domain` — a sibling origin cannot plant a parent-domain cookie to forge the + * binding (RFC 6265bis §4.1.3.2). Requires HTTPS in production; the same + * trade-off applies to all __Host- cookies. + */ + +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import type { Request } from '../types.ts'; + +export const BROWSER_SECRET_COOKIE_NAME = '__Host-oauth_browser'; + +/** Rolling 7-day lifetime; refreshed on every flow initiation. */ +const BROWSER_SECRET_MAX_AGE_S = 7 * 24 * 60 * 60; + +/** Generate a cryptographically random browser secret. */ +export function generateBrowserSecret(): string { + return randomBytes(32).toString('base64url'); +} + +/** SHA-256 (base64url) of the browser secret. Only the hash is stored server-side. */ +export function hashBrowserSecret(secret: string): string { + return createHash('sha256').update(secret).digest('base64url'); +} + +/** + * Build the `Set-Cookie` value for the stable browser secret. + * `__Host-` requires `Secure` + `Path=/` + no `Domain`. + * Call on every flow initiation to refresh the rolling Max-Age. + */ +export function buildBrowserSecretCookie(secret: string): string { + return `${BROWSER_SECRET_COOKIE_NAME}=${secret}; Max-Age=${BROWSER_SECRET_MAX_AGE_S}; Path=/; Secure; HttpOnly; SameSite=Lax`; +} + +/** + * Read the Cookie header from a Harper 4 request. + * + * Harper 4 exposes `request.headers` as a custom Headers object with `.get()`. + * Tests pass a plain object with a `.cookie` property. Handle both. + */ +function readCookieHeader(request: Request | undefined): string | undefined { + const headers = request?.headers as any; + if (!headers) return undefined; + // Harper 4 runtime: Headers object with .get() + if (typeof headers.get === 'function') return headers.get('cookie') ?? undefined; + // Test doubles: plain object + return headers.cookie ?? undefined; +} + +/** + * Read the browser secret from the request's Cookie header. + * + * Simple split parser, first name-match wins. Safe because the cookie name + * is a fixed constant and the value is base64url (no `=`, `;`, quotes, or + * spaces inside the value itself). + */ +export function readBrowserSecret(request: Request | undefined): string | undefined { + const header = readCookieHeader(request); + if (typeof header !== 'string' || !header) return undefined; + for (const part of header.split(';')) { + const eq = part.indexOf('='); + if (eq === -1) continue; + if (part.slice(0, eq).trim() === BROWSER_SECRET_COOKIE_NAME) { + return part.slice(eq + 1).trim() || undefined; + } + } + return undefined; +} + +/** Constant-time check that `secret` hashes to `expectedHash`. */ +export function browserSecretMatches(secret: string | undefined, expectedHash: string | undefined): boolean { + if (!secret || !expectedHash) return false; + const actual = Buffer.from(hashBrowserSecret(secret)); + const expected = Buffer.from(expectedHash); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} diff --git a/src/lib/handlers.ts b/src/lib/handlers.ts index bb8784c..282c257 100644 --- a/src/lib/handlers.ts +++ b/src/lib/handlers.ts @@ -18,6 +18,13 @@ import type { OnLoginResultNeedsConfirmation, } from '../types.ts'; import type { HookManager } from './hookManager.ts'; +import { + browserSecretMatches, + buildBrowserSecretCookie, + generateBrowserSecret, + hashBrowserSecret, + readBrowserSecret, +} from './browserBinding.ts'; /** * Sanitize a redirect parameter to prevent open redirect attacks @@ -128,12 +135,22 @@ export async function handleLogin( const referer = request.headers?.referer ? sanitizeRedirect(request.headers.referer) : undefined; const originalUrl = redirectParam || referer || config.postLoginRedirect || '/'; + // Browser binding: session binding (#185) covers flows started while logged + // in, but Harper 4 mints no session id for logged-out requests, leaving the + // primary login flow unprotected against login-CSRF. One stable + // `__Host-oauth_browser` cookie per browser closes the gap: its hash is + // stored in the CSRF state and checked in the callback before any upstream + // call. Max-Age is refreshed here so active browsers never silently expire. + const existingSecret = readBrowserSecret(request); + const browserSecret = existingSecret ?? generateBrowserSecret(); + // Generate CSRF token with metadata // Bind token to provider to prevent cross-provider CSRF attacks const csrfToken = await provider.generateCSRFToken({ originalUrl, sessionId: request.session?.id, providerName, // Bind state token to this provider + browserNonceHash: hashBrowserSecret(browserSecret), }); // Build authorization URL with CSRF token as state parameter @@ -144,7 +161,8 @@ export async function handleLogin( return { status: 302, headers: { - Location: authUrl, + 'Location': authUrl, + 'Set-Cookie': buildBrowserSecretCookie(browserSecret), }, }; } @@ -169,7 +187,8 @@ export async function handleCallback( // Handle OAuth errors from provider if (error) { - logger?.error?.(`OAuth error: ${error} - ${errorDescription}`); + // JSON.stringify: CRLF-safe logging of browser-controlled params (CWE-117). + logger?.error?.(`OAuth error: ${JSON.stringify(error)} - ${JSON.stringify(errorDescription)}`); const errorUrl = buildErrorRedirect(config.postLoginRedirect || '/', { error: 'oauth_failed', reason: error }); return { status: 302, @@ -247,6 +266,27 @@ export async function handleCallback( }; } + // Browser binding (GHSA-xf67): session binding has nothing to check when the + // flow starts logged out (no session id). The `__Host-oauth_browser` cookie + // closes that gap — handleLogin stores hash(secret) in the state; the + // callback must arrive in the same browser. Enforced when the state carries + // the hash; pre-upgrade in-flight tokens (no hash) pass through. + if (tokenData.browserNonceHash) { + if (!browserSecretMatches(readBrowserSecret(request), tokenData.browserNonceHash)) { + logger?.warn?.(`OAuth callback: login browser binding mismatch (provider '${providerName}')`); + const errorUrl = buildErrorRedirect(tokenData.originalUrl || config.postLoginRedirect || '/', { + error: 'auth_failed', + reason: 'csrf', + }); + return { + status: 302, + headers: { + Location: errorUrl, + }, + }; + } + } + try { // Exchange code for tokens const tokenResponse = await provider.exchangeCodeForToken(code, config.redirectUri || ''); diff --git a/test/lib/browserBinding.test.js b/test/lib/browserBinding.test.js new file mode 100644 index 0000000..41422e5 --- /dev/null +++ b/test/lib/browserBinding.test.js @@ -0,0 +1,89 @@ +/** + * Tests for the browser-binding cookie helpers (GHSA-xf67 backport). + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + BROWSER_SECRET_COOKIE_NAME, + browserSecretMatches, + buildBrowserSecretCookie, + generateBrowserSecret, + hashBrowserSecret, + readBrowserSecret, +} from '../../dist/lib/browserBinding.js'; + +describe('browserBinding', () => { + it('generates unique url-safe browser secrets', () => { + assert.notEqual(generateBrowserSecret(), generateBrowserSecret()); + // base64url — no +, /, or = padding + assert.match(generateBrowserSecret(), /^[A-Za-z0-9_-]+$/); + }); + + it('hashBrowserSecret produces consistent SHA-256 base64url output', () => { + const secret = 'test-secret'; + assert.equal(hashBrowserSecret(secret), hashBrowserSecret(secret)); + assert.notEqual(hashBrowserSecret(secret), hashBrowserSecret('other')); + assert.match(hashBrowserSecret(secret), /^[A-Za-z0-9_-]+$/); + }); + + it('buildBrowserSecretCookie includes all required cookie attributes', () => { + const cookie = buildBrowserSecretCookie('mysecret'); + assert.ok(cookie.startsWith(`${BROWSER_SECRET_COOKIE_NAME}=mysecret`)); + assert.ok(cookie.includes('Path=/')); + assert.ok(cookie.includes('Secure')); + assert.ok(cookie.includes('HttpOnly')); + assert.ok(cookie.includes('SameSite=Lax')); + assert.ok(cookie.includes('Max-Age=')); + }); + + describe('readBrowserSecret', () => { + it('reads the secret from a plain-object headers cookie', () => { + const secret = generateBrowserSecret(); + const request = { headers: { cookie: `${BROWSER_SECRET_COOKIE_NAME}=${secret}` } }; + assert.equal(readBrowserSecret(request), secret); + }); + + it('reads the secret when other cookies precede it', () => { + const secret = 'abc123'; + const request = { + headers: { cookie: `other=value; ${BROWSER_SECRET_COOKIE_NAME}=${secret}; trailing=x` }, + }; + assert.equal(readBrowserSecret(request), secret); + }); + + it('reads the secret via .get() (Harper 4 runtime Headers shape)', () => { + const secret = 'runtime-secret'; + const request = { + headers: { + get: (name) => (name === 'cookie' ? `${BROWSER_SECRET_COOKIE_NAME}=${secret}` : null), + }, + }; + assert.equal(readBrowserSecret(request), secret); + }); + + it('returns undefined when the cookie is absent', () => { + assert.equal(readBrowserSecret({ headers: { cookie: 'other=value' } }), undefined); + assert.equal(readBrowserSecret({ headers: {} }), undefined); + assert.equal(readBrowserSecret(undefined), undefined); + }); + }); + + describe('browserSecretMatches', () => { + it('returns true for a matching secret and hash', () => { + const secret = generateBrowserSecret(); + assert.ok(browserSecretMatches(secret, hashBrowserSecret(secret))); + }); + + it('returns false for a mismatched secret', () => { + const secret = generateBrowserSecret(); + assert.ok(!browserSecretMatches('wrong-secret', hashBrowserSecret(secret))); + }); + + it('returns false when secret or hash is undefined/empty', () => { + assert.ok(!browserSecretMatches(undefined, hashBrowserSecret('x'))); + assert.ok(!browserSecretMatches('x', undefined)); + assert.ok(!browserSecretMatches('', 'anyhash')); + }); + }); +}); diff --git a/test/lib/handlers.test.js b/test/lib/handlers.test.js index 36dc2a0..e5720c8 100644 --- a/test/lib/handlers.test.js +++ b/test/lib/handlers.test.js @@ -5,6 +5,11 @@ import { describe, it, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { handleLogin, handleCallback, handleLogout, handleUserInfo, handleTestPage } from '../../dist/lib/handlers.js'; +import { + BROWSER_SECRET_COOKIE_NAME, + buildBrowserSecretCookie, + hashBrowserSecret, +} from '../../dist/lib/browserBinding.js'; import { createMockFn, createMockLogger } from '../helpers/mockFn.js'; describe('OAuth Handlers', () => { @@ -151,6 +156,41 @@ describe('OAuth Handlers', () => { const csrfCall = mockProvider.generateCSRFToken.mock.calls[0]; assert.equal(csrfCall.arguments[0].sessionId, 'session-123'); }); + + it('mints a browser-binding secret: hash in the state token, stable secret in a __Host- cookie', async () => { + const result = await handleLogin(mockRequest, mockTarget, mockProvider, mockConfig, 'test-provider', mockLogger); + + const meta = mockProvider.generateCSRFToken.mock.calls[0].arguments[0]; + assert.ok(meta.browserNonceHash, 'secret hash stored in the state token'); + + const setCookie = result.headers['Set-Cookie']; + const [pair, ...attrs] = setCookie.split('; '); + const eq = pair.indexOf('='); + assert.equal(pair.slice(0, eq), BROWSER_SECRET_COOKIE_NAME, 'stable cookie name'); + assert.equal( + hashBrowserSecret(pair.slice(eq + 1)), + meta.browserNonceHash, + 'cookie value hashes to the bound hash' + ); + for (const attr of ['Path=/', 'Secure', 'HttpOnly', 'SameSite=Lax']) { + assert.ok(attrs.includes(attr), `cookie carries ${attr}`); + } + }); + + it('reuses an existing browser secret cookie rather than generating a new one', async () => { + const existingSecret = 'existing-browser-secret-abc'; + mockRequest.headers.cookie = buildBrowserSecretCookie(existingSecret).split(';')[0]; + + const result = await handleLogin(mockRequest, mockTarget, mockProvider, mockConfig, 'test-provider', mockLogger); + + const meta = mockProvider.generateCSRFToken.mock.calls[0].arguments[0]; + assert.equal(meta.browserNonceHash, hashBrowserSecret(existingSecret), 'reuses existing secret hash'); + + // The Set-Cookie refreshes the Max-Age on the same secret + const setCookie = result.headers['Set-Cookie']; + const [pair] = setCookie.split('; '); + assert.equal(pair.slice(pair.indexOf('=') + 1), existingSecret, 'cookie value unchanged'); + }); }); describe('handleCallback', () => { @@ -996,4 +1036,96 @@ describe('OAuth Handlers', () => { assert.equal(result.headers.Location, '/dashboard'); }); }); + + describe('handleCallback — login browser binding (GHSA-xf67)', () => { + const SECRET = 'login-binding-secret'; + + beforeEach(() => { + // Logged-out flow: no sessionId in the token (session binding has nothing + // to check — the exact gap the browser-secret cookie closes). + mockProvider.verifyCSRFToken = createMockFn(async () => ({ + originalUrl: '/dashboard', + timestamp: Date.now(), + providerName: 'test-provider', + browserNonceHash: hashBrowserSecret(SECRET), + })); + }); + + it('completes when the callback arrives in the browser that initiated the login', async () => { + mockRequest.headers.cookie = buildBrowserSecretCookie(SECRET).split(';')[0]; + + const result = await handleCallback( + mockRequest, + mockTarget, + mockProvider, + mockConfig, + mockHookManager, + 'test-provider', + mockLogger + ); + + assert.equal(result.status, 302); + assert.equal(result.headers.Location, '/dashboard'); + assert.equal(mockProvider.exchangeCodeForToken.mock.calls.length, 1); + }); + + it('rejects when the binding cookie is missing — attacker-minted state in a victim browser', async () => { + delete mockRequest.headers.cookie; + + const result = await handleCallback( + mockRequest, + mockTarget, + mockProvider, + mockConfig, + mockHookManager, + 'test-provider', + mockLogger + ); + + assert.equal(result.status, 302); + assert.equal(result.headers.Location, '/dashboard?error=auth_failed&reason=csrf'); + // Rejected before any upstream call or session write. + assert.equal(mockProvider.exchangeCodeForToken.mock.calls.length, 0); + assert.equal(mockRequest.session.update.mock.calls.length, 0); + }); + + it('rejects when the browser-secret cookie does not hash-match', async () => { + mockRequest.headers.cookie = buildBrowserSecretCookie('some-other-browser-secret').split(';')[0]; + + const result = await handleCallback( + mockRequest, + mockTarget, + mockProvider, + mockConfig, + mockHookManager, + 'test-provider', + mockLogger + ); + + assert.equal(result.headers.Location, '/dashboard?error=auth_failed&reason=csrf'); + assert.equal(mockProvider.exchangeCodeForToken.mock.calls.length, 0); + }); + + it('tolerates state tokens without a browserNonceHash (pre-upgrade in-flight logins)', async () => { + mockProvider.verifyCSRFToken = createMockFn(async () => ({ + originalUrl: '/dashboard', + timestamp: Date.now(), + providerName: 'test-provider', + })); + delete mockRequest.headers.cookie; + + const result = await handleCallback( + mockRequest, + mockTarget, + mockProvider, + mockConfig, + mockHookManager, + 'test-provider', + mockLogger + ); + + assert.equal(result.status, 302); + assert.equal(result.headers.Location, '/dashboard'); + }); + }); }); From 5ae2b6e4eefec73535f30e87430862077a71ead6 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Mon, 24 Aug 2026 13:06:57 -0700 Subject: [PATCH 2/6] =?UTF-8?q?revert=20package.json=20peer=20dep=20change?= =?UTF-8?q?=20=E2=80=94=20handled=20separately?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8150f1d..5d358e9 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "typescript": "^5.3.3" }, "peerDependencies": { - "harperdb": ">=4.6.0 <5.0.0" + "harperdb": ">=4.6.0" }, "engines": { "node": ">=20", From 72638c9a2eaa175b9122114302025e38b75b6d85 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Tue, 25 Aug 2026 16:35:02 -0700 Subject: [PATCH 3/6] fix(security): close three GHSA-xf67 regressions on 1.x backport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1 — readCookieHeader array join (browserBinding.ts) Harper 4 Headers (extends Map; append with commaDelimited) can store a repeated Cookie header as string[]. readCookieHeader returned headers.get('cookie') verbatim, so readBrowserSecret rejected the non-string and the binding cookie was never found, locking out browsers whose HTTP/2 connection crumbled the header. Now joins crumbs with '; ' when the value is an array; plain-object test-double path receives the same treatment. Matches 2.x consentBinding.ts readCookieHeader. Fix 2 — state token consumed on error callback (handlers.ts) handleCallback returned on the error= branch before calling verifyCSRFToken(state), leaving the single-use state replayable. The fix separates the no-state early-return (no token to consume) from the with-state path: verifyCSRFToken is now called first regardless of whether the IdP returned an error, then the error redirect uses tokenData.originalUrl — matching 2.x ordering and the comment "consuming state on error is intentional." Fix 3 — CRLF-safe username log (handlers.ts) Two log lines interpolated user.username raw, unlike the error-path logging which already used JSON.stringify (CWE-117). Wrapped both occurrences identically. Raw interpolation also exists in 2.x (lines 406 and 501 of origin/main:src/lib/handlers.ts) — filed for follow-up. Tests Added six new assertions covering the three fixes: array-crumbs via .get() and via plain object (Fix 1); error= with-state consumes token and error= without-state skips verification (Fix 2); CRLF-in-username on both log paths (Fix 3). All 394 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/browserBinding.ts | 13 ++++- src/lib/handlers.ts | 68 +++++++++++++++++------- test/lib/browserBinding.test.js | 21 ++++++++ test/lib/handlers.test.js | 93 +++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 20 deletions(-) diff --git a/src/lib/browserBinding.ts b/src/lib/browserBinding.ts index babd06d..526c7ac 100644 --- a/src/lib/browserBinding.ts +++ b/src/lib/browserBinding.ts @@ -51,15 +51,24 @@ export function buildBrowserSecretCookie(secret: string): string { * Read the Cookie header from a Harper 4 request. * * Harper 4 exposes `request.headers` as a custom Headers object with `.get()`. + * HTTP/2 crumbling (and transports that preserve repeated header fields as an + * array) can split Cookie across multiple values — join all crumbs so the + * binding cookie is found regardless of which crumb carries it. * Tests pass a plain object with a `.cookie` property. Handle both. */ function readCookieHeader(request: Request | undefined): string | undefined { const headers = request?.headers as any; if (!headers) return undefined; // Harper 4 runtime: Headers object with .get() - if (typeof headers.get === 'function') return headers.get('cookie') ?? undefined; + if (typeof headers.get === 'function') { + const raw = headers.get('cookie'); + if (raw == null) return undefined; + return Array.isArray(raw) ? raw.join('; ') : String(raw); + } // Test doubles: plain object - return headers.cookie ?? undefined; + const raw = headers.cookie; + if (raw == null) return undefined; + return Array.isArray(raw) ? raw.join('; ') : String(raw); } /** diff --git a/src/lib/handlers.ts b/src/lib/handlers.ts index 282c257..706bffc 100644 --- a/src/lib/handlers.ts +++ b/src/lib/handlers.ts @@ -185,21 +185,20 @@ export async function handleCallback( const error = target.get?.('error'); const errorDescription = target.get?.('error_description'); - // Handle OAuth errors from provider - if (error) { - // JSON.stringify: CRLF-safe logging of browser-controlled params (CWE-117). - logger?.error?.(`OAuth error: ${JSON.stringify(error)} - ${JSON.stringify(errorDescription)}`); - const errorUrl = buildErrorRedirect(config.postLoginRedirect || '/', { error: 'oauth_failed', reason: error }); - return { - status: 302, - headers: { - Location: errorUrl, - }, - }; - } - - // Validate parameters - if (!code || !state) { + // Without state there is no token to consume; handle stateless error/missing + // params and return before attempting any token verification. + if (!state) { + if (error) { + // JSON.stringify: CRLF-safe logging of browser-controlled params (CWE-117). + logger?.error?.(`OAuth error: ${JSON.stringify(error)} - ${JSON.stringify(errorDescription)}`); + const errorUrl = buildErrorRedirect(config.postLoginRedirect || '/', { error: 'oauth_failed', reason: error }); + return { + status: 302, + headers: { + Location: errorUrl, + }, + }; + } logger?.warn?.('Missing required OAuth callback parameters'); const errorUrl = buildErrorRedirect(config.postLoginRedirect || '/', { error: 'invalid_request' }); return { @@ -210,7 +209,10 @@ export async function handleCallback( }; } - // Verify CSRF token + // Verify (and consume) CSRF token BEFORE handling any upstream error. + // Single-use-state: even an error callback must burn the state so the + // same state cannot be replayed. Consuming on error is intentional — + // OAuth callbacks are not retried with the same state. const tokenData = await provider.verifyCSRFToken(state); if (!tokenData) { logger?.warn?.('Invalid or expired CSRF token'); @@ -287,6 +289,36 @@ export async function handleCallback( } } + // Handle upstream IdP errors now that the state is consumed and all binding + // checks have passed. Using tokenData.originalUrl here mirrors 2.x. + if (error) { + // JSON.stringify: CRLF-safe logging of browser-controlled params (CWE-117). + logger?.error?.(`OAuth error: ${JSON.stringify(error)} - ${JSON.stringify(errorDescription)}`); + const errorUrl = buildErrorRedirect(tokenData.originalUrl || config.postLoginRedirect || '/', { + error: 'oauth_failed', + reason: error, + }); + return { + status: 302, + headers: { + Location: errorUrl, + }, + }; + } + + if (!code) { + logger?.warn?.('Missing required OAuth callback parameters'); + const errorUrl = buildErrorRedirect(tokenData.originalUrl || config.postLoginRedirect || '/', { + error: 'invalid_request', + }); + return { + status: 302, + headers: { + Location: errorUrl, + }, + }; + } + try { // Exchange code for tokens const tokenResponse = await provider.exchangeCodeForToken(code, config.redirectUri || ''); @@ -321,7 +353,7 @@ export async function handleCallback( if (isGatedLoginOutcome(hookData)) { const denied = hookData.status === 'denied'; const reason = denied ? hookData.error : undefined; - logger?.info?.(`OAuth login ${denied ? 'denied' : 'deferred'} by onLogin hook for user: ${user.username}`); + logger?.info?.(`OAuth login ${denied ? 'denied' : 'deferred'} by onLogin hook for user: ${JSON.stringify(user.username)}`); if (hookData.redirect) { return { status: 302, headers: { Location: resolveHookRedirect(hookData.redirect) } }; } @@ -394,7 +426,7 @@ export async function handleCallback( } logger?.info?.( - `OAuth login successful for user: ${user.username}${tokenResponse.expires_in ? `, token expires in ${tokenResponse.expires_in}s` : ', token does not expire'}` + `OAuth login successful for user: ${JSON.stringify(user.username)}${tokenResponse.expires_in ? `, token expires in ${tokenResponse.expires_in}s` : ', token does not expire'}` ); } else { logger?.warn?.('No session available for OAuth user'); diff --git a/test/lib/browserBinding.test.js b/test/lib/browserBinding.test.js index 41422e5..3e34251 100644 --- a/test/lib/browserBinding.test.js +++ b/test/lib/browserBinding.test.js @@ -62,6 +62,27 @@ describe('browserBinding', () => { assert.equal(readBrowserSecret(request), secret); }); + it('reads the secret when .get() returns an array of cookie crumbs (HTTP/2 crumbling)', () => { + // Harper 4 Headers (extends Map; append with commaDelimited) can store + // repeated Cookie fields as string[] — the binding cookie may be in any crumb. + const secret = 'crumbled-secret'; + const request = { + headers: { + get: (name) => + name === 'cookie' ? ['session=abc', `${BROWSER_SECRET_COOKIE_NAME}=${secret}`, 'other=1'] : null, + }, + }; + assert.equal(readBrowserSecret(request), secret); + }); + + it('reads the secret from a plain-object headers.cookie array', () => { + const secret = 'plain-array-secret'; + const request = { + headers: { cookie: [`other=x`, `${BROWSER_SECRET_COOKIE_NAME}=${secret}`] }, + }; + assert.equal(readBrowserSecret(request), secret); + }); + it('returns undefined when the cookie is absent', () => { assert.equal(readBrowserSecret({ headers: { cookie: 'other=value' } }), undefined); assert.equal(readBrowserSecret({ headers: {} }), undefined); diff --git a/test/lib/handlers.test.js b/test/lib/handlers.test.js index e5720c8..52f76d9 100644 --- a/test/lib/handlers.test.js +++ b/test/lib/handlers.test.js @@ -255,6 +255,55 @@ describe('OAuth Handlers', () => { assert.equal(result.headers.Location, '/dashboard?error=oauth_failed&reason=access_denied'); }); + it('error= callback with a present state token consumes the token before returning (Fix 2)', async () => { + // State is present alongside the error — the token must be consumed + // (verifyCSRFToken called) so it cannot be replayed. + mockTarget.get = createMockFn((key) => { + if (key === 'error') return 'access_denied'; + if (key === 'state') return 'csrf-token-123'; + return null; + }); + + const result = await handleCallback( + mockRequest, + mockTarget, + mockProvider, + mockConfig, + mockHookManager, + 'test-provider', + mockLogger + ); + + // verifyCSRFToken must have been called (state consumed / single-use enforced) + assert.equal(mockProvider.verifyCSRFToken.mock.calls.length, 1, 'state token must be consumed on error path'); + assert.equal(result.status, 302); + // Redirect uses tokenData.originalUrl, not a hardcoded fallback + assert.ok(result.headers.Location.includes('error=oauth_failed'), 'error code surfaced'); + assert.ok(result.headers.Location.includes('reason=access_denied'), 'reason surfaced'); + }); + + it('error= callback without state does not attempt token verification', async () => { + // No state → no token to consume; must still redirect with the error reason. + mockTarget.get = createMockFn((key) => { + if (key === 'error') return 'server_error'; + return null; + }); + + const result = await handleCallback( + mockRequest, + mockTarget, + mockProvider, + mockConfig, + mockHookManager, + 'test-provider', + mockLogger + ); + + assert.equal(mockProvider.verifyCSRFToken.mock.calls.length, 0, 'no token to consume when state absent'); + assert.equal(result.status, 302); + assert.ok(result.headers.Location.includes('error=oauth_failed')); + }); + it('should handle missing code parameter', async () => { mockTarget.get = createMockFn(() => null); @@ -846,6 +895,50 @@ describe('OAuth Handlers', () => { }); }); + describe('handleCallback — CRLF-safe username logging (Fix 3)', () => { + const callbackWith = (request, target) => + handleCallback(request, target, mockProvider, mockConfig, mockHookManager, 'test-provider', mockLogger); + + it('CRLF in username is JSON-encoded on the denied-login log line', async () => { + const maliciousUsername = 'admin\r\nX-Injected: evil'; + mockProvider.mapUserToHarper = createMockFn(() => ({ + username: maliciousUsername, + role: 'user', + email: 'x@x.com', + name: 'X', + provider: 'test', + })); + mockHookManager.callOnLogin = createMockFn(async () => ({ status: 'denied' })); + + await callbackWith(mockRequest, mockTarget); + + for (const call of mockLogger.info.mock.calls) { + const msg = String(call.arguments[0]); + assert.ok(!msg.includes('\r'), 'CR must not appear raw in log output'); + assert.ok(!msg.includes('\n'), 'LF must not appear raw in log output'); + } + }); + + it('CRLF in username is JSON-encoded on the successful-login log line', async () => { + const maliciousUsername = 'user\r\nX-Injected: evil'; + mockProvider.mapUserToHarper = createMockFn(() => ({ + username: maliciousUsername, + role: 'user', + email: 'u@u.com', + name: 'U', + provider: 'test', + })); + + await callbackWith(mockRequest, mockTarget); + + for (const call of mockLogger.info.mock.calls) { + const msg = String(call.arguments[0]); + assert.ok(!msg.includes('\r'), 'CR must not appear raw in log output'); + assert.ok(!msg.includes('\n'), 'LF must not appear raw in log output'); + } + }); + }); + describe('handleLogout', () => { it('should clear session data', async () => { // Add delete method mock to session From 93223da63454b7cb99a15606235e07851439232a Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Tue, 25 Aug 2026 16:37:57 -0700 Subject: [PATCH 4/6] style: prettier-format the GHSA-xf67 backport fixes Co-Authored-By: Claude Opus 4.8 --- src/lib/handlers.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/handlers.ts b/src/lib/handlers.ts index 706bffc..6a5202d 100644 --- a/src/lib/handlers.ts +++ b/src/lib/handlers.ts @@ -353,7 +353,9 @@ export async function handleCallback( if (isGatedLoginOutcome(hookData)) { const denied = hookData.status === 'denied'; const reason = denied ? hookData.error : undefined; - logger?.info?.(`OAuth login ${denied ? 'denied' : 'deferred'} by onLogin hook for user: ${JSON.stringify(user.username)}`); + logger?.info?.( + `OAuth login ${denied ? 'denied' : 'deferred'} by onLogin hook for user: ${JSON.stringify(user.username)}` + ); if (hookData.redirect) { return { status: 302, headers: { Location: resolveHookRedirect(hookData.redirect) } }; } From 04c47053f630f2fd938a56a0903bbcfe7c225a8b Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Tue, 1 Sep 2026 11:22:18 -0700 Subject: [PATCH 5/6] fix(security): validate browser-secret cookie value and guard non-string compare inputs readBrowserSecret now rejects cookie values that don't match /^[A-Za-z0-9_-]{1,64}$/, preventing hashing of attacker-supplied arbitrary-length strings. browserSecretMatches adds explicit typeof checks so a non-string stored value (e.g. from a corrupt or unexpected source) cannot reach hashBrowserSecret/Buffer.from and throw. Tests added for: valid 43-char base64url accepted, malformed value rejected, over-length (65+ chars) rejected, and non-string arguments (number, object, null) returning false without throwing. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/browserBinding.ts | 5 +++-- test/lib/browserBinding.test.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/lib/browserBinding.ts b/src/lib/browserBinding.ts index 526c7ac..d7b3156 100644 --- a/src/lib/browserBinding.ts +++ b/src/lib/browserBinding.ts @@ -85,7 +85,8 @@ export function readBrowserSecret(request: Request | undefined): string | undefi const eq = part.indexOf('='); if (eq === -1) continue; if (part.slice(0, eq).trim() === BROWSER_SECRET_COOKIE_NAME) { - return part.slice(eq + 1).trim() || undefined; + const value = part.slice(eq + 1).trim(); + return /^[A-Za-z0-9_-]{1,64}$/.test(value) ? value : undefined; } } return undefined; @@ -93,7 +94,7 @@ export function readBrowserSecret(request: Request | undefined): string | undefi /** Constant-time check that `secret` hashes to `expectedHash`. */ export function browserSecretMatches(secret: string | undefined, expectedHash: string | undefined): boolean { - if (!secret || !expectedHash) return false; + if (typeof secret !== 'string' || typeof expectedHash !== 'string' || !secret || !expectedHash) return false; const actual = Buffer.from(hashBrowserSecret(secret)); const expected = Buffer.from(expectedHash); return actual.length === expected.length && timingSafeEqual(actual, expected); diff --git a/test/lib/browserBinding.test.js b/test/lib/browserBinding.test.js index 3e34251..c079c61 100644 --- a/test/lib/browserBinding.test.js +++ b/test/lib/browserBinding.test.js @@ -88,6 +88,27 @@ describe('browserBinding', () => { assert.equal(readBrowserSecret({ headers: {} }), undefined); assert.equal(readBrowserSecret(undefined), undefined); }); + + it('returns the secret for a valid 43-char base64url value', () => { + // generateBrowserSecret() produces exactly 43 base64url chars from randomBytes(32) + const secret = generateBrowserSecret(); + assert.equal(secret.length, 43); + assert.match(secret, /^[A-Za-z0-9_-]+$/); + const request = { headers: { cookie: `${BROWSER_SECRET_COOKIE_NAME}=${secret}` } }; + assert.equal(readBrowserSecret(request), secret); + }); + + it('returns undefined for a malformed cookie value (contains disallowed chars)', () => { + const malformed = 'abc!@#$%^&*()malformed value with spaces'; + const request = { headers: { cookie: `${BROWSER_SECRET_COOKIE_NAME}=${malformed}` } }; + assert.equal(readBrowserSecret(request), undefined); + }); + + it('returns undefined for an over-length cookie value (65+ chars)', () => { + const overLength = 'a'.repeat(65); + const request = { headers: { cookie: `${BROWSER_SECRET_COOKIE_NAME}=${overLength}` } }; + assert.equal(readBrowserSecret(request), undefined); + }); }); describe('browserSecretMatches', () => { @@ -106,5 +127,15 @@ describe('browserBinding', () => { assert.ok(!browserSecretMatches('x', undefined)); assert.ok(!browserSecretMatches('', 'anyhash')); }); + + it('returns false without throwing when either argument is a non-string (number, object)', () => { + assert.doesNotThrow(() => { + // Cast to any to simulate unexpected runtime types reaching the function + assert.ok(!browserSecretMatches(/** @type {any} */ (42), hashBrowserSecret('x'))); + assert.ok(!browserSecretMatches('x', /** @type {any} */ (42))); + assert.ok(!browserSecretMatches(/** @type {any} */ ({ valueOf: () => 'x' }), hashBrowserSecret('x'))); + assert.ok(!browserSecretMatches(/** @type {any} */ (null), hashBrowserSecret('x'))); + }); + }); }); }); From 5dccb40a2b3225117a1b4013097cfd691610ed57 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Tue, 1 Sep 2026 12:15:09 -0700 Subject: [PATCH 6/6] style: trim browser-binding comments to essentials Reduces the verbose JSDoc and inline comments added by the GHSA-xf67 fix down to terse Harper style: one line per invariant, no history or narration. No logic changed; every diff line is a comment or blank. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/browserBinding.ts | 47 +++++---------------------------- src/lib/handlers.ts | 24 ++++------------- test/lib/browserBinding.test.js | 10 ++----- test/lib/handlers.test.js | 9 +++---- 4 files changed, 17 insertions(+), 73 deletions(-) diff --git a/src/lib/browserBinding.ts b/src/lib/browserBinding.ts index d7b3156..2ee6f78 100644 --- a/src/lib/browserBinding.ts +++ b/src/lib/browserBinding.ts @@ -1,23 +1,9 @@ /** - * OAuth browser binding — stable per-browser secret cookie. + * Browser binding via stable per-browser `__Host-oauth_browser` cookie. * - * Binds the human login flow to the browser that initiated it, closing the - * login-CSRF shape where an attacker-minted state+code is delivered to a - * victim's browser to silently log them in as the attacker. Session binding - * (the #185 fix) only covers flows started while already logged in because - * Harper 4.7.29 gives logged-out requests no session id; this module closes - * the gap for the primary login path. - * - * Mechanism: on login initiation, read or generate one stable - * `__Host-oauth_browser` cookie. Its SHA-256 hash is stored server-side in - * the CSRF state (`browserNonceHash`). The callback re-reads the cookie and - * constant-time-checks the hash before any upstream code exchange. Only the - * hash leaves the server; the cookie value never appears in state. - * - * `__Host-` cookie hardening: accepted only when `Secure` + `Path=/` + no - * `Domain` — a sibling origin cannot plant a parent-domain cookie to forge the - * binding (RFC 6265bis §4.1.3.2). Requires HTTPS in production; the same - * trade-off applies to all __Host- cookies. + * Stores a SHA-256 hash of the cookie in the CSRF state; verified constant-time + * at callback before any code exchange. Only the hash leaves the server. + * `__Host-` requires `Secure` + `Path=/` + no `Domain` (HTTPS only). */ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; @@ -28,7 +14,6 @@ export const BROWSER_SECRET_COOKIE_NAME = '__Host-oauth_browser'; /** Rolling 7-day lifetime; refreshed on every flow initiation. */ const BROWSER_SECRET_MAX_AGE_S = 7 * 24 * 60 * 60; -/** Generate a cryptographically random browser secret. */ export function generateBrowserSecret(): string { return randomBytes(32).toString('base64url'); } @@ -38,24 +23,12 @@ export function hashBrowserSecret(secret: string): string { return createHash('sha256').update(secret).digest('base64url'); } -/** - * Build the `Set-Cookie` value for the stable browser secret. - * `__Host-` requires `Secure` + `Path=/` + no `Domain`. - * Call on every flow initiation to refresh the rolling Max-Age. - */ +/** Build Set-Cookie value; `__Host-` requires `Secure` + `Path=/` + no `Domain`. Refreshes Max-Age. */ export function buildBrowserSecretCookie(secret: string): string { return `${BROWSER_SECRET_COOKIE_NAME}=${secret}; Max-Age=${BROWSER_SECRET_MAX_AGE_S}; Path=/; Secure; HttpOnly; SameSite=Lax`; } -/** - * Read the Cookie header from a Harper 4 request. - * - * Harper 4 exposes `request.headers` as a custom Headers object with `.get()`. - * HTTP/2 crumbling (and transports that preserve repeated header fields as an - * array) can split Cookie across multiple values — join all crumbs so the - * binding cookie is found regardless of which crumb carries it. - * Tests pass a plain object with a `.cookie` property. Handle both. - */ +/** Extract Cookie header, joining Harper 4 Header array crumbs; falls back to plain-object for test doubles. */ function readCookieHeader(request: Request | undefined): string | undefined { const headers = request?.headers as any; if (!headers) return undefined; @@ -71,13 +44,7 @@ function readCookieHeader(request: Request | undefined): string | undefined { return Array.isArray(raw) ? raw.join('; ') : String(raw); } -/** - * Read the browser secret from the request's Cookie header. - * - * Simple split parser, first name-match wins. Safe because the cookie name - * is a fixed constant and the value is base64url (no `=`, `;`, quotes, or - * spaces inside the value itself). - */ +/** Parse the `__Host-oauth_browser` cookie value from the request. */ export function readBrowserSecret(request: Request | undefined): string | undefined { const header = readCookieHeader(request); if (typeof header !== 'string' || !header) return undefined; diff --git a/src/lib/handlers.ts b/src/lib/handlers.ts index 6a5202d..87bff04 100644 --- a/src/lib/handlers.ts +++ b/src/lib/handlers.ts @@ -135,12 +135,7 @@ export async function handleLogin( const referer = request.headers?.referer ? sanitizeRedirect(request.headers.referer) : undefined; const originalUrl = redirectParam || referer || config.postLoginRedirect || '/'; - // Browser binding: session binding (#185) covers flows started while logged - // in, but Harper 4 mints no session id for logged-out requests, leaving the - // primary login flow unprotected against login-CSRF. One stable - // `__Host-oauth_browser` cookie per browser closes the gap: its hash is - // stored in the CSRF state and checked in the callback before any upstream - // call. Max-Age is refreshed here so active browsers never silently expire. + // Browser binding: read or mint the stable __Host- cookie; store its hash in the CSRF state. const existingSecret = readBrowserSecret(request); const browserSecret = existingSecret ?? generateBrowserSecret(); @@ -185,8 +180,7 @@ export async function handleCallback( const error = target.get?.('error'); const errorDescription = target.get?.('error_description'); - // Without state there is no token to consume; handle stateless error/missing - // params and return before attempting any token verification. + // No state token: handle stateless errors and missing params without token verification. if (!state) { if (error) { // JSON.stringify: CRLF-safe logging of browser-controlled params (CWE-117). @@ -209,10 +203,7 @@ export async function handleCallback( }; } - // Verify (and consume) CSRF token BEFORE handling any upstream error. - // Single-use-state: even an error callback must burn the state so the - // same state cannot be replayed. Consuming on error is intentional — - // OAuth callbacks are not retried with the same state. + // Consume the single-use state before handling any upstream error or binding check. const tokenData = await provider.verifyCSRFToken(state); if (!tokenData) { logger?.warn?.('Invalid or expired CSRF token'); @@ -268,11 +259,7 @@ export async function handleCallback( }; } - // Browser binding (GHSA-xf67): session binding has nothing to check when the - // flow starts logged out (no session id). The `__Host-oauth_browser` cookie - // closes that gap — handleLogin stores hash(secret) in the state; the - // callback must arrive in the same browser. Enforced when the state carries - // the hash; pre-upgrade in-flight tokens (no hash) pass through. + // Browser binding: verify the __Host- cookie hash before the code exchange; absent hash passes (pre-upgrade tokens). if (tokenData.browserNonceHash) { if (!browserSecretMatches(readBrowserSecret(request), tokenData.browserNonceHash)) { logger?.warn?.(`OAuth callback: login browser binding mismatch (provider '${providerName}')`); @@ -289,8 +276,7 @@ export async function handleCallback( } } - // Handle upstream IdP errors now that the state is consumed and all binding - // checks have passed. Using tokenData.originalUrl here mirrors 2.x. + // Handle provider errors after state consumption and binding verification. if (error) { // JSON.stringify: CRLF-safe logging of browser-controlled params (CWE-117). logger?.error?.(`OAuth error: ${JSON.stringify(error)} - ${JSON.stringify(errorDescription)}`); diff --git a/test/lib/browserBinding.test.js b/test/lib/browserBinding.test.js index c079c61..b1214d1 100644 --- a/test/lib/browserBinding.test.js +++ b/test/lib/browserBinding.test.js @@ -1,7 +1,4 @@ -/** - * Tests for the browser-binding cookie helpers (GHSA-xf67 backport). - */ - +// Tests for the browser-binding cookie helpers. import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { @@ -63,8 +60,7 @@ describe('browserBinding', () => { }); it('reads the secret when .get() returns an array of cookie crumbs (HTTP/2 crumbling)', () => { - // Harper 4 Headers (extends Map; append with commaDelimited) can store - // repeated Cookie fields as string[] — the binding cookie may be in any crumb. + // Harper 4 Headers can split Cookie across array crumbs — binding cookie may be in any. const secret = 'crumbled-secret'; const request = { headers: { @@ -90,7 +86,6 @@ describe('browserBinding', () => { }); it('returns the secret for a valid 43-char base64url value', () => { - // generateBrowserSecret() produces exactly 43 base64url chars from randomBytes(32) const secret = generateBrowserSecret(); assert.equal(secret.length, 43); assert.match(secret, /^[A-Za-z0-9_-]+$/); @@ -130,7 +125,6 @@ describe('browserBinding', () => { it('returns false without throwing when either argument is a non-string (number, object)', () => { assert.doesNotThrow(() => { - // Cast to any to simulate unexpected runtime types reaching the function assert.ok(!browserSecretMatches(/** @type {any} */ (42), hashBrowserSecret('x'))); assert.ok(!browserSecretMatches('x', /** @type {any} */ (42))); assert.ok(!browserSecretMatches(/** @type {any} */ ({ valueOf: () => 'x' }), hashBrowserSecret('x'))); diff --git a/test/lib/handlers.test.js b/test/lib/handlers.test.js index 52f76d9..d963c4c 100644 --- a/test/lib/handlers.test.js +++ b/test/lib/handlers.test.js @@ -256,8 +256,7 @@ describe('OAuth Handlers', () => { }); it('error= callback with a present state token consumes the token before returning (Fix 2)', async () => { - // State is present alongside the error — the token must be consumed - // (verifyCSRFToken called) so it cannot be replayed. + // state present: token must be consumed (single-use) even on error. mockTarget.get = createMockFn((key) => { if (key === 'error') return 'access_denied'; if (key === 'state') return 'csrf-token-123'; @@ -277,13 +276,12 @@ describe('OAuth Handlers', () => { // verifyCSRFToken must have been called (state consumed / single-use enforced) assert.equal(mockProvider.verifyCSRFToken.mock.calls.length, 1, 'state token must be consumed on error path'); assert.equal(result.status, 302); - // Redirect uses tokenData.originalUrl, not a hardcoded fallback assert.ok(result.headers.Location.includes('error=oauth_failed'), 'error code surfaced'); assert.ok(result.headers.Location.includes('reason=access_denied'), 'reason surfaced'); }); it('error= callback without state does not attempt token verification', async () => { - // No state → no token to consume; must still redirect with the error reason. + // No state: no token to consume; must still redirect with the error reason. mockTarget.get = createMockFn((key) => { if (key === 'error') return 'server_error'; return null; @@ -1134,8 +1132,7 @@ describe('OAuth Handlers', () => { const SECRET = 'login-binding-secret'; beforeEach(() => { - // Logged-out flow: no sessionId in the token (session binding has nothing - // to check — the exact gap the browser-secret cookie closes). + // Logged-out flow: no sessionId in the token, so only browser binding applies. mockProvider.verifyCSRFToken = createMockFn(async () => ({ originalUrl: '/dashboard', timestamp: Date.now(),