Skip to content

GET /api/sync authenticates a caller sending "Bearer undefined" when CRON_SECRET is unset #220

Description

@mallison031

Repo context. accensa-app is the off-chain half of Accensa — the merchant back-office for x402 sellers on Stellar. Three workspace packages matter: apps/web (Next.js dashboard and the indexer at src/app/api/sync), packages/sdk (@accensa/sdk), and apps/demo-merchant. The Soroban contracts live in accensa-contracts.

🟢 Unblocked. A two-line fix in two files, testable without a database.

Problem

GET /api/sync is guarded twice, and both guards fail open in the same configuration — a deployment with CRON_SECRET unset.

apps/web/src/middleware.ts:62:

if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

When CRON_SECRET is undefined, that template literal renders the string "Bearer undefined". A request carrying the literal header Authorization: Bearer undefined compares equal and passes.

apps/web/src/app/api/sync/route.ts:294 is the second guard:

const secret = process.env.CRON_SECRET;
if (secret && request.headers.get('authorization') !== `Bearer ${secret}`) {
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

Here the secret && short-circuit means that when the variable is unset there is no check at all.

So with CRON_SECRET unset:

Request middleware route Result
no Authorization header 401 rejected
Authorization: Bearer undefined passes no check sync runs

One guessable header value drives a full ledger sync against MERCHANT_ADDRESS — unbounded RPC calls and database writes, on an endpoint whose own docstring says it "cannot be driven by arbitrary callers."

Why CI does not catch it. .github/workflows/sync.yml:40-46 does test the guard, but only the unauthenticated case:

code=$(curl -sS --max-time 60 -o /dev/null -w '%{http_code}' "$SYNC_URL")
if [ "$code" != "401" ]; then
  echo "::error::Unauthenticated GET returned HTTP $code, expected 401." \
       "CRON_SECRET is not being enforced."

That request sends no header, so it 401s and the job reports CRON_SECRET is not being enforced as passing — while the bypass is live. A negative test that only probes the empty case cannot see a sentinel-value match.

This contradicts the file's own stated standard. Twelve lines above, middleware.ts refuses to fall back to a default JWT_SECRET_KEY, with a comment explaining that a missing secret "must deny, never fall back." CRON_SECRET does exactly what that comment forbids.

What to build

  1. Fail closed on a missing secret. In both files, treat unset CRON_SECRET as "deny", matching the JWT_SECRET_KEY handling directly above it in middleware.ts. If a deployment genuinely wants an unauthenticated sync, that should be an explicit opt-in variable, not the absence of one.
  2. Compare in constant time. Use crypto.timingSafeEqual over equal-length buffers rather than !==. x402-facilitator-stellar already holds this standard for API keys (see its docs/THREAT-MODEL.md, "constant-time comparison"); this repo should not be looser.
  3. Decide which layer owns the check. Two guards with different semantics is how they drifted apart. Keep one, and have the other assert rather than re-implement.
  4. Extend the CI probe to send Authorization: Bearer undefined and require a 401.

Acceptance criteria

  • With CRON_SECRET unset, GET /api/sync returns 401 for every request, including Authorization: Bearer undefined.
  • With CRON_SECRET set, a correct bearer token succeeds and an incorrect one 401s.
  • A unit test covers the Bearer undefined case specifically — it is the regression that would otherwise return silently.
  • sync.yml probes both the no-header and the Bearer undefined cases.
  • The secret comparison is constant-time.

Out of scope

  • POST /api/sync (session-authenticated via middleware) and the MANUAL_COOLDOWN_MS behaviour.
  • The Vercel Cron configuration itself.

Metadata

Metadata

Assignees

Labels

Stellar WaveDrips Wave Programarea: securitySecurity, auth, threat model, auditarea: webWork in webbugSomething isn't workingcomplexity: medium150 pts — standard feature touching multiple parts

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions