diff --git a/package.json b/package.json index 96ed848..ba79900 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,11 @@ }, "devDependencies": { "mocha": "~3.2.0", - "should": "~11.2.1", "restify": "~4.3.0", + "should": "~11.2.1", "supertest": "~3.0.0" + }, + "dependencies": { + "assert-plus": "^1.0.0" } } diff --git a/src/actual.js b/src/actual.js index 448178c..85e3845 100644 --- a/src/actual.js +++ b/src/actual.js @@ -1,20 +1,27 @@ -var restify = require('restify'); - -// -// For now, delegate to restify.CORS -// -// Although there's a few things we could fix -// around Access-Control-Expose-Headers -// -// It would also break the cyclic dependency with Restify :) -// +var origin = require('./origin.js'); +var constants = require('./constants.js'); exports.handler = function(options) { - return restify.CORS({ - credentials: options.credentials, - origins: options.origins, - headers: options.exposeHeaders - }); + return function(req, res, next) { + var originHeader = req.headers['origin']; + + // If either no origin was set, or the origin isn't supported, continue + // without setting any headers + if (!originHeader || !origin.match(originHeader, options.origins)) { + return next(); + } + + // if match was found, let's set some headers. + res.setHeader(constants['AC_ALLOW_ORIGIN'], originHeader); + res.setHeader(constants['STR_VARY'], constants['STR_ORIGIN']); + if(options.credentials) { + res.setHeader(constants['AC_ALLOW_CREDS'], 'true'); + } + res.setHeader(constants['AC_EXPOSE_HEADERS'], + options.exposeHeaders.join(', ')); + + return next(); + }; }; diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..c8a2403 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,32 @@ +module.exports = { + ALLOW_HEADERS: [ + 'accept', + 'accept-version', + 'content-type', + 'request-id', + 'origin', + 'x-api-version', + 'x-request-id', + 'x-requested-with' + ], + EXPOSE_HEADERS: [ + 'api-version', + 'content-length', + 'content-md5', + 'content-type', + 'date', + 'request-id', + 'response-time' + ], + AC_REQ_METHOD: 'access-control-request-method', + AC_REQ_HEADERS: 'access-control-request-headers', + AC_ALLOW_CREDS: 'access-control-allow-credentials', + AC_ALLOW_ORIGIN: 'access-control-allow-origin', + AC_ALLOW_HEADERS: 'access-control-allow-headers', + AC_ALLOW_METHODS: 'access-control-allow-methods', + AC_EXPOSE_HEADERS: 'access-control-expose-headers', + AC_MAX_AGE: 'access-control-max-age', + STR_VARY: 'vary', + STR_ORIGIN: 'origin', + HTTP_NO_CONTENT: 204 +} diff --git a/src/index.js b/src/index.js index a3a7e3f..337ea22 100644 --- a/src/index.js +++ b/src/index.js @@ -1,17 +1,79 @@ -var util = require('util'); -var preflight = require('./preflight'); -var actual = require('./actual'); +var assert = require('assert-plus'); +var preflight = require('./preflight'); +var actual = require('./actual'); +var constants = require('./constants.js'); +/** + * From http://www.w3.org/TR/cors/#resource-processing-model + * + * If "simple" request (paraphrased): + * + * 1. If the Origin header is not set, or if the value of Origin is not a + * case-sensitive match to any values listed in `opts.origins`, do not + * send any CORS headers + * + * 2. If the resource supports credentials add a single + * 'Access-Control-Allow-Credentials' header with the value as "true", and + * ensure 'AC-Allow-Origin' is not '*', but is the request header value, + * otherwise add a single Access-Control-Allow-Origin header, with either the + * value of the Origin header or the string "*" as value + * + * 3. Add Access-Control-Expose-Headers as appropriate + * + * @public + * @function createCorsContext + * @param {Object} options an options object + * @param {Array} [options.origins] an array of whitelisted origins, can be + * both strings and regular expressions + * @param {Boolean} [options.credentials] if true, uses creds + * @param {Array} [options.allowHeaders] user defined headers to allow + * @param {Array} [options.exposeHeaders] user defined headers to expose + * @param {Number} [options.preflightMaxAge] ms to cache preflight requests + * @param {Object | Function} [options.preflightStrategy] + * customize preflight request handling + * @returns {Object} returns an object with actual and preflight handlers + */ module.exports = function(options) { - if (! util.isArray(options.origins)) options.origins = ['*']; - if (! util.isArray(options.allowHeaders)) options.allowHeaders = []; - if (! util.isArray(options.exposeHeaders)) options.exposeHeaders = []; - if (options.origins[0] === '*') options.credentials = false; + assert.object(options, 'options'); + assert.optionalArray(options.origins, 'options.origins'); + options.origins.forEach(function (o) { + assert.ok(typeof o === 'string' || o instanceof RegExp, o + + ' is not a valid origin'); + }); + assert.optionalBool(options.credentials, 'options.credentials'); + assert.optionalArrayOfString(options.allowHeaders, 'options.allowHeaders'); + assert.optionalArrayOfString(options.exposeHeaders, + 'options.exposeHeaders'); + assert.optionalNumber(options.preflightMaxAge, 'options.preflightMaxAge'); + assert.optionalObject(options.preflightStrategy, + 'options.preflightStrategy'); + + var opts = options; + opts.origins = options.origins || ['*'] + opts.credentials = options.credentials || false; + opts.allowHeaders = options.allowHeaders || []; + opts.exposeHeaders = options.exposeHeaders || []; + + assert.ok(options.origins.indexOf('*') === -1 || + options.credentials === false, + 'credentials not supported with wildcard'); + + constants['EXPOSE_HEADERS'].forEach(function (h) { + if (opts.exposeHeaders.indexOf(h) === -1) { + opts.exposeHeaders.push(h); + } + }); + + constants['ALLOW_HEADERS'].forEach(function (h) { + if (opts.allowHeaders.indexOf(h) === -1) { + opts.allowHeaders.push(h); + } + }); return { - actual: actual.handler(options), - preflight: preflight.handler(options) + actual: actual.handler(opts), + preflight: preflight.handler(opts) }; }; diff --git a/src/origin.js b/src/origin.js index 6194aa5..e05b221 100644 --- a/src/origin.js +++ b/src/origin.js @@ -1,11 +1,16 @@ +exports.match = function(incomingOrigin, origins) { + if(!incomingOrigin) { + return null; + } -exports.match = function(origin, list) { - function belongs(o) { - return (origin === o || o === "*"); - } - if (origin && list.some(belongs)) { - return origin; - } else { - return false; - } + for(var i = 0; i < origins.length; i++) { + var origin = origins[i]; + if( (origin instanceof RegExp && origin.test(incomingOrigin)) || + (typeof origin === 'string' && origin === incomingOrigin) || + (origin === '*')) { + return incomingOrigin; + } + } + + return null; }; diff --git a/src/preflight.js b/src/preflight.js index ce4dee6..c7f2a8b 100644 --- a/src/preflight.js +++ b/src/preflight.js @@ -1,19 +1,5 @@ -var restify = require('restify'); -var origin = require('./origin'); - -// -// For now we use the "default headers" from restify.CORS -// Maybe this should just be a global setting on this module -// (ie. list of extra Access-Control-Expose-Headers, regardless of what the middleware config says) -// - -// -// TODO: -// Handle the spec better around "simple methods" and "simple headers". -// - -var DEFAULT_ALLOW_HEADERS = restify.CORS.ALLOW_HEADERS; -var HTTP_NO_CONTENT = 204; +var origin = require('./origin'); +var constants = require('./constants.js'); exports.handler = function(options) { @@ -25,37 +11,36 @@ exports.handler = function(options) { if (origin.match(originHeader, options.origins) === false) return next(); // 6.2.3 - var requestedMethod = req.headers['access-control-request-method']; + var requestedMethod = req.headers[constants['AC_REQ_METHOD']]; if (!requestedMethod) return next(); // 6.2.4 - var requestedHeaders = req.headers['access-control-request-headers']; + var requestedHeaders = req.headers[constants['AC_REQ_HEADERS']]; requestedHeaders = requestedHeaders ? requestedHeaders.split(', ') : []; var allowedMethods = [requestedMethod, 'OPTIONS']; - var allowedHeaders = DEFAULT_ALLOW_HEADERS.concat(['x-requested-with']) - .concat(options.allowHeaders); + var allowedHeaders = constants['ALLOW_HEADERS'] + .concat(options.allowHeaders); res.once('header', function() { - // 6.2.7 - res.header('Access-Control-Allow-Origin', originHeader); - res.header('Access-Control-Allow-Credentials', true); + res.header(constants['AC_ALLOW_ORIGIN'], originHeader); + res.header(constants['AC_ALLOW_CREDS'], true); // 6.2.8 if (options.preflightMaxAge) { - res.header('Access-Control-Max-Age', options.preflightMaxAge); + res.header(constants['AC_MAX_AGE'], options.preflightMaxAge); } // 6.2.9 - res.header('Access-Control-Allow-Methods', allowedMethods.join(', ')); + res.header(constants['AC_ALLOW_METHODS'], allowedMethods.join(', ')); // 6.2.10 - res.header('Access-Control-Allow-Headers', allowedHeaders.join(', ')); + res.header(constants['AC_ALLOW_HEADERS'], allowedHeaders.join(', ')); }); - res.send(HTTP_NO_CONTENT); + res.send(constants['HTTP_NO_CONTENT']); }; }; diff --git a/test/cors.actual.spec.js b/test/cors.actual.spec.js index 3cde03f..e8d9a0e 100644 --- a/test/cors.actual.spec.js +++ b/test/cors.actual.spec.js @@ -45,16 +45,13 @@ describe('CORS: simple / actual requests', function() { }); it('6.1.3 Does not set Access-Control-Allow-Credentials header if Origin is *', function(done) { - var server = test.corsServer({ - origins: ['*'], - credentials: true - }); - request(server) - .get('/test') - .set('Origin', 'http://api.myapp.com') - .expect(test.noHeader('access-control-allow-credentials')) - .expect(200) - .end(done); + should.throws(function() { + var server = test.corsServer({ + origins: ['*'], + credentials: true + }); + }); + done(); }); it('6.1.3 Sets Access-Control-Allow-Credentials header if configured', function(done) { diff --git a/test/origin.spec.js b/test/origin.spec.js index 873fce3..4ce03b1 100644 --- a/test/origin.spec.js +++ b/test/origin.spec.js @@ -10,12 +10,12 @@ describe('Origin list', function() { it('returns null if the origin is not in the list', function() { var o = origin.match('http://random-website.com', list); - o.should.eql(false); + (o === null).should.eql(true); }); it('does not do partial matches', function() { var o = origin.match('api.myapp.com', list); - o.should.eql(false); + (o === null).should.eql(true); }); it('returns the origin if it matched', function() {