-
Notifications
You must be signed in to change notification settings - Fork 1
Backport login-CSRF fix (GHSA-xf67-jxfx-jf88) to 1.x (Harper 4) #215
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+503
−18
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
dafbf14
backport GHSA-xf67 login-CSRF fix to 1.x (Harper 4)
heskew 5ae2b6e
revert package.json peer dep change — handled separately
heskew 72638c9
fix(security): close three GHSA-xf67 regressions on 1.x backport
heskew 93223da
style: prettier-format the GHSA-xf67 backport fixes
heskew 04c4705
fix(security): validate browser-secret cookie value and guard non-str…
heskew 5dccb40
style: trim browser-binding comments to essentials
heskew File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| /** | ||
| * Browser binding via stable per-browser `__Host-oauth_browser` cookie. | ||
| * | ||
| * 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'; | ||
| 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; | ||
|
|
||
| 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 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`; | ||
| } | ||
|
|
||
| /** 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; | ||
| // Harper 4 runtime: Headers object with .get() | ||
| 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 | ||
| const raw = headers.cookie; | ||
| if (raw == null) return undefined; | ||
| return Array.isArray(raw) ? raw.join('; ') : String(raw); | ||
| } | ||
|
|
||
| /** 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; | ||
| for (const part of header.split(';')) { | ||
| const eq = part.indexOf('='); | ||
| if (eq === -1) continue; | ||
| 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; | ||
| } | ||
|
heskew marked this conversation as resolved.
|
||
| } | ||
| return undefined; | ||
| } | ||
|
|
||
| /** Constant-time check that `secret` hashes to `expectedHash`. */ | ||
| 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); | ||
|
heskew marked this conversation as resolved.
heskew marked this conversation as resolved.
|
||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| // Tests for the browser-binding cookie helpers. | ||
| 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('reads the secret when .get() returns an array of cookie crumbs (HTTP/2 crumbling)', () => { | ||
| // Harper 4 Headers can split Cookie across array crumbs — binding cookie may be in any. | ||
| 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); | ||
| assert.equal(readBrowserSecret(undefined), undefined); | ||
| }); | ||
|
|
||
| it('returns the secret for a valid 43-char base64url value', () => { | ||
| 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', () => { | ||
| 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')); | ||
| }); | ||
|
|
||
| it('returns false without throwing when either argument is a non-string (number, object)', () => { | ||
| assert.doesNotThrow(() => { | ||
| 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'))); | ||
| }); | ||
| }); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.