Skip to content

fix(api): use timing-safe compare for x-probe-secret (#3803)#3820

Merged
koala73 merged 2 commits into
mainfrom
fix/3803-seed-contract-probe-timing-safe-compare
May 18, 2026
Merged

fix(api): use timing-safe compare for x-probe-secret (#3803)#3820
koala73 merged 2 commits into
mainfrom
fix/3803-seed-contract-probe-timing-safe-compare

Conversation

@koala73

@koala73 koala73 commented May 18, 2026

Copy link
Copy Markdown
Owner

Summary

Per issue #3803, `api/seed-contract-probe.ts` used `secret !== expected` for the `x-probe-secret` header — a timing oracle on `RELAY_SHARED_SECRET`, which is shared across Vercel functions, the Railway relay, and probe endpoints. Every other internal-auth path in the codebase uses the `timingSafeEqual` helper from `server/_shared/internal-auth.ts`.

Same template as the #3705 and #3756 fixes shipped earlier today: bring the outlier site in line with the safer convention the rest of the codebase already uses. The helper exists, the cost is one import + a one-line call change.

Change

  1. Export `timingSafeEqual` from `server/_shared/internal-auth.ts` (was module-private). JSDoc explains the use case: callers whose secret comes in via a non-`Authorization` header (`x-probe-secret`, etc.) need the primitive, not the Bearer-token-shaped `authenticateInternalRequest`.

  2. `api/seed-contract-probe.ts`: import + replace `!==` with `await timingSafeEqual(secret, expected)` (with a `?? ''` guard for missing header).

  3. Regression test (`tests/no-non-timing-safe-secret-compare.test.mts`):

    • Walks all `api/*.{ts,js,mjs,cjs}` files (skipping tests and vendored dirs).
    • Greps for `(secret|token|bearer|sharedSecret|...) (===|!==) (process.env.* | expected*)` — the shape of a timing-leaky secret comparison.
    • Comment-stripped to avoid false-positives on doc comments.
    • Allowlist exists for future documented exceptions (currently empty).
    • A second targeted test asserts `seed-contract-probe.ts` specifically uses the helper, so this exact regression can't reappear silently.

Test plan

  • 2/2 new tests pass (`npx tsx --test tests/no-non-timing-safe-secret-compare.test.mts`)
  • `npm run typecheck` clean
  • Manually verified the source-grep regex catches the pre-fix shape — replacing the new call with the old `!==` makes test Batch market and RSS fetching with progressive updates #1 fail with the right file in the violations list.

Severity context

The reporter rated this Medium-P2. Practical attack against this specific endpoint is borderline (internal-only ops probes, high network jitter dominates per-byte timing differences for a 32-char secret), but the fix is one line and brings the file in line with the codebase's existing pattern — no good reason not to do it.

Closes #3803

Issue #3803 flagged that api/seed-contract-probe.ts used `secret !== expected`
for the x-probe-secret header — a timing oracle on RELAY_SHARED_SECRET,
which is shared across Vercel functions, the Railway relay, and probe
endpoints. Every other internal-auth path in the codebase uses the
`timingSafeEqual` helper from server/_shared/internal-auth.ts.

Same pattern as the #3705 and #3756 fixes: bring the outlier site in
line with the safer convention the rest of the codebase already uses.
The helper exists, the cost is one import + a one-line call change.

Changes:
1. Export `timingSafeEqual` from server/_shared/internal-auth.ts (was
   module-private). Added a JSDoc explaining the use case for callers
   whose secret comes in via a non-Authorization header
   (x-probe-secret, x-internal-key, etc.) — they need the primitive,
   not the Bearer-token-shaped `authenticateInternalRequest`.
2. api/seed-contract-probe.ts: import + replace `!==` with
   `timingSafeEqual(secret, expected)` (with a `?? ''` guard for
   missing header). Updated comment to mention the timing-oracle
   rationale and cross-reference issue #3803.

New regression test (tests/no-non-timing-safe-secret-compare.test.mts):
- Walks all api/*.{ts,js,mjs,cjs} files (skipping tests and vendored
  dirs).
- Greps for `(secret|token|bearer|sharedSecret|...) (===|!==)
  (process.env.* | expected*)` — the shape of a timing-leaky secret
  comparison.
- Comment-stripped to avoid false-positives on doc comments.
- An allowlist exists for future documented exceptions (currently
  empty).
- A second targeted test asserts seed-contract-probe.ts specifically
  uses the helper, so this exact regression can't reappear silently.

Test plan:
- [x] 2/2 new tests pass
- [x] `npm run typecheck` clean
- [x] Manually verified the source-grep regex catches the pre-fix
      shape (replacing the new call with the old `!==` makes test #1
      fail with the right file in the violations list).

Closes #3803
@vercel

vercel Bot commented May 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
worldmonitor Ignored Ignored Preview May 18, 2026 5:29pm

Request Review

@greptile-apps

greptile-apps Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a timing oracle in api/seed-contract-probe.ts by replacing a direct !== comparison of the x-probe-secret header with the existing timingSafeEqual helper, and exports that helper so non-Bearer-auth endpoints can reuse it. A source-grep regression test is added to prevent the pattern from reappearing.

  • server/_shared/internal-auth.ts: timingSafeEqual is exported with a JSDoc explaining when to use the primitive directly vs. authenticateInternalRequest; the implementation itself is unchanged.
  • api/seed-contract-probe.ts: imports timingSafeEqual, adds a ?? '' null-coalescing guard on the header, and wraps the comparison in the async helper — a one-line logic change aligned with every other internal-auth endpoint.
  • tests/no-non-timing-safe-secret-compare.test.mts: new source-grep test walks all api/ source files and asserts no (secretVar) === (envVar) patterns remain, with a second pinned test for the specific fixed site.

Confidence Score: 3/5

The auth fix itself is correct and consistent with the rest of the codebase, but the timingSafeEqual implementation it relies on still returns early when input lengths differ, leaving a length-enumeration timing channel that this PR does not close.

The timingSafeEqual function is now exported and used as the canonical safe primitive across all internal-auth endpoints, yet its early-return on byteLength mismatch still allows an attacker to determine the exact byte length of RELAY_SHARED_SECRET by observing response latency. Promoting a function with a known timing side-channel to a public export without fixing that side-channel means every current and future caller inherits the same gap.

server/_shared/internal-auth.ts — the early-return on length mismatch needs to be replaced with a constant-time padded comparison before the export is fully safe for all callers.

Security Review

  • Length-timing side-channel in timingSafeEqual (server/_shared/internal-auth.ts line 36): the early return if (aBuf.byteLength !== bBuf.byteLength) return false lets an attacker enumerate the exact byte length of RELAY_SHARED_SECRET by comparing response latency between wrong-length inputs (fast path) and same-length inputs (full HMAC path). The function is now exported and used as the canonical timing-safe primitive, so callers expect it to cover length enumeration as well.

Important Files Changed

Filename Overview
server/_shared/internal-auth.ts Exports timingSafeEqual as a public primitive; the implementation has a length-leaking early-return (line 36) that lets callers observe whether a guess matches the secret's byte length via timing, undermining the full timing-safety guarantee the export promises.
api/seed-contract-probe.ts Replaces the !== direct string comparison with await timingSafeEqual(secret, expected) and adds a ?? '' null-coalescing guard; the auth logic change is correct and follows the established codebase pattern.
tests/no-non-timing-safe-secret-compare.test.mts Adds a source-grep regression test that walks api/ and asserts no (secretVar) === (env) patterns remain; the regex misses reversed comparisons (process.env.X === secret) which is a minor coverage gap.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Handler as probe handler
    participant TSE as timingSafeEqual
    participant Env as Env vars

    Client->>Handler: POST with x-probe-secret header
    Handler->>Handler: read header value (null-coalesced to empty string)
    Handler->>Env: read shared relay secret
    alt env var not configured
        Handler-->>Client: 503 not-configured
    else env var present
        Handler->>TSE: await timingSafeEqual(headerValue, envValue)
        TSE->>TSE: encode both strings
        Note over TSE: early-return if lengths differ (timing leak)
        TSE->>TSE: HMAC sign + constant-time XOR compare
        TSE-->>Handler: true or false
        alt mismatch
            Handler-->>Client: 401 unauthorized
        else match
            Handler->>Handler: run probe checks
            Handler-->>Client: 200 or 503 with results
        end
    end
Loading

Comments Outside Diff (1)

  1. server/_shared/internal-auth.ts, line 36-45 (link)

    P1 security The early-return on mismatched buffer lengths still leaks the byte length of the secret via timing. An attacker who controls the a argument (e.g. the x-probe-secret header value) can binary-search the length of RELAY_SHARED_SECRET by observing whether responses arrive in the fast-path (wrong length) vs. the full HMAC path (matching length). Now that this function is exported as the canonical timing-safe primitive, callers will reasonably expect it to resist length enumeration too. Padding both inputs to maxLen before the HMAC call removes the distinguishable fast path.

Reviews (1): Last reviewed commit: "fix(api): use timing-safe compare for x-..." | Re-trigger Greptile

Comment thread tests/no-non-timing-safe-secret-compare.test.mts Outdated
… review)

Greptile P2 review on PR #3820 caught that the source-grep regex only
matched the forward operand order:

  secret === process.env.FOO       ← caught
  process.env.FOO === secret       ← MISSED (yoda-style or copy-paste)

A maintainer who wrote the reverse form (common when porting code
from languages where yoda comparisons are idiomatic, or when
auto-completing from `process.env.FOO`) would have silently slipped
the timing oracle past the guard.

Fix: split the pattern into two named arms (forward + reverse) and
combine via alternation. The reverse arm anchors on the env-var-ish
LHS and matches the secret-variable RHS, mirroring the forward arm.

Also added a meta-test that exercises the regex against 10
representative strings (3 forward, 3 reverse, 4 negative) and
asserts the match/no-match contract. This pins the coverage so a
future "simplification" of the regex can't silently regress it.

3/3 tests pass; typecheck clean.
@koala73
koala73 merged commit 333e5b2 into main May 18, 2026
10 checks passed
@koala73
koala73 deleted the fix/3803-seed-contract-probe-timing-safe-compare branch May 18, 2026 17:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

seed-contract-probe uses non-timing-safe string comparison for RELAY_SHARED_SECRET — timing oracle on a cross-service credential

1 participant