From 776161ca80ca5a604199e43dfcf0ce06ef4fc1eb Mon Sep 17 00:00:00 2001 From: Justin Wind Date: Fri, 24 May 2024 16:10:53 -0700 Subject: [PATCH] add more validation to addCookie parameters --- CHANGELOG.md | 2 ++ lib/common.js | 53 +++++++++++++++++++++++++++++++++++++++++++++- test/lib/common.js | 35 +++++++++++++++++++++++++++++- 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f20c7a..6071fbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Releases and notable changes to this project are documented here. ## [Unreleased] +- addCookie helper does more validation + ## [v2.1.1] - 2024-05-04 - updated dependencies and devDependencies diff --git a/lib/common.js b/lib/common.js index c6c21ef..2f0aeb8 100644 --- a/lib/common.js +++ b/lib/common.js @@ -205,6 +205,12 @@ const unfoldHeaderLines = (lines) => { return lines; }; +const validTokenRE = /^[!#$%&'*+-.0-9A-Z^_`a-z~]+$/; +const validValueRE = /^[!#$%&'()*+-./0-9:<=>?@A-Z[\]^_`a-z{|}~]*$/; +const validPathRE = /^[ !"#$%&'()*+,-./0-9:<=>?@A-Z[\\\]^_`a-z{|}~]*$/; +const validLabelRE = /^[a-zA-Z0-9-]+$/; +const invalidLabelRE = /--|^-|-$/; + /** * Adds a new set-cookie header value to response, with supplied data. * @param {http.ServerResponse} res response @@ -219,6 +225,7 @@ const unfoldHeaderLines = (lines) => { * @param {string=} opt.path cookie path * @param {string=} opt.sameSite cookie sharing * @param {boolean=} opt.secure cookie security + * @param {string[]=} opt.extension cookie extension attribute values */ function addCookie(res, name, value, opt = {}) { const options = { @@ -229,39 +236,83 @@ function addCookie(res, name, value, opt = {}) { path: undefined, sameSite: undefined, secure: false, + extension: [], ...opt, }; - // TODO: validate name, value + + if (!validTokenRE.test(name)) { + throw new RangeError('invalid cookie name'); + } + + if (value.startsWith('"') && value.endsWith('"')) { + if (!validValueRE.test(value.slice(1, value.length - 1))) { + throw new RangeError('invalid cookie value'); + }; + } else if (!validValueRE.test(value)) { + throw new RangeError('invalid cookie value'); + } + const cookieParts = [ `${name}=${value}`, ]; + if (options.domain) { + for (const label of options.domain.split('.')) { + if (!validLabelRE.test(label) || invalidLabelRE.test(label)) { + throw new RangeError('invalid cookie domain'); + } + + } cookieParts.push(`Domain=${options.domain}`); } + if (options.expires) { if (!(options.expires instanceof Date)) { throw new TypeError('cookie expires must be Date'); } cookieParts.push(`Expires=${options.expires.toUTCString()}`); } + if (options.httpOnly) { cookieParts.push('HttpOnly'); } + if (options.maxAge) { cookieParts.push(`Max-Age=${options.maxAge}`); } + if (options.path) { + if (!validPathRE.test(options.path)) { + throw new RangeError('cookie path value not valid'); + } cookieParts.push(`Path=${options.path}`); } + if (options.sameSite) { if (!(['Strict', 'Lax', 'None'].includes(options.sameSite))) { throw new RangeError('cookie sameSite value not valid'); } + if (options.sameSite === 'None' + && !options.secure) { + throw new RangeError('cookie with sameSite None must also be secure'); + } cookieParts.push(`SameSite=${options.sameSite}`); } + if (options.secure) { cookieParts.push('Secure'); } + + if (!Array.isArray(options.extension)) { + throw new TypeError('cookie extension must be Array'); + } + for (const extension of options.extension) { + if (!validPathRE.test(extension)) { + throw new RangeError('cookie extension value not valid'); + } + cookieParts.push(extension); + } + res.appendHeader(Enum.Header.SetCookie, cookieParts.join('; ')); } diff --git a/test/lib/common.js b/test/lib/common.js index 68f2599..06d711d 100644 --- a/test/lib/common.js +++ b/test/lib/common.js @@ -371,6 +371,7 @@ describe('common', function () { path: '/foo', sameSite: 'Lax', secure: true, + extension: ['Mischief'], }); assert(res.appendHeader.called); }); @@ -378,7 +379,39 @@ describe('common', function () { assert.throws(() => common.addCookie(res, name, value, { expires: 'never' }), TypeError); }); it('covers invalid sameSite', function () { - assert.throws(() => common.addCookie(res, name, value, { sameSite: 'Whatever' })); + assert.throws(() => common.addCookie(res, name, value, { sameSite: 'Whatever' }), RangeError); + }); + it('covers invalid sameSite/secure setting', function () { + assert.throws(() => common.addCookie(res, name, value, { sameSite: 'None', secure: false }), RangeError); + }); + it('covers invalid path', function () { + assert.throws(() => common.addCookie(res, name, value, { path: '/bad;path' }), RangeError); + }); + it('covers invalid domain', function () { + assert.throws(() => common.addCookie(res, name, value, { domain: 'a-.com' }), RangeError); + }); + it('covers invalid extension type', function () { + assert.throws(() => common.addCookie(res, name, value, { extension: 'extension' }), TypeError); + }); + it('covers invalid extension', function () { + assert.throws(() => common.addCookie(res, name, value, { extension: ['bad;extension'] }), RangeError); + }); + it('covers invalid name', function () { + name = 'bad:name'; + assert.throws(() => common.addCookie(res, name, value), RangeError); + }); + it('covers invalid value', function () { + value = 'bad;value'; + assert.throws(() => common.addCookie(res, name, value), RangeError); + }); + it('covers quoted value', function () { + value = '"value"'; + common.addCookie(res, name, value); + assert(res.appendHeader.called); + }); + it('covers invalid quoted value', function () { + value = '"bad;value"'; + assert.throws(() => common.addCookie(res, name, value), RangeError); }); it('covers no options', function () { common.addCookie(res, name, value); -- 2.45.2