diff --git a/docs/Reference.md b/docs/Reference.md index 22ddf36c0..d94d1ede5 100755 --- a/docs/Reference.md +++ b/docs/Reference.md @@ -925,6 +925,9 @@ can be registered with the server using the `server.state()` method, where: - `password` - password used for HMAC key generation. - `password` - password used for `'iron'` encoding. - `iron` - options for `'iron'` encoding. Defaults to [`require('iron').defaults`](https://github.com/hueniverse/iron#options). + - `failAction` - overrides the default server `state.cookies.failAction` setting. + - `clearInvalid` - overrides the default server `state.cookies.clearInvalid` setting. + - `strictHeader` - overrides the default server `state.cookies.strictHeader` setting. ```javascript // Set cookie definition diff --git a/lib/defaults.js b/lib/defaults.js index 01a710cb6..e92c43763 100755 --- a/lib/defaults.js +++ b/lib/defaults.js @@ -140,6 +140,9 @@ exports.cors = { credentials: false }; + +// Security headers + exports.security = { hsts: 15768000, xframe: 'deny', @@ -160,6 +163,12 @@ exports.cache = { // Primary cache configurati exports.state = { + // Validation settings + + strictHeader: undefined, // Defaults to server.settings.state.cookies.strictHeader + failAction: undefined, // Defaults to server.settings.state.cookies.failAction + clearInvalid: undefined, // Defaults to server.settings.state.cookies.clearInvalid + // Cookie attributes isSecure: false, diff --git a/lib/schema.js b/lib/schema.js index 88c01583f..5dd185ab9 100755 --- a/lib/schema.js +++ b/lib/schema.js @@ -340,4 +340,24 @@ internals.register = Joi.object({ vhost: internals.vhost }), select: internals.labels +}); + + +internals.state = Joi.object({ + strictHeader: Joi.boolean(), + failAction: Joi.string().valid('error', 'log', 'ignore'), + clearInvalid: Joi.boolean(), + isSecure: Joi.boolean(), + isHttpOnly: Joi.boolean(), + path: Joi.string(), + domain: Joi.string(), + ttl: Joi.number(), + encoding: Joi.string().valid('base64json', 'base64', 'form', 'iron', 'none'), + sign: Joi.object({ + password: Joi.string(), + integrity: Joi.object() + }), + iron: Joi.object(), + password: Joi.string(), + autoValue: Joi.any() }); \ No newline at end of file diff --git a/lib/server.js b/lib/server.js index 880acf2e6..a6e7c2127 100755 --- a/lib/server.js +++ b/lib/server.js @@ -463,7 +463,9 @@ internals.Server.prototype.state = function (name, options) { Hoek.assert(name && typeof name === 'string', 'Invalid name'); Hoek.assert(!this._stateDefinitions[name], 'State already defined:', name); - Hoek.assert(!options || !options.encoding || ['base64json', 'base64', 'form', 'iron', 'none'].indexOf(options.encoding) !== -1, 'Bad encoding'); + if (options) { + Schema.assert('state', options, name); + } this._stateDefinitions[name] = Hoek.applyToDefaults(Defaults.state, options || {}); }; diff --git a/lib/state.js b/lib/state.js index 3521a9d65..3be2bf996 100755 --- a/lib/state.js +++ b/lib/state.js @@ -39,7 +39,7 @@ internals.validateRx = { exports.parseCookies = function (request, next) { - var prepare = function () { + var parse = function () { request.state = {}; @@ -49,12 +49,10 @@ exports.parseCookies = function (request, next) { return next(); } - header(cookies); - }; - - var header = function (cookies) { + // Parse header var state = {}; + var names = []; var verify = cookies.replace(internals.parseRx, function ($0, $1, $2, $3) { var name = $1; @@ -69,6 +67,7 @@ exports.parseCookies = function (request, next) { } else { state[name] = value; + names.push(name); } return ''; @@ -82,43 +81,37 @@ exports.parseCookies = function (request, next) { return; // shouldStop calls next() } - // Validate cookie + // Parse cookies + + var parsed = {}; + Async.forEachSeries(names, function (name, nextName) { + + var value = state[name]; + var definition = request.server._stateDefinitions[name]; - if (request.server.settings.state.cookies.strictHeader) { - var names = Object.keys(state); - for (var i = 0, il = names.length; i < il; ++i) { - var name = names[i]; + // Validate cookie + + var strict = (definition && definition.strictHeader !== undefined ? definition.strictHeader + : request.server.settings.state.cookies.strictHeader); + if (strict) { if (!name.match(internals.validateRx.nameRx.strict)) { - if (shouldStop(cookies, name)) { + if (shouldStop(cookies, name, definition)) { return; // shouldStop calls next() } } var values = [].concat(state[name]); for (var v = 0, vl = values.length; v < vl; ++v) { - var value = values[v]; - if (!value.match(internals.validateRx.valueRx.strict)) { - if (shouldStop(cookies, name)) { + if (!values[v].match(internals.validateRx.valueRx.strict)) { + if (shouldStop(cookies, name, definition)) { return; // shouldStop calls next() } } } } - } - parse(state); - }; - - var parse = function (state) { - - var parsed = {}; - - var names = Object.keys(state); - Async.forEachSeries(names, function (name, nextName) { - - var value = state[name]; + // Check cookie format - var definition = request.server._stateDefinitions[name]; if (!definition || !definition.encoding) { @@ -132,7 +125,7 @@ exports.parseCookies = function (request, next) { unsign(name, value, definition, function (err, unsigned) { if (err) { - if (shouldStop({ name: name, value: value, settings: definition, reason: err.message }, name)) { + if (shouldStop({ name: name, value: value, settings: definition, reason: err.message }, name, definition)) { return; // shouldStop calls next() } @@ -142,7 +135,7 @@ exports.parseCookies = function (request, next) { decode(unsigned, definition, function (err, result) { if (err) { - if (shouldStop({ name: name, value: value, settings: definition, reason: err.message }, name)) { + if (shouldStop({ name: name, value: value, settings: definition, reason: err.message }, name, definition)) { return; // shouldStop calls next() } @@ -165,7 +158,7 @@ exports.parseCookies = function (request, next) { unsign(name, arrayValue, definition, function (err, unsigned) { if (err) { - if (shouldStop({ name: name, value: value, settings: definition, reason: err.message }, name)) { + if (shouldStop({ name: name, value: value, settings: definition, reason: err.message }, name, definition)) { return; // shouldStop calls next() } @@ -175,7 +168,7 @@ exports.parseCookies = function (request, next) { decode(unsigned, definition, function (err, result) { if (err) { - if (shouldStop({ name: name, value: value, settings: definition, reason: err.message }, name)) { + if (shouldStop({ name: name, value: value, settings: definition, reason: err.message }, name, definition)) { return; // shouldStop calls next() } @@ -288,23 +281,25 @@ exports.parseCookies = function (request, next) { return innerNext(null, result); }; - var shouldStop = function (error, name) { - - if (request.server.settings.state.cookies.clearInvalid && - name) { + var shouldStop = function (error, name, definition) { + var clearInvalid = (definition && definition.clearInvalid !== undefined ? definition.clearInvalid + : request.server.settings.state.cookies.clearInvalid); + if (clearInvalid && name) { request._clearState(name); } // failAction: 'error', 'log', 'ignore' - if (request.server.settings.state.cookies.failAction === 'log' || - request.server.settings.state.cookies.failAction === 'error') { + var failAction = (definition && definition.failAction !== undefined ? definition.failAction + : request.server.settings.state.cookies.failAction); + if (failAction === 'log' || + failAction === 'error') { request.log(['hapi', 'state', 'error'], error); } - if (request.server.settings.state.cookies.failAction === 'error') { + if (failAction === 'error') { next(Boom.badRequest('Bad cookie ' + (name ? 'value: ' + Hoek.escapeHtml(name) : 'header'))); return true; } @@ -312,7 +307,7 @@ exports.parseCookies = function (request, next) { return false; }; - prepare(); + parse(); }; diff --git a/test/state.js b/test/state.js index b866ea110..976ac253b 100755 --- a/test/state.js +++ b/test/state.js @@ -173,6 +173,7 @@ describe('State', function () { pass('key=Fe26.2**f3fc42242467f7a97c042be866a32c1e7645045c2cc085124eadc66d25fc8395*URXpH8k-R0d4O5bnY23fRQ*uq9rd8ZzdjZqUrq9P2Ci0yZ-EEUikGzxTLn6QTcJ0bc**3880c0ac8bab054f529afec8660ebbbbc8050e192e39e5d622e7ac312b9860d0*r_g7N9kJYqXDrFlvOnuKpfpEWwrJLOKMXEI43LAGeFg', { key: { a: 1, b: 2, c: 3 } }, null, { key: { encoding: 'iron', password: 'password', iron: Iron.defaults } }); pass('sid=a=1&b=2&c=3%20x.2d75635d74c1a987f84f3ee7f3113b9a2ff71f89d6692b1089f19d5d11d140f8*xGhc6WvkE55V-TzucCl0NVFmbijeCwgs5Hf5tAVbSUo', { sid: { a: '1', b: '2', c: '3 x' } }, null, { sid: { encoding: 'form', sign: { password: 'password' } } }); pass('sid=a=1&b=2&c=3%20x.2d75635d74c1a987f84f3ee7f3113b9a2ff71f89d6692b1089f19d5d11d140f8*xGhc6WvkE55V-TzucCl0NVFmbijeCwgs5Hf5tAVbSUo', { sid: { a: '1', b: '2', c: '3 x' } }, null, { sid: { encoding: 'form', sign: { password: 'password', integrity: Iron.defaults.integrity } } }); + pass('a="1', { a: '"1' }, null, { a: { strictHeader: false } }); var loose = Hoek.clone(Defaults.server.state); loose.cookies.strictHeader = false; @@ -258,6 +259,35 @@ describe('State', function () { clearInvalid.cookies.clearInvalid = true; fail('sid=a=1&b=2&c=3%20x', clearInvalid, { sid: { encoding: 'form', sign: { password: 'password' } } }); }); + + it('uses cookie failAction override', function (done) { + + var server = new Hapi.Server(); + server.state('test', { failAction: 'log' }); + server.route({ method: 'GET', path: '/', handler: function (request, reply) { reply(request.state.test); } }); + + server.inject({ method: 'GET', url: '/', headers: { cookie: 'test="a' } }, function (res) { + + expect(res.statusCode).to.equal(200); + expect(res.result).to.equal('"a'); + done(); + }); + }); + + it('uses cookie clearInvalid override', function (done) { + + var server = new Hapi.Server(); + server.state('test', { clearInvalid: true, failAction: 'ignore', encoding: 'base64json' }); + server.route({ method: 'GET', path: '/', handler: function (request, reply) { reply(request.state.test); } }); + + server.inject({ method: 'GET', url: '/', headers: { cookie: 'test=a' } }, function (res) { + + expect(res.statusCode).to.equal(200); + expect(res.result).to.equal(null); + expect(res.headers['set-cookie'][0]).to.equal('test=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT'); + done(); + }); + }); }); describe('#generateSetCookieHeader', function () {