diff --git a/lib/web/cookies/index.js b/lib/web/cookies/index.js index be99f6f3dd2..8ebff2b1a4c 100644 --- a/lib/web/cookies/index.js +++ b/lib/web/cookies/index.js @@ -32,8 +32,10 @@ function getCookies (headers) { const cookie = headers.get('cookie') + // A null prototype keeps a cookie named `__proto__` from hitting the + // Object.prototype setter, which would silently drop it. /** @type {Record} */ - const out = {} + const out = { __proto__: null } if (!cookie) { return out diff --git a/lib/web/cookies/util.js b/lib/web/cookies/util.js index ca408e153ab..d4b0bb76cfe 100644 --- a/lib/web/cookies/util.js +++ b/lib/web/cookies/util.js @@ -315,8 +315,27 @@ function stringify (cookie) { out.push(`Path=${cookie.path}`) } - if (cookie.expires && cookie.expires.toString() !== 'Invalid Date') { - out.push(`Expires=${toIMFDate(cookie.expires)}`) + // A numeric 0 is the Unix epoch, not an absent value -- the same reason the + // Max-Age check above tests the type rather than truthiness. + // + // expires is Date | number, so the "Invalid Date" check only guards the Date + // form. A number that is not a valid time value (NaN, +/-Infinity, or outside + // the Date range) would otherwise reach toIMFDate, which splices undefined and + // NaN into the header value instead of failing. + if (cookie.expires != null) { + const expiresMs = cookie.expires instanceof Date + ? cookie.expires.getTime() + : cookie.expires + + // The largest time value a Date can represent, in milliseconds, i.e. + // 100,000,000 days either side of the epoch (ECMA-262 Date Time Values). + // Anything within it is safe for toIMFDate to format; anything beyond it + // produces an invalid Date and must be ignored instead. + const maxTimeValue = 8640000000000000 + + if (typeof expiresMs === 'number' && Number.isFinite(expiresMs) && Math.abs(expiresMs) <= maxTimeValue) { + out.push(`Expires=${toIMFDate(cookie.expires)}`) + } } if (cookie.sameSite) { diff --git a/test/cookie/cookies.js b/test/cookie/cookies.js index 32752a5f7db..429a04f0c35 100644 --- a/test/cookie/cookies.js +++ b/test/cookie/cookies.js @@ -59,6 +59,24 @@ test('Cookie parser', () => { }) }) +test('Cookie parser keeps a cookie named __proto__', () => { + const headers = new Headers() + headers.set('Cookie', '__proto__=foo; bar=baz') + + const cookies = getCookies(headers) + + assert.deepStrictEqual(Object.keys(cookies).sort(), ['__proto__', 'bar']) + assert.strictEqual( + Object.getOwnPropertyDescriptor(cookies, '__proto__').value, + 'foo' + ) + assert.strictEqual(cookies.bar, 'baz') + + for (const value of Object.values(cookies)) { + assert.strictEqual(typeof value, 'string') + } +}) + test('Cookie Name Validation', () => { const tokens = [ '"id"', @@ -379,6 +397,68 @@ test('Cookie Set', () => { 'Space=Cat; Expires=Fri, 07 Jan 1983 15:32:00 GMT' ) + // A numeric 0 is the Unix epoch, the canonical "expire now" value, not an + // absent option. + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + expires: 0 + }) + assert.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Expires=Thu, 01 Jan 1970 00:00:00 GMT' + ) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + expires: null + }) + assert.equal(headers.get('Set-Cookie'), 'Space=Cat') + + // A numeric expires outside the range representable by Date must not be + // serialized. Before this was guarded, the numeric form skipped the + // "Invalid Date" check and toIMFDate spliced undefined/NaN into the header. + for (const expires of [8640000000000001, 1e16, Number.MAX_SAFE_INTEGER]) { + headers = new Headers() + setCookie(headers, { name: 'Space', value: 'Cat', expires }) + assert.equal(headers.get('Set-Cookie'), 'Space=Cat') + } + + // The maximum representable time value is still serialized. + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + expires: 8640000000000000 + }) + assert.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Expires=Sat, 13 Sep 275760 00:00:00 GMT' + ) + + // A non-finite number is coerced to 0 by the WebIDL converter before it + // reaches serialization, so it still serializes as the epoch. + for (const expires of [Infinity, -Infinity, NaN]) { + headers = new Headers() + setCookie(headers, { name: 'Space', value: 'Cat', expires }) + assert.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Expires=Thu, 01 Jan 1970 00:00:00 GMT' + ) + } + + // An invalid Date is ignored, consistent with the out-of-range numeric cases. + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + expires: new Date(NaN) + }) + assert.equal(headers.get('Set-Cookie'), 'Space=Cat') + headers = new Headers() setCookie(headers, { name: '__Secure-Kitty', value: 'Meow' }) assert.equal(headers.get('Set-Cookie'), '__Secure-Kitty=Meow; Secure')