diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9712e8f..d4f36ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,7 +128,7 @@ jobs: run: docker compose -f compose.yml -f compose.dev.yml up -d mongo redis - name: Start server - run: LETSCUBE_TEST_AUTH=true SOCIAL_FEATURES_ENABLED=true POSTGRES_ENABLED=false yarn workspace letscube-server node index.js > server.log 2>&1 & + run: CORS_ORIGINS=http://localhost:3000,http://localhost:8080 LETSCUBE_TEST_AUTH=true SOCIAL_FEATURES_ENABLED=true POSTGRES_ENABLED=false yarn workspace letscube-server node index.js > server.log 2>&1 & - name: Start socket server run: SOCIAL_FEATURES_ENABLED=true POSTGRES_ENABLED=false yarn workspace letscube-server node socket/ > socket.log 2>&1 & diff --git a/server/index.js b/server/index.js index 704e262..6820216 100644 --- a/server/index.js +++ b/server/index.js @@ -11,6 +11,7 @@ const { connect } = require('./database'); const { createHealthHandler, createHealthReporter } = require('./health'); const { initializePostgres, pool, startPostgresMaintenance } = require('./postgres'); const session = require('./middlewares/session'); +const { createCorsOptions } = require('./middlewares/cors'); const logger = require('./logger'); const auth = require('./auth'); const api = require('./api'); @@ -73,11 +74,7 @@ const init = async () => { /* Cors */ - app.use(cors({ - credentials: true, - origin: true, - // config.cors.origin.map((o) => new RegExp(o)), - })); + app.use(cors(createCorsOptions(config.cors.origin))); app.get('/api/csrf-token', (req, res) => res.json({ csrfToken: req.csrfToken() })); diff --git a/server/middlewares/cors.js b/server/middlewares/cors.js new file mode 100644 index 0000000..35d0335 --- /dev/null +++ b/server/middlewares/cors.js @@ -0,0 +1,10 @@ +const createCorsOptions = (origins) => { + const allowedOrigins = new Set(origins); + + return { + credentials: true, + origin: (origin, callback) => callback(null, !origin || allowedOrigins.has(origin)), + }; +}; + +module.exports = { createCorsOptions }; diff --git a/server/middlewares/cors.test.js b/server/middlewares/cors.test.js new file mode 100644 index 0000000..d3261a1 --- /dev/null +++ b/server/middlewares/cors.test.js @@ -0,0 +1,62 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +const http = require('http'); +const cors = require('cors'); +const express = require('express'); +const { createCorsOptions } = require('./cors'); + +const request = (server, origin) => new Promise((resolve, reject) => { + const req = http.request({ + headers: origin ? { origin } : {}, + host: '127.0.0.1', + method: 'GET', + path: '/', + port: server.address().port, + }, (res) => { + res.resume(); + res.on('end', () => resolve({ headers: res.headers, status: res.statusCode })); + }); + req.on('error', reject); + req.end(); +}); + +describe('CORS origin allowlist', () => { + let server; + + beforeEach((done) => { + const app = express(); + app.use(cors(createCorsOptions(['https://letscube.net', 'http://localhost:3000']))); + app.get('/', (req, res) => res.json({ ok: true })); + server = app.listen(0, '127.0.0.1', () => done()); + }); + + afterEach((done) => { + server.close(() => done()); + }); + + it('permits configured credentialed browser origins', async () => { + const response = await request(server, 'https://letscube.net'); + + expect(response).toEqual(expect.objectContaining({ status: 200 })); + expect(response.headers).toEqual(expect.objectContaining({ + 'access-control-allow-credentials': 'true', + 'access-control-allow-origin': 'https://letscube.net', + })); + }); + + it('does not grant CORS permission to an unconfigured origin', async () => { + const response = await request(server, 'https://untrusted.example'); + + expect(response).toEqual(expect.objectContaining({ status: 200 })); + expect(response.headers).not.toHaveProperty('access-control-allow-credentials'); + expect(response.headers).not.toHaveProperty('access-control-allow-origin'); + }); + + it('leaves same-origin and non-browser requests working', async () => { + const response = await request(server); + + expect(response).toEqual(expect.objectContaining({ status: 200 })); + expect(response.headers).not.toHaveProperty('access-control-allow-origin'); + }); +});