diff --git a/README.md b/README.md index cef96fb..7440242 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,7 @@ The running server loads connector settings from the environment via | `FINANCE_DEFAULT_PAGE_SIZE` | Default page size when `limit` is omitted | `25` | | `FINANCE_MAX_PAGE_SIZE` | Upper bound applied to any requested `limit` | `100` | | `LOG_LEVEL` | Structured log level (`debug`/`info`/`warn`/`error`) | `info` | +| `CORS_ALLOWED_ORIGINS` | Comma-separated list of origins allowed to read `/graphql`, `/graphql/stream`, and `/export` cross-origin | empty (all cross-origin reads denied) | Each connector endpoint that is **not** a `mock://` URL is served by the production HTTP client in `src/connectors/httpClient.js`, which adds bearer @@ -448,12 +449,30 @@ client disconnects. ### CORS -`/graphql`, `/graphql/stream`, and `/export` must be served behind an -**explicit origin allow-list** — never a wildcard (`*`) `Access-Control- -Allow-Origin` in production — so that only trusted front-ends can read -responses cross-origin. Configure the CORS middleware with a fixed list (or -an environment-driven list) of allowed origins instead of the permissive -default before deploying publicly. +`/graphql`, `/graphql/stream`, and `/export` are served behind an **explicit +origin allow-list** — a wildcard (`*`) `Access-Control-Allow-Origin` is never +emitted — so that only trusted front-ends can read responses cross-origin. + +The allow-list is built in `src/config/cors.js` from the +`CORS_ALLOWED_ORIGINS` environment variable and applied by `src/index.js`: + +```bash +CORS_ALLOWED_ORIGINS='https://app.example.com,https://admin.example.com' npm start +``` + +```js +import { loadCorsOptions } from './config/cors.js'; + +const corsOptions = loadCorsOptions(); +app.use('/graphql', cors(corsOptions), /* ... */); +``` + +An allowed `Origin` is echoed back verbatim; any other origin receives **no** +CORS headers, so the browser blocks the response. When +`CORS_ALLOWED_ORIGINS` is unset every cross-origin read is denied — this is +the secure default, so the variable must be set for browser front-ends that +live on a different origin. Requests without an `Origin` header (curl, +server-to-server calls) are unaffected. Events are dispatched by an in-process pub/sub (`src/streaming/pubsub.js`). It is intentionally dependency-free, which means subscribers only see events diff --git a/src/config/cors.js b/src/config/cors.js new file mode 100644 index 0000000..2c89a4e --- /dev/null +++ b/src/config/cors.js @@ -0,0 +1,33 @@ +/** + * Environment-driven CORS configuration. + * + * `CORS_ALLOWED_ORIGINS` holds a comma-separated allow-list of origins that may + * read responses cross-origin (e.g. `https://app.example.com,https://admin.example.com`). + * Anything outside the list gets no `Access-Control-Allow-Origin` header, so the + * browser blocks the response; the wildcard `*` is never emitted. + */ +export function parseAllowedOrigins(value) { + return String(value ?? '') + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean); +} + +/** Builds the options object passed to the `cors()` middleware. */ +export function loadCorsOptions(env = process.env) { + const allowedOrigins = parseAllowedOrigins(env.CORS_ALLOWED_ORIGINS); + + return { + // Requests without an `Origin` header (curl, server-to-server, same-origin + // navigations) are not subject to CORS, so they pass through unchanged and + // simply receive no CORS headers. + origin(origin, callback) { + if (!origin || allowedOrigins.includes(origin)) { + callback(null, origin ? [origin] : false); + return; + } + callback(null, false); + }, + credentials: true, + }; +} diff --git a/src/index.js b/src/index.js index 8d3013b..db36ce8 100644 --- a/src/index.js +++ b/src/index.js @@ -2,6 +2,7 @@ import { expressMiddleware } from '@as-integrations/express4'; import cors from 'cors'; import express from 'express'; +import { loadCorsOptions } from './config/cors.js'; import { store } from './data/store.js'; import { createExportRouter } from './export/router.js'; import { logger } from './observability/logger.js'; @@ -22,6 +23,10 @@ async function main() { const app = express(); + // Cross-origin access is restricted to the CORS_ALLOWED_ORIGINS allow-list so + // a malicious page can never read GraphQL or export responses. + const corsOptions = loadCorsOptions(); + // Liveness probe: the process is up and serving. app.get('/health', (_req, res) => res.json({ status: 'ok' })); @@ -38,13 +43,17 @@ async function main() { }); // Spreadsheet downloads (.xlsx) for the same data the GraphQL API serves. - app.use('/export', cors(), createExportRouter({ store, finance: financeService, logger })); + app.use( + '/export', + cors(corsOptions), + createExportRouter({ store, finance: financeService, logger }) + ); // Streaming endpoint (Server-Sent Events) for subscriptions and one-shot // operations, mounted before /graphql so it keeps its own body parsing. app.use( '/graphql/stream', - cors(), + cors(corsOptions), express.json(), createStreamRouter({ schema, @@ -56,7 +65,7 @@ async function main() { app.use( '/graphql', - cors(), + cors(corsOptions), express.json(), expressMiddleware(apolloServer, { // Every request shares the same in-memory store. diff --git a/test/cors.test.js b/test/cors.test.js new file mode 100644 index 0000000..27fe83d --- /dev/null +++ b/test/cors.test.js @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { expressMiddleware } from '@as-integrations/express4'; +import cors from 'cors'; +import express from 'express'; + +import { loadCorsOptions, parseAllowedOrigins } from '../src/config/cors.js'; +import { createStore } from '../src/data/store.js'; +import { createExportRouter } from '../src/export/router.js'; +import { createSchema } from '../src/schema.js'; +import { createFinanceService } from '../src/services/financeService.js'; +import { createApolloServer } from '../src/server.js'; +import { createPubSub } from '../src/streaming/pubsub.js'; +import { createStreamRouter } from '../src/streaming/sseRouter.js'; + +/** Starts a throwaway app guarded by the allow-list CORS middleware. */ +async function withServer(env, run) { + const app = express(); + app.use(cors(loadCorsOptions(env))); + app.get('/probe', (_req, res) => res.json({ ok: true })); + + const server = await new Promise((resolve) => { + const listener = app.listen(0, () => resolve(listener)); + }); + const { port } = server.address(); + try { + await run(`http://127.0.0.1:${port}/probe`); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +} + +/** Starts the actual /export, /graphql/stream, and /graphql mounts wired the same way as src/index.js. */ +async function withApp(env, run) { + const store = createStore(); + const finance = createFinanceService(); + const pubsub = createPubSub(); + const schema = createSchema(); + const apolloServer = createApolloServer({ schema }); + await apolloServer.start(); + + const app = express(); + const corsOptions = loadCorsOptions(env); + + app.use('/export', cors(corsOptions), createExportRouter({ store, finance })); + app.use( + '/graphql/stream', + cors(corsOptions), + express.json(), + createStreamRouter({ schema, contextValue: () => ({ store, finance, pubsub }) }) + ); + app.use( + '/graphql', + cors(corsOptions), + express.json(), + expressMiddleware(apolloServer, { context: async () => ({ store, finance, pubsub }) }) + ); + + const server = await new Promise((resolve) => { + const listener = app.listen(0, () => resolve(listener)); + }); + const { port } = server.address(); + try { + await run(`http://127.0.0.1:${port}`); + } finally { + await new Promise((resolve) => server.close(resolve)); + await apolloServer.stop(); + } +} + +describe('cors configuration', () => { + it('parses a comma-separated allow-list and ignores blanks', () => { + assert.deepEqual(parseAllowedOrigins(' https://a.example , ,https://b.example '), [ + 'https://a.example', + 'https://b.example', + ]); + assert.deepEqual(parseAllowedOrigins(undefined), []); + }); + + it('echoes allowed origins and never emits a wildcard', async () => { + await withServer({ CORS_ALLOWED_ORIGINS: 'https://app.example' }, async (url) => { + const allowed = await fetch(url, { headers: { origin: 'https://app.example' } }); + assert.equal(allowed.headers.get('access-control-allow-origin'), 'https://app.example'); + + const denied = await fetch(url, { headers: { origin: 'https://evil.example' } }); + assert.equal(denied.headers.get('access-control-allow-origin'), null); + + const noOrigin = await fetch(url); + assert.equal(noOrigin.headers.get('access-control-allow-origin'), null); + assert.equal(noOrigin.status, 200); + }); + }); + + it('denies every origin when the allow-list is unset', async () => { + await withServer({}, async (url) => { + const response = await fetch(url, { headers: { origin: 'https://app.example' } }); + assert.equal(response.headers.get('access-control-allow-origin'), null); + assert.equal(response.status, 200); + }); + }); + + it('enforces the allow-list on the production /export, /graphql/stream, and /graphql mounts', async () => { + const env = { CORS_ALLOWED_ORIGINS: 'https://app.example' }; + await withApp(env, async (baseUrl) => { + const routes = [ + { path: '/export', init: {} }, + { + // A valid one-shot query completes and closes the response itself, + // instead of leaving an SSE stream open for the test to clean up. + path: `/graphql/stream?query=${encodeURIComponent('{ __typename }')}`, + init: { headers: { accept: 'text/event-stream' }, method: 'GET' }, + }, + { + path: '/graphql', + init: { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query: '{ __typename }' }), + }, + }, + ]; + + for (const { path, init } of routes) { + const allowed = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { ...init.headers, origin: 'https://app.example' }, + }); + assert.equal(allowed.status, 200, `${path} should succeed for an allowed origin`); + assert.equal( + allowed.headers.get('access-control-allow-origin'), + 'https://app.example', + `${path} should echo the allowed origin` + ); + + const denied = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { ...init.headers, origin: 'https://evil.example' }, + }); + assert.equal(denied.status, 200, `${path} should still respond for a denied origin`); + assert.equal( + denied.headers.get('access-control-allow-origin'), + null, + `${path} should not echo a denied origin` + ); + + const noOrigin = await fetch(`${baseUrl}${path}`, init); + assert.equal(noOrigin.status, 200, `${path} should succeed without an Origin header`); + assert.equal( + noOrigin.headers.get('access-control-allow-origin'), + null, + `${path} should not emit a CORS header without an Origin header` + ); + } + }); + }); +});