fix(orchestrator): constant-time bearer-token check + rotation slot (#76) - #103
Conversation
) Replace the plain `!==` comparison in the daemon WebSocket upgrade handler with `crypto.timingSafeEqual` over length-padded buffers. JavaScript string equality short-circuits on the first mismatched byte and leaks the matching prefix length via response latency, allowing a network-adjacent attacker to recover `DAEMON_AUTH_TOKEN` byte-by-byte and register a malicious daemon (which receives per-job GitHub App installation tokens carrying contents:write / issues:write). Also add an optional `DAEMON_AUTH_TOKEN_PREVIOUS` rotation slot so operators can rotate the secret without a synchronised fleet restart. Both branches of the comparator are evaluated unconditionally and combined with a bitwise OR so timing cannot distinguish "rejected by primary" from "rejected by previous". Closes #76 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
bot workflow 🔍 Code review complete — 7 files, +286/-48. Review — PR #103 (
|
|
bot workflow 🔎 Resolve iteration complete — 0 failing checks, 1 open comment threads (some may already be resolved). SummaryPR #103 ( CI status
Review comments
Commits pushed
OutstandingNothing blocking from the resolve workflow's perspective. The PR is awaiting human review/approval ( cost: $3.2415 · turns: 5 · duration: 26s |
#76) Add `daemonAuthTokenPrevious` to both `REDACT_PATHS` and `SENSITIVE_FIELD_NAMES_LC` so the new rotation-window slot inherits the same path-based + structural-walker redaction as the primary `daemonAuthToken`. Without this, a future `logger.info({ config }, ...)` or an Octokit error carrying both fields under `request.headers.*` would emit the previous token in cleartext while the primary stayed redacted — the asymmetry the existing list relies on. Two regression tests lock the contract: one for top-level fields and one for the structural walker via `err.response.data`. Co-authored-by: chrisleekr-bot[bot] <chrisleekr-bot[bot]@users.noreply.github.com>
|
bot workflow 🔍 Code review complete — 9 files, +331/-48. Review — PR #103 (
|
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
Important Review skippedBot user detected. To trigger a single review, invoke the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThis PR implements issue ChangesDaemon WebSocket Bearer Token Security & Rotation
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
This PR hardens the orchestrator’s daemon WebSocket authentication by replacing a timing-leaky bearer token string comparison with a constant-time timingSafeEqual-based comparator, and adds an optional previous-token rotation slot to support safe secret rotation with an overlap window.
Changes:
- Implement constant-time bearer-token validation for the
/wsupgrade path, accepting either the primary token or an optional previous token for rotation. - Extend configuration and docs to support
DAEMON_AUTH_TOKEN_PREVIOUS, including an operational runbook for rotating the daemon fleet. - Add regression tests for auth header shapes and extend log redaction coverage to include the new previous-token field.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/orchestrator/ws-server.ts |
Adds isAuthHeaderValid() using timingSafeEqual and wires it into the WebSocket upgrade auth check. |
src/config.ts |
Adds optional daemonAuthTokenPrevious and maps DAEMON_AUTH_TOKEN_PREVIOUS from env. |
src/logger.ts |
Adds redaction coverage for daemonAuthTokenPrevious in both path-based and structural redaction. |
test/orchestrator/ws-server.test.ts |
Adds regression tests covering missing/short/wrong/long auth headers and previous-token acceptance. |
test/utils/logger.test.ts |
Adds tests ensuring both daemon auth tokens are redacted, including nested error payload cases. |
docs/operate/configuration.md |
Documents DAEMON_AUTH_TOKEN_PREVIOUS and links to the rotation runbook section. |
docs/operate/runbooks/daemon-fleet.md |
Adds a rotation procedure (with sequence diagram) for DAEMON_AUTH_TOKEN. |
.env.example |
Documents the new optional DAEMON_AUTH_TOKEN_PREVIOUS env var. |
IMPLEMENT.md |
Updates implementation tracking summary/evidence for issue #76. |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@IMPLEMENT.md`:
- Line 7: Update the inline code span on the documented line so it has no
leading or trailing spaces inside the backticks (fix MD038); specifically
normalize the inline span that references authHeader !== `Bearer ${authToken}`
and DAEMON_AUTH_TOKEN so there are no internal edge spaces, save the markdown,
and re-run markdownlint to verify the MD038 warning is gone.
In `@src/config.ts`:
- Around line 282-288: The daemonAuthTokenPrevious schema currently allows
whitespace-only strings; update the zod schema for daemonAuthTokenPrevious so
whitespace-only values become undefined (treated as unset). Replace the simple
z.string().optional() with a transform/refinement that trims the value and
returns undefined for empty results (e.g. z.string().optional().transform(v => {
if (typeof v !== 'string') return undefined; const t = v.trim(); return t === ''
? undefined : t; })). This ensures downstream logic (e.g. isAuthHeaderValid)
never sees a whitespace-only previous token.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: cab21761-d84f-4971-bc6f-3ae4aa1f02ce
📒 Files selected for processing (9)
.env.exampleIMPLEMENT.mddocs/operate/configuration.mddocs/operate/runbooks/daemon-fleet.mdsrc/config.tssrc/logger.tssrc/orchestrator/ws-server.tstest/orchestrator/ws-server.test.tstest/utils/logger.test.ts
|
bot workflow 🔎 Resolve iteration complete — 0 failing checks, 5 open comment threads (some may already be resolved). Resolve iteration — PR #103 (
|
| # | File:line | Author | Classification | Action taken | Commit / Reply | Resolved? |
|---|---|---|---|---|---|---|
| 1 | src/orchestrator/ws-server.ts:51 |
copilot-pull-request-reviewer | Valid | Hoisted the precomputed Bearer … padded buffers, original byte lengths, and shared padLength out of the per-request comparator into a new buildAuthExpectations() builder called once at server start. The fetch handler now passes a frozen AuthExpectations object into isAuthHeaderValid(), eliminating two Buffer.from(\Bearer …`, "utf8")` allocations per upgrade attempt. Reduces auth-flood amplification. |
commit 953fd7f · reply r3184820977 |
✅ resolved (PRRT_kwDORS-R1M5_ekVi) |
| 2 | src/orchestrator/ws-server.ts:63 |
copilot-pull-request-reviewer | Valid | Bounded per-request comparator work to O(padLength). The previous Buffer.from(authHeader ?? "", "utf8") allocated proportional to the (attacker-controlled) header length and undermined the doc-comment "work bounded to expected token length" claim. Replaced with Buffer.byteLength(headerStr, "utf8") for the original-length check (no buffer alloc) and actual.write(headerStr, 0, padLength, "utf8") to copy at most padLength bytes into a fixed-size padded buffer. The longer-with-correct-prefix attack is still rejected by the explicit length-equality guard. |
commit 953fd7f · reply r3184821764 |
✅ resolved (PRRT_kwDORS-R1M5_ekWF) |
| 3 | IMPLEMENT.md:7 |
coderabbitai | Partially Valid | Confirmed the readability problem (backtick-adjacent text running together) but the literal-space patch was clobbered on save by prettier --write, which reflows the line back to the no-space form because the surrounding \authHeader !== \`Bearer ${authToken}\``nested escaped-backtick code span confuses prettier's CommonMark walker. Rewrote the sentence to drop the verbatim-comparison code span, refer to the secret as "the daemon auth token" in prose, and splitcontents:write/issues:writewith anandso both code spans have plain whitespace on either side.bunx prettier --check IMPLEMENT.md` now passes. |
commit 953fd7f · reply r3184822797 |
✅ resolved (PRRT_kwDORS-R1M5_emeW) |
| 4 | src/config.ts:288 |
coderabbitai | Valid | Switched daemonAuthTokenPrevious schema from z.string().optional() to the existing nonEmptyOptionalString helper. A whitespace-only env value (e.g. an empty Secret key whose decrypted value is "" or " ") is now coerced to undefined at parse time rather than surviving as a "valid" but effectively-empty Bearer credential after Bearer is prepended in the comparator. The existing length > 0 check in the comparator was the wrong invariant. |
commit 953fd7f · reply r3184823481 |
✅ resolved (PRRT_kwDORS-R1M5_emeh) |
A 5th thread (PRRT_kwDORS-R1M5_XYyU on src/config.ts:288 — the prior-iteration redaction-symmetry finding) was already resolved when this iteration started; no action taken.
Verification commands run locally before push
bun run typecheck— pass (clean exit).bunx eslint src/orchestrator/ws-server.ts src/config.ts— 0 errors / 1 pre-existing warning (regex onsrc/config.ts:28, unrelated to this PR).bunx prettier --check IMPLEMENT.md src/orchestrator/ws-server.ts src/config.ts— All matched files use Prettier code style.bun test test/orchestrator/ws-server.test.ts test/utils/logger.test.ts test/config.test.ts— 71 pass / 0 fail; per-file coverage showssrc/orchestrator/ws-server.tsat 100% line / 100% function (test count unchanged because the public API of the comparator is exercised through the realBun.servefetch handler).bun run scripts/check-docs-versions.ts— OK.bun run scripts/check-docs-citations.ts— OK.
Commits pushed
953fd7f·fix(orchestrator): bound auth comparator work + reject whitespace-only previous token (#76)
Outstanding
Nothing blocking from the resolve workflow's perspective. The PR is awaiting human review/approval (reviewDecision empty — no formal approve/request-changes yet). Per FR-017 the bot does not merge.
cost: $7.7021 · turns: 5 · duration: 72s
…y previous token (#76) Three resolve-iteration fixes addressing review feedback on PR #103: 1. Hoist precomputed `Bearer <token>` buffers and `padLength` out of the per-request `isAuthHeaderValid()` into a `buildAuthExpectations()` builder called once at server start, so each upgrade attempt no longer re-encodes the expected primary/previous tokens. Reduces auth-flood amplification. 2. Bound per-request comparator work to `O(padLength)`. The previous implementation called `Buffer.from(authHeader ?? "", "utf8")` which allocated proportional to the (attacker-controlled) header length — undermining the "work bounded to expected token length" claim. Replace with `Buffer.alloc(padLength)` + `actual.write(..., padLength, ...)`, and use `Buffer.byteLength(headerStr, "utf8")` for the length-equality check instead of allocating a buffer sized to the header. 3. Switch `daemonAuthTokenPrevious` schema from `z.string().optional()` to the existing `nonEmptyOptionalString` helper so a whitespace-only env value (e.g. an empty Secret key like `""` or `" "`) is coerced to `undefined` rather than surviving as a "valid" but empty Bearer credential after `Bearer ` is prepended. Also reword the IMPLEMENT.md summary line so prettier-markdown stops fighting the nested escaped-backtick code span and the rendered prose flows cleanly without backtick-adjacent text collisions. Co-authored-by: chrisleekr-bot[bot] <chrisleekr-bot[bot]@users.noreply.github.com>
…#76) Senior-review follow-up to cdc4834. Eliminates the residual side channel where `isAuthHeaderValid` ran one `timingSafeEqual` when `DAEMON_AUTH_TOKEN_PREVIOUS` was unset and two when it was set, leaking rotation posture (not the secret). The previous slot now always allocates a zero-filled sentinel buffer with `previousLength = -1` (`Buffer.byteLength` is always non-negative, so the length-equality guard rejects unconditionally), letting the comparator run exactly two `timingSafeEqual` calls per request regardless of rotation state. Also adds a startup `logger.warn` when `DAEMON_AUTH_TOKEN_PREVIOUS` equals `DAEMON_AUTH_TOKEN`, since that is a no-op rotation overlap an operator should know about. Tightens the `isAuthHeaderValid` JSDoc: per-request copy work is bounded by `padLength`, but `Buffer.byteLength(headerStr, "utf8")` walks the full header — bounded in practice by Bun's HTTP header limit, not by `padLength`. Pins the two `accepts the primary token` / `accepts the previous token` test assertions from `.not.toBe(401)` to `.toBe(500)` so a future refactor of the upgrade-fallback path that returns a different status while still accepting bad credentials trips the test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| * re-encoding two `Bearer …` strings on every call. Also caps auth-flood | ||
| * amplification: a malicious client with a very long `Authorization` | ||
| * header still triggers only fixed-size work in the comparator. |
|
🎉 This PR is included in version 1.10.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Summary
Closes a timing-attack surface on the orchestrator's daemon WebSocket handshake (#76). The previous
authHeader !== \Bearer ${authToken}`short-circuited on the first mismatched byte, leaking the matching prefix ofDAEMON_AUTH_TOKENvia response latency and allowing a network-adjacent attacker to recover the secret byte-by-byte. A recovered token would let the attacker register a malicious daemon and harvest per-job GitHub App installation tokens (contents:write/issues:write`).The fix swaps the comparison for a
crypto.timingSafeEqual-based comparator over length-padded buffers and adds an optionalDAEMON_AUTH_TOKEN_PREVIOUSrotation slot so operators can rotate the secret without a synchronised fleet restart.Changes
src/orchestrator/ws-server.ts. New internalisAuthHeaderValid()helper pads buffers to a fixed length, callscrypto.timingSafeEqualfor both primary and previous tokens unconditionally, and combines results with bitwise|(no JS short-circuit). Wired into the upgrade handler in place of!==. Preserves the existing 401 response shape andlogger.warnpayload so log shippers and the daemon reconnect path are unaffected.DAEMON_AUTH_TOKEN_PREVIOUSenv var (src/config.ts). Orchestrator accepts either the primary or the previous token; daemons keep sending the primary.validateDataLayerConfigcontinues to require only the primary, so existing deployments are unaffected.test/orchestrator/ws-server.test.tsexercising every header shape (missing, shorter, equal-length-different-bytes, longer prefix-collision, primary accept, previous-token accept) through the realBun.servefetch handler.docs/operate/configuration.mdadds the new env var;docs/operate/runbooks/daemon-fleet.mdadds a "RotatingDAEMON_AUTH_TOKEN" runbook section with a sequence diagram and 5-step overlap-window procedure (90-day cadence per OWASP Secrets Management).Files changed
src/orchestrator/ws-server.ts· adds the constant-time comparator and replaces the vulnerable!==check.src/config.ts· addsdaemonAuthTokenPreviouszod field +DAEMON_AUTH_TOKEN_PREVIOUSenv mapping.test/orchestrator/ws-server.test.ts· six new regression cases underdescribe("WebSocket auth (constant-time bearer comparator, #76)").docs/operate/configuration.md·DAEMON_AUTH_TOKEN_PREVIOUSrow + constant-time note + runbook link.docs/operate/runbooks/daemon-fleet.md· new "RotatingDAEMON_AUTH_TOKEN" section with mermaid sequence diagram..env.example· documents the new env var.IMPLEMENT.md· tracking summary for the bot workflow.Commits
cdc4834·fix(orchestrator): constant-time bearer-token check + rotation slot (#76)329233d·docs(implement): add tracking summary for issue #76Tests run
bun run typecheck· pass (clean exit)bunx eslint src/orchestrator/ws-server.ts src/config.ts test/orchestrator/ws-server.test.ts· 0 errors / 10 pre-existing warningsbun run format· all files pass Prettierbun test test/orchestrator/ws-server.test.ts· 17 pass / 0 fail (11 pre-existing + 6 new);src/orchestrator/ws-server.tsreports 100% line coveragebun test test/config.test.ts· 40 pass / 0 failbun run scripts/check-docs-versions.ts· OKbun run scripts/check-docs-citations.ts· OKbun test(full suite) · 535 pass / 153 skip / 194 fail — verified bygit stash+ re-run that all 194 failures are pre-existing onmain(Postgres / Valkey / removed-test-imports infra) and not introduced by this PRbun run docs:build· not run locally (mkdocs/Python not installed in the bot sandbox); the two project-specific gates (check:docs-versions,check:docs-citations) that run ahead ofmkdocs build --strictin CI both passVerification
grep -n "Bearer" src/orchestrator/ws-server.tsshows only the twoBuffer.from(\Bearer ${...}`, "utf8")lines insideisAuthHeaderValid— no!==` Bearer comparison remains.grep -rn "timingSafeEqual" src/now returns 3 hits insidesrc/orchestrator/ws-server.ts(was 0 before — same negative grep cited in the issue).timingSafeEqualresults with an explicit length-equality check via bitwise AND, so the work is constant regardless of whether the header is missing / shorter / equal-but-wrong / longer-with-correct-prefix. The longer-with-correct-prefix case is the one a buggy length-prefix comparator would have authenticated; the explicit length-equality guard rejects it.timingSafeEqualcalls run unconditionally whenexpectedPrevious !== null, and the results are combined with bitwise|(no JS||short-circuit), so an attacker cannot distinguish which slot rejected them via timing.src/orchestrator/ws-server.ts; daemons (src/daemon/ws-client.ts) keep sending the primarydaemonAuthTokenvalue.validateDataLayerConfigstill only requires the primary, so existing deployments without_PREVIOUScontinue to start.IMPLEMENT.mdfor the per-task evidence map.ws://to a hard refusal in production), and chore: project housekeeping — 90% coverage, ESLint v8, CI security scans #6 (per-IP rate limit / jittered 401 delay) are deliberately deferred — they are separate hardening surfaces and the plan explicitly bounded this PR to T1–T6 (constant-time fix + rotation slot + tests + docs). Each is its own follow-up issue candidate.Related Issues
Test plan
bun run typecheckcleanbun run lintno new errors🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation
Improvements
Tests