From c5ee8d1287eb335c32de1303348a2030a3e68197 Mon Sep 17 00:00:00 2001 From: DeePrincipal-dev-lang Date: Mon, 27 Apr 2026 10:19:43 +0000 Subject: [PATCH] feat: configure explicit CORS with allowed origins whitelist --- .env.example | 4 ++++ backend/src/app.js | 18 ++++++++++++++- backend/src/config.js | 2 ++ backend/tests/cors.test.js | 47 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 backend/tests/cors.test.js diff --git a/.env.example b/.env.example index b2997c1..b7792f3 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,10 @@ JWT_SECRET= # TCP port for the Express backend (default: 4000) PORT=4000 +# Comma-separated list of allowed CORS origins — no wildcards in production +# Example: https://app.vaccichain.io,https://admin.vaccichain.io +ALLOWED_ORIGINS=http://localhost:3000 + # ── Rate limiting ───────────────────────────────────────────────────────────── # Max SEP-10 challenge requests per IP per minute (default: 10) RATE_LIMIT_SEP10=10 diff --git a/backend/src/app.js b/backend/src/app.js index 08f9e1b..0039b24 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -13,7 +13,23 @@ const adminRoutes = require('./routes/admin'); const app = express(); -app.use(cors()); +const allowedOrigins = config.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean); + +app.use(cors({ + origin: (origin, callback) => { + // Allow server-to-server requests (no Origin header) only in non-production + if (!origin) { + return callback(null, process.env.NODE_ENV !== 'production'); + } + if (allowedOrigins.includes(origin)) { + return callback(null, true); + } + callback(new Error(`CORS: origin '${origin}' not allowed`)); + }, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], +})); app.use(express.json()); // Request logging middleware diff --git a/backend/src/config.js b/backend/src/config.js index 6c24dbd..aa7ac87 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -16,6 +16,8 @@ const schema = z.object({ SEP10_SERVER_KEY: z.string().min(1), JWT_SECRET: z.string().min(1), PORT: z.coerce.number().int().positive().default(4000), + // Comma-separated list of allowed origins, e.g. https://app.example.com,https://admin.example.com + ALLOWED_ORIGINS: z.string().default('http://localhost:3000'), // Transaction fees (stroops; 1 XLM = 10_000_000 stroops) SOROBAN_FEE: z.coerce.number().int().positive().default(100), diff --git a/backend/tests/cors.test.js b/backend/tests/cors.test.js new file mode 100644 index 0000000..d82a810 --- /dev/null +++ b/backend/tests/cors.test.js @@ -0,0 +1,47 @@ +const request = require('supertest'); + +describe('CORS', () => { + const ALLOWED = 'http://localhost:3000'; + const BLOCKED = 'https://evil.example.com'; + + let app; + + beforeEach(() => { + jest.resetModules(); + process.env.ALLOWED_ORIGINS = ALLOWED; + app = require('../src/app'); + }); + + it('allows requests from an allowed origin', async () => { + const res = await request(app).get('/health').set('Origin', ALLOWED); + expect(res.headers['access-control-allow-origin']).toBe(ALLOWED); + }); + + it('blocks requests from a disallowed origin', async () => { + const res = await request(app).get('/health').set('Origin', BLOCKED); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + }); + + it('handles preflight for allowed origin', async () => { + const res = await request(app) + .options('/health') + .set('Origin', ALLOWED) + .set('Access-Control-Request-Method', 'GET'); + expect(res.status).toBe(204); + expect(res.headers['access-control-allow-origin']).toBe(ALLOWED); + }); + + it('sets credentials header for allowed origin', async () => { + const res = await request(app).get('/health').set('Origin', ALLOWED); + expect(res.headers['access-control-allow-credentials']).toBe('true'); + }); + + it('supports multiple allowed origins', async () => { + jest.resetModules(); + process.env.ALLOWED_ORIGINS = `${ALLOWED},https://admin.example.com`; + const multiApp = require('../src/app'); + + const res = await request(multiApp).get('/health').set('Origin', 'https://admin.example.com'); + expect(res.headers['access-control-allow-origin']).toBe('https://admin.example.com'); + }); +});