Backport login-CSRF fix (GHSA-xf67-jxfx-jf88) to 1.x (Harper 4) - #215
Backport login-CSRF fix (GHSA-xf67-jxfx-jf88) to 1.x (Harper 4)#215heskew wants to merge 4 commits into
Conversation
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 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Reviewed; no blockers found. |
There was a problem hiding this comment.
Code Review
This pull request introduces a stable per-browser secret cookie (__Host-oauth_browser) to bind the human login flow to the initiating browser, mitigating login-CSRF attacks for logged-out requests. It adds helpers for generating, hashing, reading, and matching the secret, integrates them into the login and callback handlers, and includes comprehensive unit tests. The review feedback suggests validating the format and length of the read cookie value to prevent CPU exhaustion attacks from hashing excessively long inputs, and defensively verifying that both arguments in browserSecretMatches are strings to avoid runtime TypeErrors.
| if (part.slice(0, eq).trim() === BROWSER_SECRET_COOKIE_NAME) { | ||
| return part.slice(eq + 1).trim() || undefined; | ||
| } |
There was a problem hiding this comment.
To prevent potential CPU exhaustion attacks from hashing extremely long cookie values, and to ensure we only reuse well-formed secrets, we should validate the format and length of the cookie value before returning it. Since the secret is generated as a 32-byte cryptographically secure base64url string (which is 43 characters), we can enforce a safe regex pattern (e.g., /^[A-Za-z0-9_-]{1,64}$/) to allow standard test mocks while rejecting any excessively long or malformed inputs.
if (part.slice(0, eq).trim() === BROWSER_SECRET_COOKIE_NAME) {
const value = part.slice(eq + 1).trim();
return /^[A-Za-z0-9_-]{1,64}$/.test(value) ? value : undefined;
}| 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); |
There was a problem hiding this comment.
In JavaScript/TypeScript, any value can be thrown or passed at runtime. If secret or expectedHash is not a string (e.g., due to unexpected database values or malformed inputs), calling Buffer.from or hashBrowserSecret on them could throw a runtime TypeError, potentially leading to an unhandled exception in the callback handler. We should defensively verify that both arguments are non-empty strings before performing the constant-time comparison.
| 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); | |
| export function browserSecretMatches(secret: string | undefined, expectedHash: string | undefined): boolean { | |
| 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); | |
| } |
References
- For functions that guarantee a 'never throws' contract (such as cryptographic verification or key selection), defensively guard against null, primitive, or malformed elements before accessing their properties to prevent runtime TypeErrors.
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What this ports
This is the 1.x / Harper 4 backport of the login-CSRF fix already released on the 2.x line in v2.5.0 under GHSA-xf67-jxfx-jf88.
Two security items and one low-severity hardening fix are included:
Browser-secret binding (
__Host-oauth_browsercookie) —handleLoginmints or reuses a stable per-browser secret, storeshash(secret)asbrowserNonceHashin the CSRF state, andhandleCallbackverifies the hash constant-time before any code exchange or session write. Tokens minted before the upgrade (in-flight at deploy time) pass through without the check. Implemented in the new self-containedsrc/lib/browserBinding.tsmodule.State↔session binding — the existing session-id binding introduced in #185 is preserved unchanged (
tokenData.sessionId && tokenData.sessionId !== request.session?.id).CRLF log injection fix (CWE-117) — browser-controlled
erroranderror_descriptioncallback parameters are now passed throughJSON.stringifybefore logging.Scope
This branch contains only the security backport. The
peerDependencies.harperdbengine-pin tightening (>=4.6.0 <5.0.0) is tracked separately in #214 and is not included here; thepackage.jsonin this branch is identical to 1.6.1's.Version bump and release steps are handled separately — this PR targets
v1.xas a pre-release checkpoint.Test results
All 388 non-skipped tests pass. The 2 skipped tests are pre-existing environment-dependent skips (Harper table availability in unit context).
Verification
Build:
npm run build— clean (tsc emits no errors).Tests:
npm teston the worktree — 388/388 passing.Complexity: Low — isolated module addition + targeted handler changes; no new external dependencies.