Skip to content

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

Description

@tg12

Summary

api/seed-contract-probe.ts authenticates the x-probe-secret header using JavaScript's !== operator rather than a timing-safe comparison. Every other auth check in the codebase uses crypto.subtle-based HMAC or constant-time comparison. This one outlier creates a timing oracle that can be used to recover the RELAY_SHARED_SECRET byte-by-byte.

Evidence

api/seed-contract-probe.ts lines ~225:

const secret = req.headers.get('x-probe-secret');
const expected = process.env.RELAY_SHARED_SECRET;
if (!expected) return jsonResponse({ error: 'not-configured' }, 503, cors);
if (secret !== expected) return jsonResponse({ error: 'unauthorized' }, 401, cors);

Contrast with the rest of the codebase (api/cache-purge.js, server/_shared/internal-auth.ts):

// Correct pattern used everywhere else:
const isValid = await timingSafeEqual(provided, expected);

Why this matters

RELAY_SHARED_SECRET is used across multiple internal services (Vercel functions, Railway relay, probe). Leaking this secret via timing oracle grants access to all auth-gated relay endpoints. The timing difference between an early string-compare exit (wrong first byte) and a full comparison (correct prefix) is measurable over a network with repeated sampling.

Attack or failure scenario

  1. Attacker sends requests to /api/seed-contract-probe with varying x-probe-secret values.
  2. Measures response time distribution for each candidate byte at each position.
  3. Recovers the secret prefix statistically over ~1000 requests per byte.
  4. Uses recovered RELAY_SHARED_SECRET to authenticate against all relay endpoints.

(This attack is practical when the relay is low-latency and the secret is short. It's less practical for long secrets on high-jitter connections, but the vulnerability is architectural.)

Root cause

Inconsistent security review across API endpoints. The timing-safe helper exists and is used elsewhere; this endpoint was not reviewed against the same standard.

Recommended fix

Replace the direct string comparison with the same timing-safe helper used throughout the rest of the codebase:

import { timingSafeEqual } from '../server/_shared/internal-auth.js';

const isValid = await timingSafeEqual(secret ?? '', expected);
if (!isValid) return jsonResponse({ error: 'unauthorized' }, 401, cors);

Acceptance criteria

  • api/seed-contract-probe.ts uses a constant-time comparison for the probe secret.
  • No direct === or !== string comparison exists for any secret/token value across all api/ files.

Suggested labels

security, area: API

Priority

P2

Severity

Medium — timing oracle against a shared secret used across multiple services.

Confidence

Confirmed — contrast with api/cache-purge.js and server/_shared/internal-auth.ts verified.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions