Skip to content

fix(security): make the job routes' API-key gate fail closed - #405

Merged
godamongstmen897 merged 1 commit into
mainfrom
feat/api-key-fail-closed
Sep 12, 2026
Merged

godamongstmen897 merged 1 commit into
mainfrom
feat/api-key-fail-closed

Conversation

@godamongstmen897

Copy link
Copy Markdown
Contributor

The problem

The API_KEY checks in src/routes/jobs.ts were written so that a missing key disabled authentication rather than denying it:

const requiredApiKey = process.env.API_KEY;
if (requiredApiKey) {          // unset -> whole block skipped
  const providedKey = req.header("x-api-key");
  if (providedKey !== requiredApiKey) { /* 401 */ }
}

A deployment that forgot to set API_KEY served six endpoints to anyone, and logged nothing to say so:

Method Route
GET /api/jobs/by-wallet/:address
GET /api/jobs/:contractId
GET /api/jobs/:contractId/whitelist
POST /api/jobs/:contractId/whitelist/update
POST /api/jobs/:contractId/milestones/:index/partial-release
GET /api/jobs/:contractId/milestones/:index/time-remaining

requireAdmin() in middleware/adminAuth.ts already failed closed on the same condition, so the two halves of the codebase disagreed about what a missing key means.

The fix

All six copies collapse into one ensureApiKey() helper that rejects when API_KEY is unset, logging at error level so a misconfigured deployment is visible:

const requiredApiKey = process.env.API_KEY;
if (!requiredApiKey) {
  logger.error("API_KEY is not configured - refusing request", logContext);
  sendError(res, 401, "Unauthorized");
  return false;
}

Six duplicated copies are what allowed this to drift — one implementation is also easier to audit.

Plus a production startup assertion in src/index.ts, placed directly after dotenv.config() and before migrations, the poller and listen, so a misconfigured container exits rather than booting into a state that passes /health while serving job endpoints unauthenticated, rejecting the real frontend origin (ALLOWED_ORIGINS defaults to localhost:3000), or indexing nothing against testnet.

About the test changes

Closing the gate invalidated 113 existing tests that reached their handlers only because the gate was open. They are not weakened to accommodate the fix.

Each now authenticates through a test-only autoAuth middleware that injects the key only while API_KEY still holds jest.setup's default value. The dedicated auth suites set their own value ("secret-key", "secret-test-key") in a beforeEach, so for those the middleware does nothing and their "no key provided" / "wrong key" assertions test exactly what they did before. An explicit x-api-key header is never overwritten.

Two tests asserted the old behaviour outright — returns 200 (no gate) when API_KEY is not set — and now assert 401.

Verification

TSC EXIT: 0
Test Suites: 92 passed, 92 total
Tests:       1618 passed, 1618 total

Baseline before this change was 91 suites / 1612 tests, so this adds 6: one fail-closed test per route, plus two for the startup assertion. That test imports the real src/index.ts rather than re-implementing the check, so deleting the assertion or moving it below a side effect fails the test.

Mutation-verified. Reverting ensureApiKey to fail-open fails exactly six tests, one per hardened route:

● GET  /api/jobs/by-wallet/:address ... returns 401 (fails closed) when API_KEY env var is not set
● GET  /api/jobs/:contractId ... returns 401 (fails closed) when API_KEY is not set
● GET  /api/jobs/:contractId/whitelist ... returns 401 (fails closed) when API_KEY is not set
● POST /api/jobs/:contractId/whitelist/update ... returns 401 (fails closed) when API_KEY is not set
● POST /api/jobs/:contractId/milestones/:index/partial-release ... returns 401 (fails closed) when API_KEY is not set
● GET  /api/jobs/:contractId/milestones/:index/time-remaining ... returns 401 (fails closed) when API_KEY is not set
Tests: 6 failed, 1612 passed, 1618 total

Removing the startup assertion fails exactly two:

● production startup assertion (src/index.ts) › throws and names every missing variable when NODE_ENV=production
● production startup assertion (src/index.ts) › names ADMIN_API_KEY specifically when only that one is absent
Tests: 2 failed, 1616 passed, 1618 total

Both restored; suite green after each.

Deployment impact — read before merging

API_KEY and ADMIN_API_KEY must be set in production before this ships, or all six endpoints return 401 and the process refuses to boot. That is the intent, but it makes this a coordinated change rather than a drop-in.

ALLOWED_ORIGINS and CONTRACT_ID become mandatory in production too.

Not in scope

Deploy scaffolding (workflow, deploy/, .nvmrc, engines) lands in a separate PR. The indexer's DB-over-env CONTRACT_ID precedence is unchanged.

🤖 Generated with Claude Code

The six API_KEY checks in src/routes/jobs.ts were written as
`if (requiredApiKey) { ...verify... }`, which skipped authentication
entirely whenever API_KEY was unset. A deployment that forgot to set it
served these endpoints to anyone, with nothing in the logs to say so:

  GET  /api/jobs/by-wallet/:address
  GET  /api/jobs/:contractId
  GET  /api/jobs/:contractId/whitelist
  POST /api/jobs/:contractId/whitelist/update
  POST /api/jobs/:contractId/milestones/:index/partial-release
  GET  /api/jobs/:contractId/milestones/:index/time-remaining

requireAdmin() in middleware/adminAuth.ts already failed closed, so the
two halves of the codebase disagreed on what a missing key means.

Replace all six copies with one ensureApiKey() helper that rejects when
API_KEY is unset, logging at error level so a misconfigured deployment is
visible. Six duplicated copies are what let this drift in the first place.

Also add a production startup assertion to src/index.ts. It runs directly
after dotenv.config(), before migrations, the poller and listen, so a
misconfigured container exits instead of booting into a state that passes
/health while serving job endpoints unauthenticated (API_KEY unset),
rejecting the real frontend origin (ALLOWED_ORIGINS defaulting to
localhost:3000), or indexing nothing against testnet (CONTRACT_ID unset).

Tests: closing the gate invalidated 113 existing tests that reached their
handlers only because the gate was open. They are not weakened — each now
authenticates through a test-only autoAuth middleware that injects the key
only while API_KEY still holds jest.setup's default, so the dedicated auth
suites keep full control of the header and their "no key"/"wrong key"
assertions are untouched. Two tests that asserted the old behaviour
("returns 200 (no gate) when API_KEY is not set") now assert 401.

Adds one fail-closed test per route plus two covering the startup
assertion, which imports the real entry point rather than re-implementing
the check. Both verified by mutation: reverting ensureApiKey to fail-open
fails exactly six tests, one per route; removing the startup assertion
fails exactly two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 12, 2026 09:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@godamongstmen897
godamongstmen897 merged commit 073f481 into main Sep 12, 2026
1 check passed
@godamongstmen897
godamongstmen897 deleted the feat/api-key-fail-closed branch September 12, 2026 10:02
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.

2 participants