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
- 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.
- 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.
- 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.
- Extend the CI probe to send
Authorization: Bearer undefined and require a 401.
Acceptance criteria
Out of scope
POST /api/sync (session-authenticated via middleware) and the MANUAL_COOLDOWN_MS behaviour.
- The Vercel Cron configuration itself.
Problem
GET /api/syncis guarded twice, and both guards fail open in the same configuration — a deployment withCRON_SECRETunset.apps/web/src/middleware.ts:62:When
CRON_SECRETisundefined, that template literal renders the string"Bearer undefined". A request carrying the literal headerAuthorization: Bearer undefinedcompares equal and passes.apps/web/src/app/api/sync/route.ts:294is the second guard:Here the
secret &&short-circuit means that when the variable is unset there is no check at all.So with
CRON_SECRETunset:AuthorizationheaderAuthorization: Bearer undefinedOne 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-46does test the guard, but only the unauthenticated case:That request sends no header, so it 401s and the job reports
CRON_SECRET is not being enforcedas 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.tsrefuses to fall back to a defaultJWT_SECRET_KEY, with a comment explaining that a missing secret "must deny, never fall back."CRON_SECRETdoes exactly what that comment forbids.What to build
CRON_SECRETas "deny", matching theJWT_SECRET_KEYhandling directly above it inmiddleware.ts. If a deployment genuinely wants an unauthenticated sync, that should be an explicit opt-in variable, not the absence of one.crypto.timingSafeEqualover equal-length buffers rather than!==.x402-facilitator-stellaralready holds this standard for API keys (see itsdocs/THREAT-MODEL.md, "constant-time comparison"); this repo should not be looser.Authorization: Bearer undefinedand require a 401.Acceptance criteria
CRON_SECRETunset,GET /api/syncreturns 401 for every request, includingAuthorization: Bearer undefined.CRON_SECRETset, a correct bearer token succeeds and an incorrect one 401s.Bearer undefinedcase specifically — it is the regression that would otherwise return silently.sync.ymlprobes both the no-header and theBearer undefinedcases.Out of scope
POST /api/sync(session-authenticated via middleware) and theMANUAL_COOLDOWN_MSbehaviour.