Skip to content

Backport login-CSRF fix (GHSA-xf67-jxfx-jf88) to 1.x (Harper 4) - #215

Draft
heskew wants to merge 4 commits into
v1.xfrom
backport/ghsa-xf67-1x
Draft

Backport login-CSRF fix (GHSA-xf67-jxfx-jf88) to 1.x (Harper 4)#215
heskew wants to merge 4 commits into
v1.xfrom
backport/ghsa-xf67-1x

Conversation

@heskew

@heskew heskew commented Aug 25, 2026

Copy link
Copy Markdown
Member

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_browser cookie)handleLogin mints or reuses a stable per-browser secret, stores hash(secret) as browserNonceHash in the CSRF state, and handleCallback verifies 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-contained src/lib/browserBinding.ts module.

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 error and error_description callback parameters are now passed through JSON.stringify before logging.

Scope

This branch contains only the security backport. The peerDependencies.harperdb engine-pin tightening (>=4.6.0 <5.0.0) is tracked separately in #214 and is not included here; the package.json in this branch is identical to 1.6.1's.

Version bump and release steps are handled separately — this PR targets v1.x as a pre-release checkpoint.

Test results

tests 390 | pass 388 | fail 0 | cancelled 0 | skipped 2

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 test on the worktree — 388/388 passing.

Complexity: Low — isolated module addition + targeted handler changes; no new external dependencies.

heskew and others added 2 commits August 24, 2026 13:05
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>
@github-actions

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/lib/browserBinding.ts
Comment on lines +78 to +80
if (part.slice(0, eq).trim() === BROWSER_SECRET_COOKIE_NAME) {
return part.slice(eq + 1).trim() || undefined;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

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;
}

Comment thread src/lib/browserBinding.ts
Comment on lines +86 to +90
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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
  1. 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.

heskew and others added 2 commits August 25, 2026 16:35
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant