add more validation to addCookie parameters
authorJustin Wind <justin.wind+git@gmail.com>
Fri, 24 May 2024 23:10:53 +0000 (16:10 -0700)
committerJustin Wind <justin.wind+git@gmail.com>
Fri, 7 Jun 2024 22:22:43 +0000 (15:22 -0700)
CHANGELOG.md
lib/common.js
test/lib/common.js

index 0f20c7aa312046996b0181a7374c583155c4c280..6071fbdc6da139091f6757740ebabd8b8f2030d6 100644 (file)
@@ -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
index c6c21efc705cf7581dfd351ffe8ba3a8275cbdb1..2f0aeb835694b5eb0f5e6130b8974ff645c585b6 100644 (file)
@@ -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('; '));
 }
 
index 68f2599791c12faf5d106656200fd23cb7c55947..06d711d186fdc07469e89311a0ca2207d769540c 100644 (file)
@@ -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);