From 24d224c90b9c6b2a15397c8d27a0de61e9e2efe5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 05:03:15 +0000 Subject: [PATCH 1/7] Initial plan From 93e36539d91333be702a991526ab5d90e18b3ddc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 05:05:18 +0000 Subject: [PATCH 2/7] Enforce env-driven CORS origin allow-list Co-authored-by: charles2ke <6725706+charles2ke@users.noreply.github.com> --- README.md | 31 ++++++++++++++++++++----- src/config/cors.js | 33 +++++++++++++++++++++++++++ src/index.js | 15 ++++++++++--- test/cors.test.js | 56 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 9 deletions(-) create mode 100644 src/config/cors.js create mode 100644 test/cors.test.js 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..b861f4f --- /dev/null +++ b/test/cors.test.js @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import cors from 'cors'; +import express from 'express'; + +import { loadCorsOptions, parseAllowedOrigins } from '../src/config/cors.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)); + } +} + +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); + }); + }); +}); From 17fbce8c1f3ed7c551ae328a55b2d3f1d2f2664e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 05:42:57 +0000 Subject: [PATCH 3/7] test: add integration coverage for CORS on production mounts Co-authored-by: charles2ke <6725706+charles2ke@users.noreply.github.com> --- test/cors.test.js | 91 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/test/cors.test.js b/test/cors.test.js index b861f4f..f34d314 100644 --- a/test/cors.test.js +++ b/test/cors.test.js @@ -1,10 +1,18 @@ 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) { @@ -23,6 +31,44 @@ async function withServer(env, run) { } } +/** 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 '), [ @@ -53,4 +99,49 @@ describe('cors configuration', () => { 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: {} }, + { + path: '/graphql/stream', + 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.headers.get('access-control-allow-origin'), + 'https://app.example', + `${path} should echo the allowed origin` + ); + if (allowed.body) await allowed.body.cancel(); + + const denied = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { ...init.headers, origin: 'https://evil.example' }, + }); + assert.equal( + denied.headers.get('access-control-allow-origin'), + null, + `${path} should not echo a denied origin` + ); + if (denied.body) await denied.body.cancel(); + } + }); + }); }); From 174cd29e83ef1ea57acbbe50eebe769d489bc506 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 05:43:54 +0000 Subject: [PATCH 4/7] test: avoid dangling SSE stream in CORS integration test Co-authored-by: charles2ke <6725706+charles2ke@users.noreply.github.com> --- test/cors.test.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/test/cors.test.js b/test/cors.test.js index f34d314..aab31f9 100644 --- a/test/cors.test.js +++ b/test/cors.test.js @@ -106,7 +106,9 @@ describe('cors configuration', () => { const routes = [ { path: '/export', init: {} }, { - path: '/graphql/stream', + // 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' }, }, { @@ -120,27 +122,33 @@ describe('cors configuration', () => { ]; for (const { path, init } of routes) { + const allowedController = new AbortController(); const allowed = await fetch(`${baseUrl}${path}`, { ...init, headers: { ...init.headers, origin: 'https://app.example' }, + signal: allowedController.signal, }); assert.equal( allowed.headers.get('access-control-allow-origin'), 'https://app.example', `${path} should echo the allowed origin` ); - if (allowed.body) await allowed.body.cancel(); + allowedController.abort(); + await allowed.body?.cancel().catch(() => {}); + const deniedController = new AbortController(); const denied = await fetch(`${baseUrl}${path}`, { ...init, headers: { ...init.headers, origin: 'https://evil.example' }, + signal: deniedController.signal, }); assert.equal( denied.headers.get('access-control-allow-origin'), null, `${path} should not echo a denied origin` ); - if (denied.body) await denied.body.cancel(); + deniedController.abort(); + await denied.body?.cancel().catch(() => {}); } }); }); From 640175bdd7d3494654ceaa8f9815066108349923 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 05:44:22 +0000 Subject: [PATCH 5/7] test: simplify CORS integration test cleanup to a single abort mechanism Co-authored-by: charles2ke <6725706+charles2ke@users.noreply.github.com> --- test/cors.test.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/cors.test.js b/test/cors.test.js index aab31f9..44c9e54 100644 --- a/test/cors.test.js +++ b/test/cors.test.js @@ -134,7 +134,6 @@ describe('cors configuration', () => { `${path} should echo the allowed origin` ); allowedController.abort(); - await allowed.body?.cancel().catch(() => {}); const deniedController = new AbortController(); const denied = await fetch(`${baseUrl}${path}`, { @@ -148,7 +147,6 @@ describe('cors configuration', () => { `${path} should not echo a denied origin` ); deniedController.abort(); - await denied.body?.cancel().catch(() => {}); } }); }); From 8503d5cd4335144a86d3ff929b83734620383bcf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 05:44:55 +0000 Subject: [PATCH 6/7] test: guarantee CORS integration test cleanup with try/finally and assert status Co-authored-by: charles2ke <6725706+charles2ke@users.noreply.github.com> --- test/cors.test.js | 52 +++++++++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/test/cors.test.js b/test/cors.test.js index 44c9e54..f96b2db 100644 --- a/test/cors.test.js +++ b/test/cors.test.js @@ -123,30 +123,38 @@ describe('cors configuration', () => { for (const { path, init } of routes) { const allowedController = new AbortController(); - const allowed = await fetch(`${baseUrl}${path}`, { - ...init, - headers: { ...init.headers, origin: 'https://app.example' }, - signal: allowedController.signal, - }); - assert.equal( - allowed.headers.get('access-control-allow-origin'), - 'https://app.example', - `${path} should echo the allowed origin` - ); - allowedController.abort(); + try { + const allowed = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { ...init.headers, origin: 'https://app.example' }, + signal: allowedController.signal, + }); + 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` + ); + } finally { + allowedController.abort(); + } const deniedController = new AbortController(); - const denied = await fetch(`${baseUrl}${path}`, { - ...init, - headers: { ...init.headers, origin: 'https://evil.example' }, - signal: deniedController.signal, - }); - assert.equal( - denied.headers.get('access-control-allow-origin'), - null, - `${path} should not echo a denied origin` - ); - deniedController.abort(); + try { + const denied = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { ...init.headers, origin: 'https://evil.example' }, + signal: deniedController.signal, + }); + 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` + ); + } finally { + deniedController.abort(); + } } }); }); From 57a7d25a2cd5585d9b80a31698eef44135b5984f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 05:45:34 +0000 Subject: [PATCH 7/7] test: simplify CORS integration test and cover requests without an Origin header Co-authored-by: charles2ke <6725706+charles2ke@users.noreply.github.com> --- test/cors.test.js | 62 ++++++++++++++++++++++------------------------- 1 file changed, 29 insertions(+), 33 deletions(-) diff --git a/test/cors.test.js b/test/cors.test.js index f96b2db..27fe83d 100644 --- a/test/cors.test.js +++ b/test/cors.test.js @@ -122,39 +122,35 @@ describe('cors configuration', () => { ]; for (const { path, init } of routes) { - const allowedController = new AbortController(); - try { - const allowed = await fetch(`${baseUrl}${path}`, { - ...init, - headers: { ...init.headers, origin: 'https://app.example' }, - signal: allowedController.signal, - }); - 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` - ); - } finally { - allowedController.abort(); - } - - const deniedController = new AbortController(); - try { - const denied = await fetch(`${baseUrl}${path}`, { - ...init, - headers: { ...init.headers, origin: 'https://evil.example' }, - signal: deniedController.signal, - }); - 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` - ); - } finally { - deniedController.abort(); - } + 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` + ); } }); });