fix(auth): compare HEALTH_TOKEN in constant time via tokenMatches #30

Merged
maximus merged 3 commits from issue-17-timing-safe-health-token into main 2026-09-27 23:27:45 +00:00

3 commits

Author SHA1 Message Date
le king fu
f006a1c05e test(auth): pin the single space after the Bearer scheme
Second review of #30: `startsWith("Bearer")` followed by slice(7) would
accept any byte as the separator (`Bearer\t<token>`, `BearerX<token>`) and
the suite stayed green. Two MALFORMED cases now reject both; the mutation
turns them red.

Refs #17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:23:50 -04:00
le king fu
5074f95d0a test(auth): guard tokenMatches itself and close the pin's blind spots
Review of #30 found four mutations that left the suite green: an early
success return for the read kind, a reversed `want === header` comparison,
and tokenMatches() reduced to `===` or to raw strings behind a length check
(the GHSA-376v-xgjx-7mfr shape).

Behavioural guard: a spy on crypto.timingSafeEqual, set before a fresh
require of index.js, requires every request (6 read routes, short and
same-length wrong tokens, the right token, the ingest route) to compare two
32-byte digests. Source pins now also allowlist every equality operator in
checkAuth() (both operand orders), require a single `return null`, and check
that tokenMatches() hashes both sides without a length test.

All seven mutations (the reviewer's four, the original three) now turn
13-15 tests red. CLAUDE.md no longer claims the pin covers the helper.

Refs #17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:13:16 -04:00
le king fu
f70a7e928f fix(auth): compare HEALTH_TOKEN in constant time via tokenMatches
The read path still compared the bearer with `header === \`Bearer ${token}\``,
which stops at the first differing byte and leaks the token's length and
prefix through response time. checkAuth() now has a single comparison for
both token kinds: `Bearer ` scheme check, then tokenMatches() (SHA-256
digests, then timingSafeEqual, so lengths are always equal). Only the
expected token varies.

Semantics are unchanged: exact match, case-sensitive scheme. Every existing
auth test passes untouched.

Tests: near-miss tokens (prefix, suffix, double space) on all six read
routes, an empty-token assertion on the helper, and a source pin that
forbids direct equality against a Bearer string and requires exactly one
tokenMatches() call in checkAuth(). A timing test would be flaky, so the pin
is the regression guard: three mutations back to direct comparison each
turn it red.

Fixes #17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:06:04 -04:00