Skip to content

feat(api): restrict CORS origins and add security headers to deployed serverless API - #1754

Merged
1nonlypiece merged 2 commits into
Disciplr-Org:mainfrom
Stephan-Thomas:feat/serverless-cors-security-headers
Oct 24, 2026
Merged

1nonlypiece merged 2 commits into
Disciplr-Org:mainfrom
Stephan-Thomas:feat/serverless-cors-security-headers

Conversation

@Stephan-Thomas

Copy link
Copy Markdown
Contributor

Summary

Closes #1731

  • Port hardened Helmet configuration from src/app.ts to api/index.mjs providing baseline security headers (X-Content-Type-Options: nosniff, Strict-Transport-Security 1-year with subdomains, Referrer-Policy: no-referrer, and restrictive API CSP default-src 'none', frame-ancestors 'none') across all responses including 200, 404, and 500.
  • Create apilib/cors.mjs containing:
    • buildCorsOptions(env): creates CORS options with explicit methods (GET, POST, OPTIONS), allowed headers (Content-Type, Authorization, idempotency-key, x-test-now, x-wallet-address, x-network-id), maxAge: 600, and credentials: false.
    • parseAllowedOrigins(env): parses comma-separated CORS_ORIGINS from environment, trims whitespace, normalizes trailing slashes, and filters empty entries.
    • Fail-closed behavior: unset CORS_ORIGINS in production (NODE_ENV === 'production') resolves to [] (rejecting cross-origin writes with 403 Forbidden). In development/test, defaults to https://disciplr-frontend.vercel.app.
    • isOriginAllowed(origin, allowedOrigins): exact origin comparison (no substring or regex matching) to avoid vulnerabilities with malicious domains.
  • Update api/index.mjs:
    • Split CORS policy: permissive wildcard (*) access for public read routes (GET and HEAD on /health, /openapi.json, /vaults, /transactions, /stats).
    • Strict allow-list on OPTIONS preflight (returns matching Access-Control-Allow-Origin for allowed origins; no CORS headers for disallowed origins) and POST write routes (returns 403 JSON { error: 'Origin not allowed' } without mutating store state).
    • Requests without Origin header (server-to-server, curl) continue to be allowed.
    • Error handling middleware ensuring 500 responses return clean JSON and carry Helmet headers.
  • Update documentation:
    • .env.example: document CORS_ORIGINS for serverless API (default frontend origin and fail-closed production behavior).
    • README.md: document CORS_ORIGINS in environment variables table and deployment notes.
  • Test suite:
    • Add api/__tests__/cors.security.test.mjs covering unit tests for buildCorsOptions & origin parsing, preflight allowed/denied matrix, POST allowed/denied/missing origins (asserting store count unchanged on denial), GET cross-origin, and presence of all security headers on 200/404/500 responses.
    • Add test file to npm run test:api.

Why

The production serverless API previously called app.use(cors()) with no options, exposing state-changing POST routes (/vaults, /validate, /fail) cross-origin to any website a victim visits with Access-Control-Allow-Origin: *. Furthermore, no standard security headers (CSP, HSTS, nosniff, Referrer-Policy) were present on responses. Restricting cross-origin writes to configured frontend origins and attaching hardened headers mitigates cross-site scripting and unauthorized cross-origin request risks.

Testing

Run commands:

  • node --test api/__tests__/cors.security.test.mjs (18 passed, 0 failed)
  • npm run test:api (51 passed, 0 failed, 4 skipped)
  • npm run build (clean TypeScript build)
  • npm run openapi:check (docs/openapi.yaml up to date)

Test coverage details:

  • Unit tests: normalizeOrigin strips whitespace and trailing slashes.
  • Unit tests: parseAllowedOrigins parses comma-separated origins, ignores wildcard * for writes, and fails closed in production when unset.
  • Unit tests: isOriginAllowed checks exact origin matching without substring or regex false positives.
  • Unit tests: buildCorsOptions returns expected shape, methods, headers, and origin callback logic.
  • Preflight OPTIONS /api/vaults: allowed origin returns matching Access-Control-Allow-Origin: <origin> and max-age 600; disallowed origin returns no CORS header.
  • POST /api/vaults: disallowed origin returns 403 JSON and store count is not mutated; allowed origin returns 201 and adds vault; missing origin (curl/server-to-server) returns 201.
  • POST /validate and /fail: disallowed origin returns 403 JSON.
  • Production fail-closed: unset CORS_ORIGINS with NODE_ENV: production rejects cross-origin POSTs.
  • Public read routes (GET /api/health, /api/vaults, /api/stats, /api/transactions, /api/openapi.json) return Access-Control-Allow-Origin: *.
  • Security headers (X-Content-Type-Options: nosniff, Strict-Transport-Security, Referrer-Policy: no-referrer, and Content-Security-Policy) verified on 200, 404, and 500 responses.

Scope

In scope: CORS origin policy split for serverless API, Helmet security headers, apilib/cors.mjs helper, .env.example, README.md, and integration test suite in api/__tests__/cors.security.test.mjs.
Out of scope: Authentication/wallet signatures on write endpoints (separate issue); rate limiting (separate issue); changes to src/app.ts.

… serverless API

Closes Disciplr-Org#1731

Restrict CORS on write endpoints via CORS_ORIGINS allow-list while keeping public GET routes accessible cross-origin. Add Helmet security headers (CSP, HSTS, no-referrer, nosniff) across all responses, and fail closed in production when unset.
@drips-wave

drips-wave Bot commented Oct 20, 2026

Copy link
Copy Markdown

@Stephan-Thomas Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Comment thread apilib/cors.mjs
*/
export function normalizeOrigin(origin) {
if (typeof origin !== 'string') return ''
return origin.trim().replace(/\/+$/, '')
…flicts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@1nonlypiece
1nonlypiece merged commit 89b11a4 into Disciplr-Org:main Oct 24, 2026
2 of 5 checks passed
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.

[api] Restrict CORS to configured origins and add security headers to the deployed serverless API

3 participants