fix(auth): compare HEALTH_TOKEN in constant time via tokenMatches #30
No reviewers
Labels
No labels
autopilot:pending-human
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-clarification
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/vps-health-api#30
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-17-timing-safe-health-token"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fixes #17
What
The read path compared the bearer with
header ===+ template literal, which stops at the first differing byte and leaks the HEALTH_TOKEN's length and prefix through response time.checkAuth()now has a single comparison for both token kinds: exactBearerscheme check, thentokenMatches()(SHA-256 digests, thentimingSafeEqual, so lengths are always equal; cf. GHSA-376v-xgjx-7mfr). Only the expected token varies.Semantics unchanged: exact match, case-sensitive scheme, single space separator. Every pre-existing auth test (#12) passes untouched.
Tests (
__tests__/auth.test.js, 29 -> 67 cases)crypto.timingSafeEqual, set before a fresh require (index.js destructures it at load), requires every request to compare two 32-byte digests: 6 read routes x (shorter wrong token, same-length wrong token), the right token on/defenseurs, and the ingest route. This catches a short-circuiting===, a per-kind early return, and raw strings behind a length check.Bearertemplate literal or concatenation anywhere inindex.js; incheckAuth(), onetokenMatches(call, onereturn null, and every equality operator allowlisted (both operand orders); intokenMatches(), both sides hashed, onetimingSafeEqual, no.length.Bearer\t<token>,BearerX<token>); empty token pinned ontokenMatches("", TOKEN) === false(a trailing-spaceBeareris trimmed toBearerby the HTTP parser, already covered).Near-miss cases describe the contract but would also pass against a naive
===; the timing property is guarded by the spy and the pins.Mutation check
All restored from a scratchpad copy, diff empty, suite green after.
"Bearer " + expectedheader.slice(7) === expectedwant === header(review 1)if (!isIngest) return want === header ? ...(review 1)provided === expected(review 1)startsWith("Bearer")without the space (review 2)npm test: 301/301 (263 before this PR)bash test-curl.sh: 45/45. Port 3099 was held by another project'snext-serverlocally, so it was run with ports rewritten on the fly (3199/3198), script unchanged.Post-merge (not done by this PR)
/health,/defenseurs,/defenseurs/state,/defenseurs/findings?project=...,/reports/scans?date=...,/hosts: 200 with token, 401 without🤖 Generated with Claude Code
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>Review — APPROVE
Summary: The fix is correct and minimal.
checkAuth()now has a single comparison for both token kinds (Bearerscheme check, thentokenMatches()on SHA-256 digests). The semantics are unchanged: exact match and a case-sensitive scheme, and the fail-closed and 404-before-401 order is intact. Suite: 284/284 locally onf70a7e9.Checks
Fixes #17) is in the commit message. The commit follows conventional format.header.startsWith("Bearer ")short-circuits on the scheme only, which is public, so nothing leaks.slice(7)thentokenMatches: the hashing makes the lengths equal, sotimingSafeEqualnever throws.Reviewer mutations (on a copy outside the repo, tree left untouched)
isIngest ? ... : want === header(withwant = "Bearer " + expected)if (!isIngest) return want === header ? null : 401before the shared expressiontokenMatches()body reduced toreturn provided === expectedtokenMatches()without hashing:provided.length === expected.length && timingSafeEqual(raw)Suggestions (non-blocking)
checkAuth()callstokenMatches(), not whattokenMatches()does. A regression to===, or to rawtimingSafeEqualwith a length short-circuit (the exact case cited in GHSA-376v-xgjx-7mfr), passes the whole suite. Since the helper now protects every route, it is worth a pin on its body too: twocreateHash("sha256")calls, onetimingSafeEqual(, and no===/.lengthonprovided/expected. This gap predates the PR (the helper was already unpinned on the ingest path), but the new CLAUDE.md text ("la garde est une epingle sur le source") suggests broader coverage than there is.const want = "Bearer " + expected; want === header. The regex\bheader\s*[!=]==?only catchesheaderon the left. Covering both sides ([!=]==?\s*header\b) and requiring thatcheckAuth()contain exactly onereturn nullwould close this case cheaply. A structural pin will never be complete, so this is a best-effort hardening.auth.test.jsL120-121 now runs well past the usual width (a reflow is enough).Post-merge
Nothing to add to the PR checklist (manual Coolify redeploy, smoke on the 6 read routes, admin card).
Follow-up to review 16536 (commit on top of
f70a7e9).crypto.timingSafeEqual(set before a fresh require, since index.js destructures at load) requires every request to compare two 32-byte digests: 6 read routes x (short wrong token, same-length wrong token), right token on /defenseurs, and the ingest route. Structural: tokenMatches() must hash both sides, call timingSafeEqual once, no.length, no equality beyond the type guards. CLAUDE.md wording fixed.typeof header === "string",route.tokenKind === "ingest"), both operand orders, and exactly onereturn null.Mutations (restored from a scratchpad copy, diff empty, suite green after):
want === headerif (!isIngest) return want === header ? null : 401provided === expected"Bearer " + expectedbranchheader.slice(7) === expectedThe spy alone catches all seven.
npm test299/299,test-curl.sh45/45 (ports 3199/3198, 3099 held by another project locally).Review — APPROVE (head
5074f95)Summary:
checkAuth()has a single comparison path for both token kinds: theBearerscheme check, thentokenMatches()on SHA-256 digests. The follow-up commit closes the guard gaps from the earlier review with a spy ontimingSafeEqualplus source pins. Semantics are unchanged, the fail-closed paths and the routing-before-auth order are intact, and there are no secrets,.onlyor.skipin the diff. The commits follow conventional format, andFixes #17is in the message off70a7e9.Verification (my own mutations, on a scratchpad copy of the tree; the repo was not touched)
auth.test.jsmatch CLAUDE.md).header === \Bearer ${expected}``)if (!expected.startsWith(provided)) return false;before hashingreturn a.equals(b)instead oftimingSafeEqualBuffer.compare(a,b) === 0 || timingSafeEqual(a,b)timingSafeEqual(a,b) && provided.localeCompare(expected) === 0startsWith("Bearer")(space dropped)test-curl.shwas not rerun: port 3099 is held locally by another process. The socket-level surface is unchanged by this PR.Suggestions (non-blocking)
startsWith("Bearer"), the suite stays green, andBearer\t<token>orBearerX<token>would be accepted (slice(7)skips the 7th character, whatever it is). There is no timing leak, but it loosens the "exact match" contract. One["tab instead of space",Bearer\t${TOKEN}]case inMALFORMED(__tests__/auth.test.js:124) closes the gap.5074f95: it still says 284/284 and describes only the source pin as the guard. Update it to 299 and mention thetimingSafeEqualspy for the merge record.Post-merge
Items from the PR body: manual Coolify redeploy, then a smoke test on the 6 read routes (200 with the token / 401 without) and on the admin card.
Review — APPROVE
Summary:
checkAuth()now funnels both token kinds through a singletokenMatches()call (SHA-256 digests, thentimingSafeEqual), which removes the early-exit===onHEALTH_TOKEN. The observable semantics are unchanged: exactBearerscheme, case-sensitive, single space. The new tests do catch the defect when it is reintroduced.Verification (reviewer's own mutations, on a copy outside the repo)
f006a1c: 301/301 (auth.test.js: 67/67), no.only/.skip.index.jsfrommain(the old template-literal===): 15 red.if (!isIngest) return header === "Bearer " + expected ? null : …: 15 red.tokenMatches()on the raw strings with abyteLengthguard and thentimingSafeEqual(the GHSA-376v shape, written withbyteLengthso it slips past the.lengthpin): 15 red. The spy catches it where the source pin alone would not, so the two layers do complement each other.diffagainst the working tree is empty.test-curl.shwas not re-run: port 3099 is still held by anext-serverlocally (same as the PR body reports).Checklist
startsWith("Bearer ")check compares a public constant, not a secret, so it leaks nothing.Fixes #17is present in the message off70a7e9. Commits follow conventional commit format.Suggestions (non-blocking)
__tests__/auth.test.js,functionBody(): the pin's end boundary is the next^functionat the start of a line. If an arrow function (const x = () => …) is inserted betweencheckAuthand the next declaration, its equalities would count towardcheckAuth's allowlist. That would give a false red, not a false green, so it is acceptable.index.js(theCOPYlists files one by one).