Discovered during review of #2083.
Files:
packages/api/src/utils/auth.ts:121 — apiKeyHeader === PACKRAT_API_KEY
packages/api/src/routes/admin/index.ts:24 — password === env.ADMIN_PASSWORD, username === env.ADMIN_USERNAME
Both comparisons use plain === against a secret. An attacker with enough samples can derive the secret byte-by-byte via response-time timing differences.
Fix: use a constant-time compare. Cloudflare Workers has WebCrypto:
function timingSafeEqual(a: string, b: string): boolean {
const ab = new TextEncoder().encode(a);
const bb = new TextEncoder().encode(b);
if (ab.byteLength !== bb.byteLength) return false;
let out = 0;
for (let i = 0; i < ab.byteLength; i++) out |= ab[i] ^ bb[i];
return out === 0;
}
Length-equalize before compare to avoid leaking length differences.
Related: #2083 (middleware was touched by the rewrite; admin/index.ts was reduced 906→580 LOC — HTMX refactor tracked elsewhere, this timing issue is separate and still present)
Test requirement: cannot directly unit-test timing, but lint/CI can assert no === comparison against env secrets.
Discovered during review of #2083.
Files:
packages/api/src/utils/auth.ts:121—apiKeyHeader === PACKRAT_API_KEYpackages/api/src/routes/admin/index.ts:24—password === env.ADMIN_PASSWORD,username === env.ADMIN_USERNAMEBoth comparisons use plain
===against a secret. An attacker with enough samples can derive the secret byte-by-byte via response-time timing differences.Fix: use a constant-time compare. Cloudflare Workers has WebCrypto:
Length-equalize before compare to avoid leaking length differences.
Related: #2083 (middleware was touched by the rewrite;
admin/index.tswas reduced 906→580 LOC — HTMX refactor tracked elsewhere, this timing issue is separate and still present)Test requirement: cannot directly unit-test timing, but lint/CI can assert no
===comparison against env secrets.