diff --git a/lib/plugins/cors/index.js b/lib/plugins/cors/index.js index 5306417..93222e5 100644 --- a/lib/plugins/cors/index.js +++ b/lib/plugins/cors/index.js @@ -7,61 +7,206 @@ 'use strict'; -// Imports const _ = require('lodash'); -const Router = require('../../routes/Router'); + +/** + * @typedef {string | string[] | RegExp | OriginPredicate} OriginConfig + * + * What origins are allowed. One of: + * - `'*'` — public API. Any origin reads the response. Disallows + * credentials. + * - exact string — only that origin (case-sensitive). Browser sees that + * exact value echoed back. + * - string[] — allowed if the incoming Origin header is in the set. + * - RegExp — allowed if the incoming Origin header matches. + * - function — `(incoming: string) => boolean`; allowed if it returns + * truthy. + * + * Anything other than `'*'` causes the response to echo the incoming Origin + * (with `Vary: Origin` so caches don't poison) and is the only form + * compatible with `allowCredentials: true`. + */ + +/** + * @callback OriginPredicate + * @param {string} incoming - The request's `Origin` header value. + * @returns {boolean} + */ /** * @typedef CorsOptions - * @property {String} [origin='*'] - What origin to allow CORS from (or '*' for all). - * @property {boolean} [allowCredentials=true] - Whether or not to allow credentials from other source. - * @property {boolean} [preflight=true] - Whether to support pre-flight CORS for more complex methods. - * @property {String} [methods] - What HTTP methods to support (Defaults to all valid http methods) - * @property {String} [headers='*'] - What http headers to allow (or '*' for all). - * @property {Number} [maxAge=86400] - Maximum age for pre-flight response for browser. + * @property {OriginConfig} [origin='*'] - What origin to allow. See {@link OriginConfig}. + * @property {boolean} [allowCredentials=false] - Send `Access-Control-Allow-Credentials: true`. + * Cannot be combined with wildcard `origin`, `methods`, or `headers` — the CORS spec + * rejects that pairing and the plugin throws at construction time. + * @property {boolean} [preflight=true] - Register the preflight `OPTIONS` handler. + * @property {string | string[]} [methods] - Methods to allow on the resource. Defaults to + * the common HTTP verbs (`GET, HEAD, POST, PUT, PATCH, DELETE`). + * @property {string | string[]} [headers='*'] - Request headers to allow. `'*'` reflects + * whatever the browser asked for in `Access-Control-Request-Headers` — only legal + * when `allowCredentials` is `false`. + * @property {string[]} [exposeHeaders=[]] - Response headers to expose to JS via + * `Access-Control-Expose-Headers` (beyond the seven safelisted ones). + * @property {number} [maxAge=86400] - Preflight cache lifetime, in seconds. Send `0` + * to skip the header. */ /** * @callback Cors - * @param {CorsOptions} options - The CORS options to use. Will be filled in with defaults. - * @returns {import("../../routes/Router").RouteBack} the plugin router (to be used). + * @param {CorsOptions} [options] - CORS options. Defaults are public-safe + * (`origin: '*'`, credentials off). Validated on construction. + * @returns {import("../../routes/Router").RouteBack} The plugin's mountable router back. */ -// Default options: +// CORS spec language for "common HTTP verbs". OPTIONS is added by the preflight +// handler itself; don't echo it in Access-Control-Allow-Methods unless asked. +const DEFAULT_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']; + const defaults = { - origin: '*', - allowCredentials: true, + origin: '*', + allowCredentials: false, preflight: true, - methods: Router.allHttpMethods.join(', '), + methods: DEFAULT_METHODS, headers: '*', - maxAge: 86400 -} + exposeHeaders: [], + maxAge: 86400, +}; /** - * CORS plugin to allow easy Cross-Origin-Resource-Sharing. + * CORS plugin. Adds `Access-Control-*` response headers and handles preflight + * `OPTIONS` requests. + * + * Mount with a glob so every subpath in the mount context gets covered: + * + * router.use('/**', RestNio.cors()); // app-wide + * router.use('/api**', RestNio.cors({ // /api/* only + * origin: ['https://app.example.com', 'https://staging.example.com'], + * allowCredentials: true, + * headers: ['content-type', 'authorization'], + * })); + * + * Mounting without a glob (`router.use(RestNio.cors())` or + * `router.use('/api', RestNio.cors())`) only attaches CORS to that exact + * path, NOT to its subpaths — that's a router-prefix semantic, not a plugin + * bug, but it's a sharp edge in practice. + * + * `router.all('/foo', ...)` matches every HTTP verb including `OPTIONS`. If + * such a route is mounted at a path the cors preflight would otherwise own, + * the user route wins and the preflight reaches your handler with an empty + * body. Use specific verbs (`router.post`, `router.get`, …) for any path + * the browser will preflight. + * * @type {Cors} */ -module.exports = (options) => (router) => { - options = _.defaultsDeep(options, defaults); - - // Simple CORS (For get post and head requests) - router.httpAll('', { isActive: false, func: (params, client) => { - client.header('access-control-allow-origin', options.origin); - }}, ['GET', 'POST', 'HEAD']); - - if (options.preflight) { - // Pre-Flight CORS (For special cases) - router.httpOptions('', { isActive: true, func: (params, client) => { - client.header('access-control-allow-origin', options.origin); - client.header('access-control-allow-credentials', options.allowCredentials); - client.header('access-control-request-method', options.methods); - let allowedHeaders = options.headers; - if (allowedHeaders === '*' && client.request.headers['access-control-request-headers']) { - allowedHeaders = client.request.headers['access-control-request-headers']; - } - client.header('access-control-allow-headers', allowedHeaders); - if (options.maxAge) client.header('access-control-max-age', options.maxAge); - }}); +module.exports = (options = {}) => { + const o = _.defaultsDeep({}, options, defaults); + + validateOptions(o); + + const matchOrigin = compileOriginMatcher(o.origin); + const methodsHeader = Array.isArray(o.methods) ? o.methods.join(', ') : o.methods; + const exposeHeader = (o.exposeHeaders || []).join(', '); + const fixedHeadersHeader = headersConfigToHeader(o.headers); + + return (router) => { + + // -- Simple/actual response: passive (isActive: false) handler that + // fires alongside any matched active route to attach CORS response + // headers. Runs on every verb so credentialed POSTs are covered. + router.httpAll('', { + isActive: false, + func: (_params, client) => { + const incoming = client.request.headers.origin; + const allow = matchOrigin(incoming); + if (allow === null) return; + client.header('access-control-allow-origin', allow); + if (allow !== '*') client.header('vary', 'Origin'); + if (o.allowCredentials) client.header('access-control-allow-credentials', 'true'); + if (exposeHeader) client.header('access-control-expose-headers', exposeHeader); + }, + }); + + // -- Preflight: terminating OPTIONS handler. + if (o.preflight) { + router.httpOptions('', { + isActive: true, + func: (_params, client) => { + const incoming = client.request.headers.origin; + const allow = matchOrigin(incoming); + if (allow === null) { + // Origin not in the allow set. Throw an error code the + // browser will still interpret as "preflight failed" + // (no Access-Control-Allow-Origin), but it's debuggable + // from curl/devtools. + throw [403, 'origin not allowed']; + } + client.header('access-control-allow-origin', allow); + if (allow !== '*') client.header('vary', 'Origin'); + if (o.allowCredentials) client.header('access-control-allow-credentials', 'true'); + client.header('access-control-allow-methods', methodsHeader); + + let headersHeader = fixedHeadersHeader; + if (o.headers === '*' && client.request.headers['access-control-request-headers']) { + // Reflect what the browser asked for. The spec only + // permits this when credentials are off — already + // enforced by validateOptions(). + headersHeader = client.request.headers['access-control-request-headers']; + } + if (headersHeader) client.header('access-control-allow-headers', headersHeader); + + if (o.maxAge > 0) client.header('access-control-max-age', String(o.maxAge)); + return ''; + }, + }); + } + }; +}; + +// -- Internals --------------------------------------------------------------- + +function validateOptions(o) { + if (o.allowCredentials) { + if (o.origin === '*') { + throw new Error( + 'cors: allowCredentials: true is invalid with origin: "*". ' + + 'Pass an explicit origin (string), array, RegExp, or predicate function.', + ); + } + if (o.headers === '*') { + throw new Error( + 'cors: allowCredentials: true is invalid with headers: "*". ' + + 'List the allowed request headers explicitly (e.g. ["content-type", "authorization"]).', + ); + } + if (o.methods === '*' || (Array.isArray(o.methods) && o.methods.includes('*'))) { + throw new Error( + 'cors: allowCredentials: true is invalid with wildcard methods. ' + + 'Pass an explicit verb list.', + ); + } + } +} + +function compileOriginMatcher(origin) { + if (origin === '*') return () => '*'; + if (typeof origin === 'string') { + return (incoming) => (incoming === origin ? origin : null); + } + if (Array.isArray(origin)) { + const set = new Set(origin); + return (incoming) => (incoming && set.has(incoming) ? incoming : null); + } + if (origin instanceof RegExp) { + return (incoming) => (incoming && origin.test(incoming) ? incoming : null); } + if (typeof origin === 'function') { + return (incoming) => (incoming && origin(incoming) ? incoming : null); + } + throw new Error(`cors: invalid origin type (${typeof origin}). Expected string, string[], RegExp, or function.`); +} -} \ No newline at end of file +function headersConfigToHeader(headers) { + if (headers === '*') return ''; // resolved per-request in the preflight + if (Array.isArray(headers)) return headers.join(', '); + return headers || ''; +} diff --git a/package.json b/package.json index 6bdb6d5..f08faf9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "restnio", - "version": "4.7.4", + "version": "4.8.0", "description": "Node implementation of RestNio, the easy peasy API system.", "main": "index.js", "scripts": { diff --git a/test/integration/cors.js b/test/integration/cors.js index c5be78f..c2aa668 100644 --- a/test/integration/cors.js +++ b/test/integration/cors.js @@ -12,7 +12,11 @@ describe('cors plugin (integration)', function() { server = null; }); - it('adds access-control-allow-origin on simple requests', async () => { + // -------------------------------------------------------------------- + // Defaults + // -------------------------------------------------------------------- + + it('adds access-control-allow-origin: * on simple requests by default', async () => { server = await spinUp((router) => { router.use('/api**', RestNio.cors()); router.get('/api/thing', () => ({ ok: true })); @@ -20,43 +24,129 @@ describe('cors plugin (integration)', function() { const res = await request('GET', `${server.url}/api/thing`); res.status.should.equal(200); res.headers['access-control-allow-origin'].should.equal('*'); + // Credentials default is OFF — header MUST NOT appear. + should(res.headers['access-control-allow-credentials']).be.undefined(); }); - it('uses a custom origin when specified', async () => { + it('does not emit Vary: Origin when origin is wildcard', async () => { server = await spinUp((router) => { - router.use('/api**', RestNio.cors({ origin: 'https://example.com' })); + router.use('/api**', RestNio.cors()); router.get('/api/thing', () => ({ ok: true })); }); const res = await request('GET', `${server.url}/api/thing`); - res.headers['access-control-allow-origin'].should.equal('https://example.com'); + should(res.headers['vary']).be.undefined(); }); - it('responds to preflight OPTIONS with the full set of headers', async () => { + // -------------------------------------------------------------------- + // Origin: string + // -------------------------------------------------------------------- + + it('echoes a configured string origin only when the request matches', async () => { server = await spinUp((router) => { router.use('/api**', RestNio.cors({ origin: 'https://example.com' })); router.get('/api/thing', () => 'ok'); }); + + const ok = await request('GET', `${server.url}/api/thing`, { + headers: { origin: 'https://example.com' }, + }); + ok.headers['access-control-allow-origin'].should.equal('https://example.com'); + ok.headers['vary'].should.equal('Origin'); + + const bad = await request('GET', `${server.url}/api/thing`, { + headers: { origin: 'https://evil.example' }, + }); + should(bad.headers['access-control-allow-origin']).be.undefined(); + }); + + // -------------------------------------------------------------------- + // Origin: array, regex, function + // -------------------------------------------------------------------- + + it('accepts an array of origins', async () => { + server = await spinUp((router) => { + router.use('/api**', RestNio.cors({ + origin: ['https://a.example.com', 'https://b.example.com'], + })); + router.get('/api/thing', () => 'ok'); + }); + const a = await request('GET', `${server.url}/api/thing`, { + headers: { origin: 'https://b.example.com' }, + }); + a.headers['access-control-allow-origin'].should.equal('https://b.example.com'); + const x = await request('GET', `${server.url}/api/thing`, { + headers: { origin: 'https://c.example.com' }, + }); + should(x.headers['access-control-allow-origin']).be.undefined(); + }); + + it('accepts a RegExp origin', async () => { + server = await spinUp((router) => { + router.use('/api**', RestNio.cors({ origin: /^https:\/\/.*\.example\.com$/ })); + router.get('/api/thing', () => 'ok'); + }); + const ok = await request('GET', `${server.url}/api/thing`, { + headers: { origin: 'https://staging.example.com' }, + }); + ok.headers['access-control-allow-origin'].should.equal('https://staging.example.com'); + const no = await request('GET', `${server.url}/api/thing`, { + headers: { origin: 'https://example.org' }, + }); + should(no.headers['access-control-allow-origin']).be.undefined(); + }); + + it('accepts a predicate function as origin', async () => { + server = await spinUp((router) => { + router.use('/api**', RestNio.cors({ + origin: (incoming) => incoming.endsWith('.trusted.test'), + })); + router.get('/api/thing', () => 'ok'); + }); + const ok = await request('GET', `${server.url}/api/thing`, { + headers: { origin: 'https://app.trusted.test' }, + }); + ok.headers['access-control-allow-origin'].should.equal('https://app.trusted.test'); + const no = await request('GET', `${server.url}/api/thing`, { + headers: { origin: 'https://app.untrusted.test' }, + }); + should(no.headers['access-control-allow-origin']).be.undefined(); + }); + + // -------------------------------------------------------------------- + // Preflight + // -------------------------------------------------------------------- + + it('responds to preflight OPTIONS with the full set of headers', async () => { + server = await spinUp((router) => { + router.use('/api**', RestNio.cors({ + origin: 'https://example.com', + allowCredentials: true, + headers: ['content-type', 'authorization'], + })); + router.get('/api/thing', () => 'ok'); + }); const res = await request('OPTIONS', `${server.url}/api/thing`, { headers: { - 'origin': 'https://example.com', - 'access-control-request-method': 'GET' - } + origin: 'https://example.com', + 'access-control-request-method': 'GET', + }, }); res.status.should.equal(200); res.headers['access-control-allow-origin'].should.equal('https://example.com'); res.headers['access-control-allow-credentials'].should.equal('true'); - res.headers.should.have.property('access-control-request-method'); - res.headers.should.have.property('access-control-allow-headers'); + res.headers['vary'].should.equal('Origin'); + res.headers['access-control-allow-methods'].should.containEql('GET'); + res.headers['access-control-allow-headers'].should.equal('content-type, authorization'); res.headers['access-control-max-age'].should.equal('86400'); }); - it('reflects requested headers when headers="*"', async () => { + it('reflects requested headers when headers="*" (the default)', async () => { server = await spinUp((router) => { router.use('/api**', RestNio.cors()); router.get('/api/thing', () => 'ok'); }); const res = await request('OPTIONS', `${server.url}/api/thing`, { - headers: { 'access-control-request-headers': 'x-foo, x-bar' } + headers: { 'access-control-request-headers': 'x-foo, x-bar' }, }); res.headers['access-control-allow-headers'].should.equal('x-foo, x-bar'); }); @@ -67,11 +157,26 @@ describe('cors plugin (integration)', function() { router.get('/api/thing', () => 'ok'); }); const res = await request('OPTIONS', `${server.url}/api/thing`, { - headers: { 'access-control-request-headers': 'x-foo, x-bar' } + headers: { 'access-control-request-headers': 'x-foo, x-bar' }, }); res.headers['access-control-allow-headers'].should.equal('x-only-this'); }); + it('preflight: rejects an unallowed origin with 403', async () => { + server = await spinUp((router) => { + router.use('/api**', RestNio.cors({ origin: 'https://example.com' })); + router.get('/api/thing', () => 'ok'); + }); + const res = await request('OPTIONS', `${server.url}/api/thing`, { + headers: { origin: 'https://evil.example' }, + }); + // 403 is enough — browsers fail any non-2xx preflight, regardless of + // headers. (The framework's global `corsErrorOrigin: '*'` default + // still attaches an allow-origin header on errors so they're visible + // to fetch error handlers — that's a separate, intentional behavior.) + res.status.should.equal(403); + }); + it('with preflight=false does not register an OPTIONS handler', async () => { server = await spinUp((router) => { router.use('/api**', RestNio.cors({ preflight: false })); @@ -80,4 +185,39 @@ describe('cors plugin (integration)', function() { const res = await request('OPTIONS', `${server.url}/api/thing`); res.status.should.equal(404); }); + + // -------------------------------------------------------------------- + // Expose-Headers + // -------------------------------------------------------------------- + + it('emits Access-Control-Expose-Headers when configured', async () => { + server = await spinUp((router) => { + router.use('/api**', RestNio.cors({ exposeHeaders: ['x-total-count', 'x-page'] })); + router.get('/api/thing', () => 'ok'); + }); + const res = await request('GET', `${server.url}/api/thing`); + res.headers['access-control-expose-headers'].should.equal('x-total-count, x-page'); + }); + + // -------------------------------------------------------------------- + // Construction-time validation + // -------------------------------------------------------------------- + + it('throws when allowCredentials: true is paired with origin: "*"', () => { + (() => RestNio.cors({ allowCredentials: true })).should.throw(/allowCredentials/); + }); + + it('throws when allowCredentials: true is paired with headers: "*"', () => { + (() => + RestNio.cors({ + origin: 'https://example.com', + allowCredentials: true, + headers: '*', + }) + ).should.throw(/allowCredentials/); + }); + + it('throws on an invalid origin type', () => { + (() => RestNio.cors({ origin: 42 })).should.throw(/invalid origin/); + }); });