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>
180 lines
6.7 KiB
TypeScript
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 |