Compare commits

...

1 Commits

Author SHA1 Message Date
Xiangzhe
6e112d4b4f fix(security): compare the OIDC state cookie in constant time
GHSA-7434-6q4c-33fh: the OIDC callback validated the CSRF `state` cookie with
`storedState !== returnedState`. `!==` short-circuits on the first differing
byte, so rejection time correlates with matching-prefix length (CWE-208).

The reporter scopes this honestly and so do we: `oidc_state` is a single-use
per-login nonce, cleared immediately after validation, and recovering it would
not by itself yield an authorization code. The reason to fix it is that this was
the one callback in the repo still comparing a secret with `!==` — every sibling
(the OAuth callback, the A2A token check, Telegram initData, the CLI token
check) already compares in constant time — and an inconsistent pattern is one
that gets copied into a context where it does matter.

Which is exactly what had already happened: `isInternalAdmissionBypass()` gated
an admission-lane bypass on `match[1].toLowerCase() === resolveSelfLoopBearer()
.toLowerCase()`. That one guards a shared secret with real consequences, so it
is fixed here too.

Both now use `timingSafeCompare()` (src/shared/utils/timingSafeCompare.ts). The
five lines it wraps were already copy-pasted into at least eight places; this is
the shared one. The existing copies are left alone — consolidating them is a
refactor, not a security fix, and does not belong in this diff.

tests/unit/timing-safe-compare.test.ts — 6 tests, the two callsite guards red
before the fix. The helper is covered for equal, differing-same-length,
differing-length, null/undefined identity, and byte-exactness (precomposed vs
decomposed "é" must not match).

Closes GHSA-7434-6q4c-33fh
2026-08-26 11:31:55 -03:00
4 changed files with 98 additions and 2 deletions

View File

@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getCachedSettings, updateSettings } from "@/lib/localDb";
import { SignJWT, jwtVerify, createRemoteJWKSet } from "jose";
import { cookies } from "next/headers";
import { timingSafeCompare } from "@/shared/utils/timingSafeCompare";
// Test seam (static) — allows tests to inject a cookie store and capture the minted auth_token.
// Mirrors the pattern in src/app/api/auth/login/route.ts
export const oidcCallbackInternals = {
@@ -54,7 +55,10 @@ export async function GET(request: Request) {
// Validate state from cookie (via seam so tests can capture)
const cookieStore = await oidcCallbackInternals.getCookieStore();
const storedState = cookieStore.get("oidc_state")?.value;
if (!storedState || storedState !== returnedState) {
// Constant-time: `!==` short-circuits on the first differing byte, so
// rejection time correlates with matching-prefix length (GHSA-7434-6q4c-33fh).
// The sibling OAuth callback already compares `state` this way.
if (!storedState || !timingSafeCompare(storedState, returnedState)) {
return NextResponse.redirect(new URL("/login?oidc_error=invalid_state", originEarly));
}

View File

@@ -1,4 +1,5 @@
import { createHmac } from "crypto";
import { timingSafeCompare } from "@/shared/utils/timingSafeCompare";
const ADMISSION_BYPASS_VALUE = "internal";
const SELF_LOOP_KEY = "sk_omniroute";
@@ -31,7 +32,10 @@ export function isInternalAdmissionBypass(request: Request): boolean {
const auth = request.headers.get("authorization") || "";
const match = /^bearer\s+(\S+)$/i.exec(auth.trim());
return Boolean(match && match[1].trim().toLowerCase() === resolveSelfLoopBearer().toLowerCase());
if (!match) return false;
// This gates an admission-lane bypass on a shared secret, so the compare is
// constant-time — `===` leaks matching-prefix length (GHSA-7434 class).
return timingSafeCompare(match[1].trim().toLowerCase(), resolveSelfLoopBearer().toLowerCase());
}
function fingerprint(value: string): string {

View File

@@ -0,0 +1,27 @@
import { timingSafeEqual } from "crypto";
/**
* Constant-time string comparison for secrets, tokens and single-use nonces.
*
* `===` short-circuits on the first differing byte, so rejection time
* correlates with how much of the value the caller already guessed (CWE-208).
* That is the comparison this repo already avoids in every OAuth callback, the
* A2A token check, the Telegram initData HMAC and the CLI token check — each of
* which grew its own private copy of these five lines. This is the shared one:
* reach for it instead of writing a ninth copy, and instead of `===`.
*
* Length is not secret here (it leaks through the early return, as it does in
* every other copy) — the value being protected is the content, not its size.
* `null`/`undefined` compare by identity so a missing secret never matches a
* present one.
*/
export function timingSafeCompare(
a: string | null | undefined,
b: string | null | undefined
): boolean {
if (a == null || b == null) return a === b;
const bufA = Buffer.from(String(a), "utf8");
const bufB = Buffer.from(String(b), "utf8");
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}

View File

@@ -0,0 +1,61 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { timingSafeCompare } from "../../src/shared/utils/timingSafeCompare.ts";
// GHSA-7434-6q4c-33fh — the OIDC callback compared the CSRF `state` cookie with
// `!==` while every sibling callback already used a constant-time compare. Low
// severity on its own (single-use nonce), but the pattern gets copied, so the
// guard below pins the two callsites to the shared helper.
test("timingSafeCompare accepts identical values", () => {
assert.equal(timingSafeCompare("abc123", "abc123"), true);
assert.equal(timingSafeCompare("", ""), true);
});
test("timingSafeCompare rejects different values, including same-length ones", () => {
assert.equal(timingSafeCompare("abc123", "abc124"), false);
assert.equal(timingSafeCompare("abc123", "xbc123"), false);
assert.equal(timingSafeCompare("abc", "abcdef"), false);
assert.equal(timingSafeCompare("abcdef", "abc"), false);
});
test("timingSafeCompare compares null/undefined by identity, never as a match", () => {
assert.equal(timingSafeCompare(null, null), true);
assert.equal(timingSafeCompare(undefined, undefined), true);
assert.equal(timingSafeCompare(null, undefined), false);
assert.equal(timingSafeCompare(null, "abc"), false);
assert.equal(timingSafeCompare("abc", undefined), false);
assert.equal(timingSafeCompare(undefined, ""), false);
});
test("timingSafeCompare is byte-exact, not unicode-normalizing", () => {
// "é" precomposed vs decomposed — different bytes, must not match.
assert.equal(timingSafeCompare("é", "é"), false);
});
function sourceOf(relPath: string): string {
return readFileSync(fileURLToPath(new URL(`../../${relPath}`, import.meta.url)), "utf8");
}
test("the OIDC callback validates `state` with the constant-time helper", () => {
const source = sourceOf("src/app/api/auth/oidc/callback/route.ts");
assert.ok(
source.includes("timingSafeCompare"),
"oidc/callback must compare the state cookie in constant time (GHSA-7434-6q4c-33fh)"
);
assert.ok(
!/storedState\s*!==\s*returnedState/.test(source),
"the short-circuiting `!==` state comparison is back"
);
});
test("the internal admission bypass compares its bearer in constant time", () => {
const source = sourceOf("src/shared/middleware/chatAdmissionIdentity.ts");
assert.ok(
source.includes("timingSafeCompare"),
"isInternalAdmissionBypass gates a bypass on a shared secret — compare it in constant time"
);
});