diff --git a/cookie.js b/cookie.js new file mode 100644 index 0000000..349db26 --- /dev/null +++ b/cookie.js @@ -0,0 +1,225 @@ +/*! + * Adapted from https://github.com/jshttp/cookie + * + * (The MIT License) + * + * Copyright (c) 2012-2014 Roman Shtylman + * Copyright (c) 2015 Douglas Christopher Wilson + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * 'Software'), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +'use strict' + +/** + * Module exports. + * @public + */ + +exports.parse = parse +exports.serialize = serialize + +/** + * Module variables. + * @private + */ + +const decode = decodeURIComponent +const encode = encodeURIComponent + +/** + * RegExp to match field-content in RFC 7230 sec 3.2 + * + * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] + * field-vchar = VCHAR / obs-text + * obs-text = %x80-FF + */ + +const fieldContentRegExp = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/ // eslint-disable-line + +/** + * Parse a cookie header. + * + * Parse the given cookie header string into an object + * The object has the various cookies as keys(names) => values + * + * @param {string} str + * @param {object} [options] + * @return {object} + * @public + */ + +function parse (str, options) { + if (typeof str !== 'string') { + throw new TypeError('argument str must be a string') + } + + const result = {} + const dec = (options && options.decode) || decode + + let pos = 0 + let terminatorPos = 0 + let eqIdx = 0 + + while (true) { + if (terminatorPos === str.length) { + break + } + terminatorPos = str.indexOf(';', pos) + terminatorPos = (terminatorPos === -1) ? str.length : terminatorPos + eqIdx = str.indexOf('=', pos) + + // skip things that don't look like key=value + if (eqIdx === -1 || eqIdx > terminatorPos) { + pos = terminatorPos + 1 + continue + } + + const key = str.substring(pos, eqIdx++).trim() + + // only assign once + if (undefined === result[key]) { + const val = (str.charCodeAt(eqIdx) === 0x22) + ? str.substring(eqIdx + 1, terminatorPos - 1).trim() + : str.substring(eqIdx, terminatorPos).trim() + + result[key] = (dec !== decode || val.indexOf('%') !== -1) + ? tryDecode(val, dec) + : val + } + pos = terminatorPos + 1 + } + return result +} + +/** + * Serialize data into a cookie header. + * + * Serialize the a name value pair into a cookie string suitable for + * http headers. An optional options object specified cookie parameters. + * + * serialize('foo', 'bar', { httpOnly: true }) + * => "foo=bar; httpOnly" + * + * @param {string} name + * @param {string} val + * @param {object} [options] + * @return {string} + * @public + */ + +function serialize (name, val, options) { + const opt = options || {} + const enc = opt.encode || encode + if (typeof enc !== 'function') { + throw new TypeError('option encode is invalid') + } + + if (!fieldContentRegExp.test(name)) { + throw new TypeError('argument name is invalid') + } + + const value = enc(val) + if (value && !fieldContentRegExp.test(value)) { + throw new TypeError('argument val is invalid') + } + + let str = name + '=' + value + if (opt.maxAge != null) { + const maxAge = opt.maxAge - 0 + if (isNaN(maxAge) || !isFinite(maxAge)) { + throw new TypeError('option maxAge is invalid') + } + + str += '; Max-Age=' + Math.floor(maxAge) + } + + if (opt.domain) { + if (!fieldContentRegExp.test(opt.domain)) { + throw new TypeError('option domain is invalid') + } + + str += '; Domain=' + opt.domain + } + + if (opt.path) { + if (!fieldContentRegExp.test(opt.path)) { + throw new TypeError('option path is invalid') + } + + str += '; Path=' + opt.path + } + + if (opt.expires) { + if (typeof opt.expires.toUTCString !== 'function') { + throw new TypeError('option expires is invalid') + } + + str += '; Expires=' + opt.expires.toUTCString() + } + + if (opt.httpOnly) { + str += '; HttpOnly' + } + + if (opt.secure) { + str += '; Secure' + } + + if (opt.sameSite) { + const sameSite = typeof opt.sameSite === 'string' + ? opt.sameSite.toLowerCase() + : opt.sameSite + switch (sameSite) { + case true: + str += '; SameSite=Strict' + break + case 'lax': + str += '; SameSite=Lax' + break + case 'strict': + str += '; SameSite=Strict' + break + case 'none': + str += '; SameSite=None' + break + default: + throw new TypeError('option sameSite is invalid') + } + } + + return str +} + +/** + * Try decoding a string using a decoding function. + * + * @param {string} str + * @param {function} decode + * @private + */ + +function tryDecode (str, decode) { + try { + return decode(str) + } catch (e) { + return str + } +} diff --git a/package.json b/package.json index 875f9ac..8d16a4c 100644 --- a/package.json +++ b/package.json @@ -51,8 +51,8 @@ "tsd": "^0.29.0" }, "dependencies": { - "cookie": "^0.5.0", - "fastify-plugin": "^4.0.0" + "fastify-plugin": "^4.0.0", + "cookie-signature": "^1.1.0" }, "tsd": { "directory": "test" diff --git a/plugin.js b/plugin.js index 52d56f6..81e7432 100644 --- a/plugin.js +++ b/plugin.js @@ -1,7 +1,7 @@ 'use strict' const fp = require('fastify-plugin') -const cookie = require('cookie') +const cookie = require('./cookie') const { Signer, sign, unsign } = require('./signer') diff --git a/test/cookie-module.test.js b/test/cookie-module.test.js new file mode 100644 index 0000000..e8e10f7 --- /dev/null +++ b/test/cookie-module.test.js @@ -0,0 +1,205 @@ +'use strict' + +const tap = require('tap') +const test = tap.test + +const cookie = require('../cookie') + +test('parse: argument validation', (t) => { + t.plan(2) + t.throws(cookie.parse.bind(), /argument str must be a string/) + t.throws(cookie.parse.bind(null, 42), /argument str must be a string/) + t.end() +}) + +test('parse: basic', (t) => { + t.plan(2) + t.same(cookie.parse('foo=bar'), { foo: 'bar' }) + t.same(cookie.parse('foo=123'), { foo: '123' }) + t.end() +}) + +test('parse: ignore spaces', (t) => { + t.plan(1) + t.same(cookie.parse('FOO = bar; baz = raz'), { FOO: 'bar', baz: 'raz' }) + t.end() +}) + +test('parse: escaping', (t) => { + t.plan(2) + t.same(cookie.parse('foo="bar=123456789&name=Magic+Mouse"'), { foo: 'bar=123456789&name=Magic+Mouse' }) + t.same(cookie.parse('email=%20%22%2c%3b%2f'), { email: ' ",;/' }) + t.end() +}) + +test('parse: ignore escaping error and return original value', (t) => { + t.plan(1) + t.same(cookie.parse('foo=%1;bar=bar'), { foo: '%1', bar: 'bar' }) + t.end() +}) + +test('parse: ignore non values', (t) => { + t.plan(1) + t.same(cookie.parse('foo=%1;bar=bar;HttpOnly;Secure'), + { foo: '%1', bar: 'bar' }) + t.end() +}) + +test('parse: unencoded', (t) => { + t.plan(2) + t.same(cookie.parse('foo="bar=123456789&name=Magic+Mouse"', { + decode: function (v) { return v } + }), { foo: 'bar=123456789&name=Magic+Mouse' }) + + t.same(cookie.parse('email=%20%22%2c%3b%2f', { + decode: function (v) { return v } + }), { email: '%20%22%2c%3b%2f' }) + t.end() +}) + +test('parse: dates', (t) => { + t.plan(1) + t.same(cookie.parse('priority=true; expires=Wed, 29 Jan 2014 17:43:25 GMT; Path=/', { + decode: function (v) { return v } + }), { priority: 'true', Path: '/', expires: 'Wed, 29 Jan 2014 17:43:25 GMT' }) + t.end() +}) + +test('parse: missing value', (t) => { + t.plan(1) + t.same(cookie.parse('foo; bar=1; fizz= ; buzz=2', { + decode: function (v) { return v } + }), { bar: '1', fizz: '', buzz: '2' }) + t.end() +}) + +test('parse: assign only once', (t) => { + t.plan(3) + t.same(cookie.parse('foo=%1;bar=bar;foo=boo'), { foo: '%1', bar: 'bar' }) + t.same(cookie.parse('foo=false;bar=bar;foo=true'), { foo: 'false', bar: 'bar' }) + t.same(cookie.parse('foo=;bar=bar;foo=boo'), { foo: '', bar: 'bar' }) + t.end() +}) + +test('serializer: basic', (t) => { + t.plan(6) + t.same(cookie.serialize('foo', 'bar'), 'foo=bar') + t.same(cookie.serialize('foo', 'bar baz'), 'foo=bar%20baz') + t.same(cookie.serialize('foo', ''), 'foo=') + t.throws(cookie.serialize.bind(cookie, 'foo\n', 'bar'), /argument name is invalid/) + t.throws(cookie.serialize.bind(cookie, 'foo\u280a', 'bar'), /argument name is invalid/) + t.throws(cookie.serialize.bind(cookie, 'foo', 'bar', { encode: 42 }), /option encode is invalid/) + t.end() +}) + +test('serializer: path', (t) => { + t.plan(2) + t.same(cookie.serialize('foo', 'bar', { path: '/' }), 'foo=bar; Path=/') + t.throws(cookie.serialize.bind(cookie, 'foo', 'bar', { + path: '/\n' + }), /option path is invalid/) + t.end() +}) + +test('serializer: secure', (t) => { + t.plan(2) + t.same(cookie.serialize('foo', 'bar', { secure: true }), 'foo=bar; Secure') + t.same(cookie.serialize('foo', 'bar', { secure: false }), 'foo=bar') + t.end() +}) + +test('serializer: domain', (t) => { + t.plan(2) + t.same(cookie.serialize('foo', 'bar', { domain: 'example.com' }), 'foo=bar; Domain=example.com') + t.throws(cookie.serialize.bind(cookie, 'foo', 'bar', { + domain: 'example.com\n' + }), /option domain is invalid/) + t.end() +}) + +test('serializer: httpOnly', (t) => { + t.plan(1) + t.same(cookie.serialize('foo', 'bar', { httpOnly: true }), 'foo=bar; HttpOnly') + t.end() +}) + +test('serializer: maxAge', (t) => { + t.plan(9) + t.throws(function () { + cookie.serialize('foo', 'bar', { + maxAge: 'buzz' + }) + }, /option maxAge is invalid/) + + t.throws(function () { + cookie.serialize('foo', 'bar', { + maxAge: Infinity + }) + }, /option maxAge is invalid/) + + t.same(cookie.serialize('foo', 'bar', { maxAge: 1000 }), 'foo=bar; Max-Age=1000') + t.same(cookie.serialize('foo', 'bar', { maxAge: '1000' }), 'foo=bar; Max-Age=1000') + t.same(cookie.serialize('foo', 'bar', { maxAge: 0 }), 'foo=bar; Max-Age=0') + t.same(cookie.serialize('foo', 'bar', { maxAge: '0' }), 'foo=bar; Max-Age=0') + t.same(cookie.serialize('foo', 'bar', { maxAge: null }), 'foo=bar') + t.same(cookie.serialize('foo', 'bar', { maxAge: undefined }), 'foo=bar') + t.same(cookie.serialize('foo', 'bar', { maxAge: 3.14 }), 'foo=bar; Max-Age=3') + t.end() +}) + +test('serializer: expires', (t) => { + t.plan(2) + t.same(cookie.serialize('foo', 'bar', { + expires: new Date(Date.UTC(2000, 11, 24, 10, 30, 59, 900)) + }), 'foo=bar; Expires=Sun, 24 Dec 2000 10:30:59 GMT') + + t.throws(cookie.serialize.bind(cookie, 'foo', 'bar', { + expires: Date.now() + }), /option expires is invalid/) + t.end() +}) + +test('sameSite', (t) => { + t.plan(9) + t.same(cookie.serialize('foo', 'bar', { sameSite: true }), 'foo=bar; SameSite=Strict') + t.same(cookie.serialize('foo', 'bar', { sameSite: 'Strict' }), 'foo=bar; SameSite=Strict') + t.same(cookie.serialize('foo', 'bar', { sameSite: 'strict' }), 'foo=bar; SameSite=Strict') + t.same(cookie.serialize('foo', 'bar', { sameSite: 'Lax' }), 'foo=bar; SameSite=Lax') + t.same(cookie.serialize('foo', 'bar', { sameSite: 'lax' }), 'foo=bar; SameSite=Lax') + t.same(cookie.serialize('foo', 'bar', { sameSite: 'None' }), 'foo=bar; SameSite=None') + t.same(cookie.serialize('foo', 'bar', { sameSite: 'none' }), 'foo=bar; SameSite=None') + t.same(cookie.serialize('foo', 'bar', { sameSite: false }), 'foo=bar') + + t.throws(cookie.serialize.bind(cookie, 'foo', 'bar', { + sameSite: 'foo' + }), /option sameSite is invalid/) + t.end() +}) + +test('escaping', (t) => { + t.plan(1) + t.same(cookie.serialize('cat', '+ '), 'cat=%2B%20') + t.end() +}) + +test('parse->serialize', (t) => { + t.plan(2) + t.same(cookie.parse(cookie.serialize('cat', 'foo=123&name=baz five')), + { cat: 'foo=123&name=baz five' }) + + t.same(cookie.parse(cookie.serialize('cat', ' ";/')), + { cat: ' ";/' }) + t.end() +}) + +test('unencoded', (t) => { + t.plan(2) + t.same(cookie.serialize('cat', '+ ', { + encode: function (value) { return value } + }), 'cat=+ ') + + t.throws(cookie.serialize.bind(cookie, 'cat', '+ \n', { + encode: function (value) { return value } + }), /argument val is invalid/) + t.end() +})