Files
codegraph/telemetry-dashboard/src/auth.ts
T
Colby McHenryandClaude Fable 5 3cb774a5cd fix(telemetry-dashboard): accept Origin: null on login — our own no-referrer policy locked Chromium out (CG-16)
The dashboard sends Referrer-Policy: no-referrer, and Chromium's behavior
on a same-origin form submit from such a page is to send Origin: null.
isSameOriginPost() fed "null" to new URL(), which throws → false → 400
"bad request" for every Chromium user typing the correct password.

Treat a null Origin like an absent one: it is an unattributed origin, not
a foreign one — curl (no Origin at all) was always allowed, the login POST
carries no session to ride, and the password is the credential. Real
foreign origins stay rejected.

The regression net now posts the way Chromium actually does: the
smoke-auth sign-in and logout carry Origin: null, and cross-origin logout
gets its own rejection case (54 → 56 assertions). The suites missed this
because every passing login came from curl or Node fetch — neither sends
an Origin header — while render-check injects its cookie past the form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 16:48:24 -05:00

180 lines
6.7 KiB
TypeScript

/**
* Shared-password auth for the admin dashboard.
*
* Exactly two humans use this dashboard, so there is no user table, no auth
* provider and no session store: one password in a secret, and a long-lived
* HMAC-signed cookie so you sign in once per browser.
*
* Properties worth keeping if you touch this file:
* - the password is compared in constant time (over SHA-256 digests, so the
* lengths always match and the comparison leaks nothing about the secret);
* - the cookie is a signed assertion, not a lookup key — nothing is stored
* server-side, and a tampered payload fails the HMAC check;
* - the session is bound to a fingerprint of the password, so rotating
* ADMIN_PASSWORD invalidates every cookie already out there.
*/
const COOKIE_NAME = 'cg_admin_session';
/** ~1 year. Long-lived on purpose: two users, one password, sign in once. */
const SESSION_TTL_SECONDS = 365 * 24 * 60 * 60;
const SESSION_VERSION = 1;
const encoder = new TextEncoder();
const decoder = new TextDecoder();
interface SessionPayload {
v: number;
iat: number;
exp: number;
/** Fingerprint of the password this session was minted against. */
pw: string;
}
function base64UrlEncode(bytes: Uint8Array): string {
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function base64UrlDecode(text: string): Uint8Array | null {
try {
const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/'));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
} catch {
return null;
}
}
// Importing the HMAC key costs a round through WebCrypto; cache it per isolate.
// A secret rotation ships a new deployment, which means new isolates.
let cachedKey: { secret: string; key: CryptoKey } | null = null;
async function hmacKey(secret: string): Promise<CryptoKey> {
if (cachedKey?.secret === secret) return cachedKey.key;
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
cachedKey = { secret, key };
return key;
}
async function sign(secret: string, payload: string): Promise<Uint8Array> {
const signature = await crypto.subtle.sign('HMAC', await hmacKey(secret), encoder.encode(payload));
return new Uint8Array(signature);
}
/**
* Constant-time string equality. Both sides are hashed first so the digests are
* always the same length — `timingSafeEqual` throws on a length mismatch, and a
* throw would itself leak the length of the secret.
*/
async function equalsInConstantTime(a: string, b: string): Promise<boolean> {
const [digestA, digestB] = await Promise.all([
crypto.subtle.digest('SHA-256', encoder.encode(a)),
crypto.subtle.digest('SHA-256', encoder.encode(b)),
]);
return crypto.subtle.timingSafeEqual(digestA, digestB);
}
/** Short, non-reversible marker of the current password, embedded in the session. */
async function passwordFingerprint(password: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', encoder.encode(`cg-admin-pw${password}`));
return base64UrlEncode(new Uint8Array(digest).subarray(0, 8));
}
export async function checkPassword(env: Env, submitted: string): Promise<boolean> {
// A misconfigured deployment must not become an open dashboard.
if (!env.ADMIN_PASSWORD || !env.SESSION_SECRET) return false;
return equalsInConstantTime(submitted, env.ADMIN_PASSWORD);
}
export async function issueSession(env: Env): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const payload: SessionPayload = {
v: SESSION_VERSION,
iat: now,
exp: now + SESSION_TTL_SECONDS,
pw: await passwordFingerprint(env.ADMIN_PASSWORD),
};
const encoded = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
return `${encoded}.${base64UrlEncode(await sign(env.SESSION_SECRET, encoded))}`;
}
export async function hasValidSession(env: Env, request: Request): Promise<boolean> {
if (!env.ADMIN_PASSWORD || !env.SESSION_SECRET) return false;
const token = readSessionCookie(request);
if (!token) return false;
const dot = token.indexOf('.');
if (dot <= 0 || dot === token.length - 1) return false;
const encoded = token.slice(0, dot);
const provided = base64UrlDecode(token.slice(dot + 1));
if (!provided) return false;
const expected = await sign(env.SESSION_SECRET, encoded);
// Length is checked first: timingSafeEqual throws on mismatched lengths, and
// the length of an HMAC-SHA256 tag is public anyway.
if (provided.byteLength !== expected.byteLength) return false;
if (!crypto.subtle.timingSafeEqual(provided, expected)) return false;
const raw = base64UrlDecode(encoded);
if (!raw) return false;
let payload: SessionPayload;
try {
payload = JSON.parse(decoder.decode(raw)) as SessionPayload;
} catch {
return false;
}
if (payload?.v !== SESSION_VERSION) return false;
if (typeof payload.exp !== 'number' || payload.exp <= Math.floor(Date.now() / 1000)) return false;
// Rotating ADMIN_PASSWORD signs everyone out.
return payload.pw === (await passwordFingerprint(env.ADMIN_PASSWORD));
}
export function readSessionCookie(request: Request): string | null {
const header = request.headers.get('cookie');
if (!header) return null;
for (const part of header.split(';')) {
const cookie = part.trim();
if (cookie.startsWith(`${COOKIE_NAME}=`)) return cookie.slice(COOKIE_NAME.length + 1);
}
return null;
}
export function sessionCookie(token: string): string {
// Secure is accepted on http://localhost too, so `wrangler dev` still works.
return `${COOKIE_NAME}=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${SESSION_TTL_SECONDS}`;
}
export function clearedSessionCookie(): string {
return `${COOKIE_NAME}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`;
}
/**
* Rejects a cross-site form post. SameSite=Lax already blocks the cookie on a
* cross-site POST; this is the belt to that pair of braces.
*/
export function isSameOriginPost(request: Request): boolean {
const origin = request.headers.get('origin');
if (!origin) return true; // Absent on some legitimate non-browser clients (curl).
// "null" is what Chromium sends on a SAME-origin form submit when the page
// carries our own `Referrer-Policy: no-referrer` header — an unattributed
// origin, not a foreign one. Treat it like an absent header: rejecting it
// locks every Chromium user out of the login form, while an attacker who
// knows the password never needed a browser in the first place.
if (origin === 'null') return true;
try {
return new URL(origin).host === new URL(request.url).host;
} catch {
return false;
}
}