diff --git a/api/docs/tough-cookie.parsedate.md b/api/docs/tough-cookie.parsedate.md index 51e35563..bf7fa954 100644 --- a/api/docs/tough-cookie.parsedate.md +++ b/api/docs/tough-cookie.parsedate.md @@ -52,67 +52,13 @@ the cookie date string Date \| undefined -## Remarks - -\#\#\# RFC6265 - 5.1.1. Dates - -The user agent MUST use an algorithm equivalent to the following algorithm to parse a cookie-date. Note that the various boolean flags defined as a part of the algorithm (i.e., found-time, found- day-of-month, found-month, found-year) are initially "not set". - -1. Using the grammar below, divide the cookie-date into date-tokens. - -``` - cookie-date = *delimiter date-token-list *delimiter - date-token-list = date-token *( 1*delimiter date-token ) - date-token = 1*non-delimiter - - delimiter = %x09 / %x20-2F / %x3B-40 / %x5B-60 / %x7B-7E - non-delimiter = %x00-08 / %x0A-1F / DIGIT / ":" / ALPHA / %x7F-FF - non-digit = %x00-2F / %x3A-FF - - day-of-month = 1*2DIGIT ( non-digit *OCTET ) - month = ( "jan" / "feb" / "mar" / "apr" / - "may" / "jun" / "jul" / "aug" / - "sep" / "oct" / "nov" / "dec" ) *OCTET - year = 2*4DIGIT ( non-digit *OCTET ) - time = hms-time ( non-digit *OCTET ) - hms-time = time-field ":" time-field ":" time-field - time-field = 1*2DIGIT -``` -2. Process each date-token sequentially in the order the date-tokens appear in the cookie-date: - -1. If the found-time flag is not set and the token matches the time production, set the found-time flag and set the hour- value, minute-value, and second-value to the numbers denoted by the digits in the date-token, respectively. Skip the remaining sub-steps and continue to the next date-token. - -2. If the found-day-of-month flag is not set and the date-token matches the day-of-month production, set the found-day-of- month flag and set the day-of-month-value to the number denoted by the date-token. Skip the remaining sub-steps and continue to the next date-token. - -3. If the found-month flag is not set and the date-token matches the month production, set the found-month flag and set the month-value to the month denoted by the date-token. Skip the remaining sub-steps and continue to the next date-token. - -4. If the found-year flag is not set and the date-token matches the year production, set the found-year flag and set the year-value to the number denoted by the date-token. Skip the remaining sub-steps and continue to the next date-token. +Date if valid, undefined if invalid -3. If the year-value is greater than or equal to 70 and less than or equal to 99, increment the year-value by 1900. - -4. If the year-value is greater than or equal to 0 and less than or equal to 69, increment the year-value by 2000. - -1. NOTE: Some existing user agents interpret two-digit years differently. - -5. Abort these steps and fail to parse the cookie-date if: - -- at least one of the found-day-of-month, found-month, found- year, or found-time flags is not set, - -- the day-of-month-value is less than 1 or greater than 31, - -- the year-value is less than 1601, - -- the hour-value is greater than 23, - -- the minute-value is greater than 59, or - -- the second-value is greater than 59. - -(Note that leap seconds cannot be represented in this syntax.) +## Remarks -6. Let the parsed-cookie-date be the date whose day-of-month, month, year, hour, minute, and second (in UTC) are the day-of-month- value, the month-value, the year-value, the hour-value, the minute-value, and the second-value, respectively. If no such date exists, abort these steps and fail to parse the cookie-date. +This implementation is compliant with RFC6265 Section 5.1.1 and incorporates [RFC6265 Erratum 4148 - Grammar Fixed](https://www.rfc-editor.org/errata/eid4148) which corrects the ABNF grammar for day-of-month, year, and time to make trailing non-digit characters optional (changing `( )` to `[ ]`). -7. Return the parsed-cookie-date as the result of this algorithm. +Also compatible with [draft-ietf-httpbis-rfc6265bis-21](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-21) which maintains the same date parsing algorithm with additional clarifications. ## Example diff --git a/eslint.config.mjs b/eslint.config.mjs index 8825d7cc..8cf3b211 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -30,6 +30,7 @@ export default config( rules: { '@typescript-eslint/explicit-function-return-type': 'error', 'import/no-nodejs-modules': 'error', + 'no-control-regex': 'off', }, }, { diff --git a/lib/__tests__/date.spec.ts b/lib/__tests__/date.spec.ts index bef002ad..80562490 100644 --- a/lib/__tests__/date.spec.ts +++ b/lib/__tests__/date.spec.ts @@ -87,6 +87,45 @@ const dateTests: DateParsingTestCase = { 'Wed, 09 Jun 2021 11:-3:44 GMT': false, 'Wed, 09 Jun 2021 11:33:-4 GMT': false, + // boundary year values + '01 Jan 9999 00:00:00 GMT': true, // 4-digit year maximum valid + '01 Jan 10000 00:00:00 GMT': false, // 5 digits - exceeds 2*4DIGIT + + // day-of-month boundaries + '00 Jan 2021 00:00:00 GMT': false, // day < 1 + '32 Jan 2021 00:00:00 GMT': false, // day > 31 + + // time component boundaries + '01 Jan 2021 24:00:00 GMT': false, // hour > 23 + '01 Jan 2021 23:60:00 GMT': false, // minute > 59 + '01 Jan 2021 23:59:60 GMT': false, // second > 59 (leap second) + + // month edge cases + '01 J 2021 00:00:00 GMT': false, // single character month + '01 1 2021 00:00:00 GMT': false, // numeric month + '01 Jax 2021 00:00:00 GMT': false, // invalid month abbreviation + + // invalid date combinations - RFC6265 S5.1.1 Step 6: "If no such date exists, abort" + '30 Feb 2021 00:00:00 GMT': false, // Feb only has 28 days in 2021 + '31 Feb 2021 00:00:00 GMT': false, // Feb only has 28 days in 2021 + '30 Feb 2020 00:00:00 GMT': false, // Feb only has 29 days in 2020 (leap year) + '31 Apr 2021 00:00:00 GMT': false, // Apr only has 30 days + '31 Jun 2021 00:00:00 GMT': false, // Jun only has 30 days + '31 Sep 2021 00:00:00 GMT': false, // Sep only has 30 days + '31 Nov 2021 00:00:00 GMT': false, // Nov only has 30 days + + // duplicate tokens (first match wins per RFC6265) + '01 Jan 2021 10:00:00 GMT 20:00:00': true, // duplicate time - first wins + 'Jan Feb 01 2021 10:00:00 GMT': true, // duplicate month - first wins + '01 Jan 2021 2022 10:00:00 GMT': true, // duplicate year - first wins + + // consecutive delimiters (empty tokens) + '01 Jan 2021 10:00:00 GMT': true, // multiple spaces + '01,,Jan,,2021,,10:00:00,,GMT': true, // multiple delimiters + + // single-digit year (less than 2 digits - should fail) + '01 Jan 9 10:00:00 GMT': false, // 1 digit year < 2*4DIGIT minimum + '': false, } @@ -133,6 +172,15 @@ const equivalenceTests: EquivalenceDateParsingTestCase = { // test the framework :wink: 'Wed, 09 Jun 2021 10:18:14 GMT': 'Wed, 09 Jun 2021 10:18:14 GMT', + + // duplicate tokens - first occurrence wins + '01 Jan 2021 10:00:00 GMT 20:00:00': '01 Jan 2021 10:00:00 GMT', + 'Jan Feb 01 2021 10:00:00 GMT': '01 Jan 2021 10:00:00 GMT', + '01 Jan 2021 2022 10:00:00 GMT': '01 Jan 2021 10:00:00 GMT', + + // consecutive delimiters + '01 Jan 2021 10:00:00 GMT': '01 Jan 2021 10:00:00 GMT', + '01,,Jan,,2021,,10:00:00,,GMT': '01 Jan 2021 10:00:00 GMT', } describe('Dates', () => { @@ -185,4 +233,208 @@ describe('Dates', () => { ).toStrictEqual(dateWithMillisIgnored) }) }) + + describe('RFC6265 edge cases and boundary values', () => { + describe('year boundaries', () => { + it('should accept year 1601 (minimum valid year)', () => { + const date = parseDate('01 Jan 1601 00:00:00 GMT') + expect(date).toBeInstanceOf(Date) + expect(date?.getUTCFullYear()).toBe(1601) + }) + + it('should reject year 1600 (below minimum)', () => { + expect(parseDate('01 Jan 1600 00:00:00 GMT')).toBeUndefined() + }) + + it('should accept year 9999 (4-digit maximum)', () => { + const date = parseDate('01 Jan 9999 00:00:00 GMT') + expect(date).toBeInstanceOf(Date) + expect(date?.getUTCFullYear()).toBe(9999) + }) + + it('should reject year 10000 (5 digits exceeds 2*4DIGIT)', () => { + expect(parseDate('01 Jan 10000 00:00:00 GMT')).toBeUndefined() + }) + + it('should reject single-digit year (below 2*4DIGIT minimum)', () => { + expect(parseDate('01 Jan 9 00:00:00 GMT')).toBeUndefined() + }) + + it('should transform two-digit year 70 to 1970', () => { + const date = parseDate('01 Jan 70 00:00:00 GMT') + expect(date?.getUTCFullYear()).toBe(1970) + }) + + it('should transform two-digit year 69 to 2069', () => { + const date = parseDate('01 Jan 69 00:00:00 GMT') + expect(date?.getUTCFullYear()).toBe(2069) + }) + + it('should transform two-digit year 00 to 2000', () => { + const date = parseDate('01 Jan 00 00:00:00 GMT') + expect(date?.getUTCFullYear()).toBe(2000) + }) + }) + + describe('day-of-month boundaries', () => { + it('should reject day 0 (below minimum)', () => { + expect(parseDate('00 Jan 2021 00:00:00 GMT')).toBeUndefined() + }) + + it('should reject day 32 (above maximum)', () => { + expect(parseDate('32 Jan 2021 00:00:00 GMT')).toBeUndefined() + }) + + it('should accept day 1 (minimum valid)', () => { + const date = parseDate('01 Jan 2021 00:00:00 GMT') + expect(date?.getUTCDate()).toBe(1) + }) + + it('should accept day 31 (maximum valid)', () => { + const date = parseDate('31 Jan 2021 00:00:00 GMT') + expect(date?.getUTCDate()).toBe(31) + }) + }) + + describe('time component boundaries', () => { + it('should reject hour 24 (above maximum)', () => { + expect(parseDate('01 Jan 2021 24:00:00 GMT')).toBeUndefined() + }) + + it('should accept hour 23 (maximum valid)', () => { + const date = parseDate('01 Jan 2021 23:00:00 GMT') + expect(date?.getUTCHours()).toBe(23) + }) + + it('should reject minute 60 (above maximum)', () => { + expect(parseDate('01 Jan 2021 23:60:00 GMT')).toBeUndefined() + }) + + it('should accept minute 59 (maximum valid)', () => { + const date = parseDate('01 Jan 2021 23:59:00 GMT') + expect(date?.getUTCMinutes()).toBe(59) + }) + + it('should reject second 60 (leap second not supported)', () => { + expect(parseDate('01 Jan 2021 23:59:60 GMT')).toBeUndefined() + }) + + it('should accept second 59 (maximum valid)', () => { + const date = parseDate('01 Jan 2021 23:59:59 GMT') + expect(date?.getUTCSeconds()).toBe(59) + }) + }) + + describe('month edge cases', () => { + it('should reject single character month', () => { + expect(parseDate('01 J 2021 00:00:00 GMT')).toBeUndefined() + }) + + it('should reject two character month', () => { + expect(parseDate('01 Ja 2021 00:00:00 GMT')).toBeUndefined() + }) + + it('should reject numeric month', () => { + expect(parseDate('01 1 2021 00:00:00 GMT')).toBeUndefined() + }) + + it('should reject invalid month abbreviation', () => { + expect(parseDate('01 Jax 2021 00:00:00 GMT')).toBeUndefined() + }) + + it('should accept month with trailing characters', () => { + const date = parseDate('01 January 2021 00:00:00 GMT') + expect(date).toBeInstanceOf(Date) + expect(date?.getUTCMonth()).toBe(0) // January + }) + }) + + describe('invalid date combinations per RFC6265', () => { + it('should reject Feb 30 (non-leap year) - no such date exists', () => { + // [RFC6265 S5.1.1 Step 6](https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1): + // "If no such date exists, abort these steps and fail to parse the cookie-date" + expect(parseDate('30 Feb 2021 00:00:00 GMT')).toBeUndefined() + }) + + it('should reject Feb 31 (non-leap year) - no such date exists', () => { + expect(parseDate('31 Feb 2021 00:00:00 GMT')).toBeUndefined() + }) + + it('should reject Feb 30 (leap year) - Feb only has 29 days even in leap years', () => { + expect(parseDate('30 Feb 2020 00:00:00 GMT')).toBeUndefined() + }) + + it('should accept Feb 29 in leap year', () => { + const date = parseDate('29 Feb 2020 00:00:00 GMT') + expect(date).toBeInstanceOf(Date) + expect(date?.getUTCMonth()).toBe(1) // February + expect(date?.getUTCDate()).toBe(29) + }) + + it('should reject Feb 29 in non-leap year', () => { + expect(parseDate('29 Feb 2021 00:00:00 GMT')).toBeUndefined() + }) + + it('should reject Apr 31 - April only has 30 days', () => { + expect(parseDate('31 Apr 2021 00:00:00 GMT')).toBeUndefined() + }) + + it('should reject Jun 31 - June only has 30 days', () => { + expect(parseDate('31 Jun 2021 00:00:00 GMT')).toBeUndefined() + }) + + it('should reject Sep 31 - September only has 30 days', () => { + expect(parseDate('31 Sep 2021 00:00:00 GMT')).toBeUndefined() + }) + + it('should reject Nov 31 - November only has 30 days', () => { + expect(parseDate('31 Nov 2021 00:00:00 GMT')).toBeUndefined() + }) + + it('should accept valid date with 31 days', () => { + const date = parseDate('31 Jan 2021 00:00:00 GMT') + expect(date).toBeInstanceOf(Date) + expect(date?.getUTCDate()).toBe(31) + }) + }) + + describe('duplicate tokens', () => { + it('should use first time when duplicate times present', () => { + const date = parseDate('01 Jan 2021 10:00:00 GMT 20:00:00') + expect(date?.getUTCHours()).toBe(10) + }) + + it('should use first month when duplicate months present', () => { + const date = parseDate('Jan Feb 01 2021 10:00:00 GMT') + expect(date?.getUTCMonth()).toBe(0) // January + }) + + it('should use first year when duplicate years present', () => { + const date = parseDate('01 Jan 2021 2022 10:00:00 GMT') + expect(date?.getUTCFullYear()).toBe(2021) + }) + + it('should parse day then year correctly (not duplicate day)', () => { + // Note: "15" becomes year 2015, not a duplicate day + // This is correct per RFC6265 - once day is set, next 2-4 digit number is year + const date = parseDate('01 15 Jan 10:00:00 GMT') + expect(date?.getUTCDate()).toBe(1) + expect(date?.getUTCFullYear()).toBe(2015) + }) + }) + + describe('delimiter handling', () => { + it('should handle consecutive spaces', () => { + const date = parseDate('01 Jan 2021 10:00:00 GMT') + expect(date).toBeInstanceOf(Date) + expect(date?.getUTCFullYear()).toBe(2021) + }) + + it('should handle mixed delimiters', () => { + const date = parseDate('01,,Jan,,2021,,10:00:00,,GMT') + expect(date).toBeInstanceOf(Date) + expect(date?.getUTCFullYear()).toBe(2021) + }) + }) + }) }) diff --git a/lib/cookie/canonicalDomain.ts b/lib/cookie/canonicalDomain.ts index 90dd3070..29e4747f 100644 --- a/lib/cookie/canonicalDomain.ts +++ b/lib/cookie/canonicalDomain.ts @@ -60,7 +60,7 @@ export function canonicalDomain( } // convert to IDN if any non-ASCII characters - // eslint-disable-next-line no-control-regex + if (/[^\u0001-\u007f]/.test(str)) { return domainToASCII(str) } diff --git a/lib/cookie/cookie.ts b/lib/cookie/cookie.ts index a03760b0..7b01b3d2 100644 --- a/lib/cookie/cookie.ts +++ b/lib/cookie/cookie.ts @@ -45,7 +45,6 @@ const COOKIE_OCTETS = /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]+$/ // Note ';' is \x3B const PATH_VALUE = /[\x20-\x3A\x3C-\x7E]+/ -// eslint-disable-next-line no-control-regex const CONTROL_CHARS = /[\x00-\x1F]/ // From Chromium // '\r', '\n' and '\0' should be treated as a terminator in diff --git a/lib/cookie/parseDate.ts b/lib/cookie/parseDate.ts index 53a98802..06387624 100644 --- a/lib/cookie/parseDate.ts +++ b/lib/cookie/parseDate.ts @@ -1,130 +1,5 @@ -// date-time parsing constants (RFC6265 S5.1.1) - import type { Nullable } from '../utils.js' -// eslint-disable-next-line no-control-regex -const DATE_DELIM = /[\x09\x20-\x2F\x3B-\x40\x5B-\x60\x7B-\x7E]/ - -const MONTH_TO_NUM = { - jan: 0, - feb: 1, - mar: 2, - apr: 3, - may: 4, - jun: 5, - jul: 6, - aug: 7, - sep: 8, - oct: 9, - nov: 10, - dec: 11, -} - -/* - * Parses a Natural number (i.e., non-negative integer) with either the - * *DIGIT ( non-digit *OCTET ) - * or - * *DIGIT - * grammar (RFC6265 S5.1.1). - * - * The "trailingOK" boolean controls if the grammar accepts a - * "( non-digit *OCTET )" trailer. - */ -function parseDigits( - token: string, - minDigits: number, - maxDigits: number, - trailingOK: boolean, -): number | undefined { - let count = 0 - while (count < token.length) { - const c = token.charCodeAt(count) - // "non-digit = %x00-2F / %x3A-FF" - if (c <= 0x2f || c >= 0x3a) { - break - } - count++ - } - - // constrain to a minimum and maximum number of digits. - if (count < minDigits || count > maxDigits) { - return - } - - if (!trailingOK && count != token.length) { - return - } - - return parseInt(token.slice(0, count), 10) -} - -function parseTime(token: string): number[] | undefined { - const parts = token.split(':') - const result = [0, 0, 0] - - /* RF6256 S5.1.1: - * time = hms-time ( non-digit *OCTET ) - * hms-time = time-field ":" time-field ":" time-field - * time-field = 1*2DIGIT - */ - - if (parts.length !== 3) { - return - } - - for (let i = 0; i < 3; i++) { - // "time-field" must be strictly "1*2DIGIT", HOWEVER, "hms-time" can be - // followed by "( non-digit *OCTET )" therefore the last time-field can - // have a trailer - const trailingOK = i == 2 - const numPart = parts[i] - if (numPart === undefined) { - return - } - const num = parseDigits(numPart, 1, 2, trailingOK) - if (num === undefined) { - return - } - result[i] = num - } - - return result -} - -function parseMonth(token: string): number | undefined { - token = String(token as unknown) - .slice(0, 3) - .toLowerCase() - switch (token) { - case 'jan': - return MONTH_TO_NUM.jan - case 'feb': - return MONTH_TO_NUM.feb - case 'mar': - return MONTH_TO_NUM.mar - case 'apr': - return MONTH_TO_NUM.apr - case 'may': - return MONTH_TO_NUM.may - case 'jun': - return MONTH_TO_NUM.jun - case 'jul': - return MONTH_TO_NUM.jul - case 'aug': - return MONTH_TO_NUM.aug - case 'sep': - return MONTH_TO_NUM.sep - case 'oct': - return MONTH_TO_NUM.oct - case 'nov': - return MONTH_TO_NUM.nov - case 'dec': - return MONTH_TO_NUM.dec - default: - return - } -} - /** * Parse a cookie date string into a {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date | Date}. Parses according to * {@link https://www.rfc-editor.org/rfc/rfc6265.html#section-5.1.1 | RFC6265 - Section 5.1.1}, not @@ -132,91 +7,13 @@ function parseMonth(token: string): number | undefined { * * @remarks * - * ### RFC6265 - 5.1.1. Dates - * - * The user agent MUST use an algorithm equivalent to the following - * algorithm to parse a cookie-date. Note that the various boolean - * flags defined as a part of the algorithm (i.e., found-time, found- - * day-of-month, found-month, found-year) are initially "not set". - * - * 1. Using the grammar below, divide the cookie-date into date-tokens. - * - * ``` - * cookie-date = *delimiter date-token-list *delimiter - * date-token-list = date-token *( 1*delimiter date-token ) - * date-token = 1*non-delimiter - * - * delimiter = %x09 / %x20-2F / %x3B-40 / %x5B-60 / %x7B-7E - * non-delimiter = %x00-08 / %x0A-1F / DIGIT / ":" / ALPHA / %x7F-FF - * non-digit = %x00-2F / %x3A-FF - * - * day-of-month = 1*2DIGIT ( non-digit *OCTET ) - * month = ( "jan" / "feb" / "mar" / "apr" / - * "may" / "jun" / "jul" / "aug" / - * "sep" / "oct" / "nov" / "dec" ) *OCTET - * year = 2*4DIGIT ( non-digit *OCTET ) - * time = hms-time ( non-digit *OCTET ) - * hms-time = time-field ":" time-field ":" time-field - * time-field = 1*2DIGIT - * ``` - * - * 2. Process each date-token sequentially in the order the date-tokens - * appear in the cookie-date: - * - * 1. If the found-time flag is not set and the token matches the - * time production, set the found-time flag and set the hour- - * value, minute-value, and second-value to the numbers denoted - * by the digits in the date-token, respectively. Skip the - * remaining sub-steps and continue to the next date-token. - * - * 2. If the found-day-of-month flag is not set and the date-token - * matches the day-of-month production, set the found-day-of- - * month flag and set the day-of-month-value to the number - * denoted by the date-token. Skip the remaining sub-steps and - * continue to the next date-token. - * - * 3. If the found-month flag is not set and the date-token matches - * the month production, set the found-month flag and set the - * month-value to the month denoted by the date-token. Skip the - * remaining sub-steps and continue to the next date-token. - * - * 4. If the found-year flag is not set and the date-token matches - * the year production, set the found-year flag and set the - * year-value to the number denoted by the date-token. Skip the - * remaining sub-steps and continue to the next date-token. - * - * 3. If the year-value is greater than or equal to 70 and less than or - * equal to 99, increment the year-value by 1900. - * - * 4. If the year-value is greater than or equal to 0 and less than or - * equal to 69, increment the year-value by 2000. - * - * 1. NOTE: Some existing user agents interpret two-digit years differently. - * - * 5. Abort these steps and fail to parse the cookie-date if: - * - * - at least one of the found-day-of-month, found-month, found- - * year, or found-time flags is not set, - * - * - the day-of-month-value is less than 1 or greater than 31, + * This implementation is compliant with RFC6265 Section 5.1.1 and incorporates + * {@link https://www.rfc-editor.org/errata/eid4148 | RFC6265 Erratum 4148 - Grammar Fixed} + * which corrects the ABNF grammar for day-of-month, year, and time to make trailing + * non-digit characters optional (changing `( )` to `[ ]`). * - * - the year-value is less than 1601, - * - * - the hour-value is greater than 23, - * - * - the minute-value is greater than 59, or - * - * - the second-value is greater than 59. - * - * (Note that leap seconds cannot be represented in this syntax.) - * - * 6. Let the parsed-cookie-date be the date whose day-of-month, month, - * year, hour, minute, and second (in UTC) are the day-of-month- - * value, the month-value, the year-value, the hour-value, the - * minute-value, and the second-value, respectively. If no such - * date exists, abort these steps and fail to parse the cookie-date. - * - * 7. Return the parsed-cookie-date as the result of this algorithm. + * Also compatible with {@link https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-21 | draft-ietf-httpbis-rfc6265bis-21} + * which maintains the same date parsing algorithm with additional clarifications. * * @example * ``` @@ -224,129 +21,270 @@ function parseMonth(token: string): number | undefined { * ``` * * @param cookieDate - the cookie date string + * @returns Date if valid, undefined if invalid * @public */ export function parseDate(cookieDate: Nullable): Date | undefined { + // Early exit for empty input if (!cookieDate) { - return + return undefined } - /* RFC6265 S5.1.1: - * 2. Process each date-token sequentially in the order the date-tokens - * appear in the cookie-date - */ - const tokens = cookieDate.split(DATE_DELIM) + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-21#section-5.1.1 + // The user agent MUST use an algorithm equivalent to the following algorithm to + // parse a cookie-date. Note that the various boolean flags defined as a part of + // the algorithm (i.e., found-time, found-day-of-month, found-month, found-year) + // are initially "not set". + const flags: Flags = { + foundTime: undefined, + foundDayOfMonth: undefined, + foundMonth: undefined, + foundYear: undefined, + } - let hour: number | undefined - let minute: number | undefined - let second: number | undefined - let dayOfMonth: number | undefined - let month: number | undefined - let year: number | undefined + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-21#section-5.1.1-2.1.1 + // 1. Using the grammar below, divide the cookie-date into date-tokens. + // + // cookie-date = *delimiter date-token-list *delimiter + // date-token-list = date-token *( 1*delimiter date-token ) + // date-token = 1*non-delimiter + // + // delimiter = %x09 / %x20-2F / %x3B-40 / %x5B-60 / %x7B-7E + // non-delimiter = %x00-08 / %x0A-1F / DIGIT / ":" / ALPHA + // / %x7F-FF + // non-digit = %x00-2F / %x3A-FF + // + // day-of-month = 1*2DIGIT [ non-digit *OCTET ] + // month = ( "jan" / "feb" / "mar" / "apr" / + // "may" / "jun" / "jul" / "aug" / + // "sep" / "oct" / "nov" / "dec" ) *OCTET + // year = 2*4DIGIT [ non-digit *OCTET ] + // time = hms-time [ non-digit *OCTET ] + // hms-time = time-field ":" time-field ":" time-field + // time-field = 1*2DIGIT + const dateTokens: string[] = cookieDate + .split(DELIMITER) + // The delimiter and non-delimiter character sets form a complete partition + // of the byte space (0x00-0xFF). Every character is either a delimiter or non-delimiter, + // with no overlap or gaps. This means split(DELIMITER) produces tokens that are guaranteed + // to contain only non-delimiter characters. The only tokens that would fail NON_DELIMITER.test() + // are empty strings (from consecutive delimiters). Therefore, we can replace the expensive + // regex test with a simple length check. + .filter((token) => token.length > 0) - for (let i = 0; i < tokens.length; i++) { - const token = (tokens[i] ?? '').trim() - if (!token.length) { - continue + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-21#section-5.1.1-2.2.1 + // 2. Process each date-token sequentially in the order the date-tokens appear in the cookie-date: + for (const dateToken of dateTokens) { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-21#section-5.1.1-2.2.2.1.1 + // 2.1. If the found-time flag is not set and the token matches the time production, + // set the found-time flag and set the hour-value, minute-value, and second-value to + // the numbers denoted by the digits in the date-token, respectively. Skip the remaining + // sub-steps and continue to the next date-token. + // Use exec() with capture groups instead of test() + split() to avoid redundant work + if (flags.foundTime === undefined) { + const [, hours, minutes, seconds] = TIME.exec(dateToken) || [] + if (hours != undefined && minutes != undefined && seconds != undefined) { + const parsedHours = parseInt(hours, 10) + const parsedMinutes = parseInt(minutes, 10) + const parsedSeconds = parseInt(seconds, 10) + if ( + !isNaN(parsedHours) && + !isNaN(parsedMinutes) && + !isNaN(parsedSeconds) + ) { + flags.foundTime = { + hours: parsedHours, + minutes: parsedMinutes, + seconds: parsedSeconds, + } + continue + } + } } - /* 2.1. If the found-time flag is not set and the token matches the time - * production, set the found-time flag and set the hour- value, - * minute-value, and second-value to the numbers denoted by the digits in - * the date-token, respectively. Skip the remaining sub-steps and continue - * to the next date-token. - */ - if (second === undefined) { - const result = parseTime(token) - if (result) { - hour = result[0] - minute = result[1] - second = result[2] + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-21#section-5.1.1-2.2.2.2.1 + // 2.2. If the found-day-of-month flag is not set and the date-token matches the day-of-month production, + // set the found-day-of-month flag and set the day-of-month-value to the number denoted by the date-token. + // Skip the remaining sub-steps and continue to the next date-token. + if (flags.foundDayOfMonth === undefined && DAY_OF_MONTH.test(dateToken)) { + const dayOfMonth = parseInt(dateToken, 10) + if (!isNaN(dayOfMonth)) { + flags.foundDayOfMonth = dayOfMonth continue } } - /* 2.2. If the found-day-of-month flag is not set and the date-token matches - * the day-of-month production, set the found-day-of- month flag and set - * the day-of-month-value to the number denoted by the date-token. Skip - * the remaining sub-steps and continue to the next date-token. - */ - if (dayOfMonth === undefined) { - // "day-of-month = 1*2DIGIT ( non-digit *OCTET )" - const result = parseDigits(token, 1, 2, true) - if (result !== undefined) { - dayOfMonth = result + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-21#section-5.1.1-2.2.2.3.1 + // 2.3. If the found-month flag is not set and the date-token matches the month production, set the found-month + // flag and set the month-value to the month denoted by the date-token. Skip the remaining sub-steps and + // continue to the next date-token. + // @spec #section-5.1.1-2.2.2.3 + if (flags.foundMonth === undefined && MONTH.test(dateToken)) { + const month = months.indexOf(dateToken.substring(0, 3).toLowerCase()) + if (month >= 0 && month <= 11) { + flags.foundMonth = month continue } } - /* 2.3. If the found-month flag is not set and the date-token matches the - * month production, set the found-month flag and set the month-value to - * the month denoted by the date-token. Skip the remaining sub-steps and - * continue to the next date-token. - */ - if (month === undefined) { - const result = parseMonth(token) - if (result !== undefined) { - month = result + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-21#section-5.1.1-2.2.2.4.1 + // 2.4. If the found-year flag is not set and the date-token matches the year production, set the found-year + // flag and set the year-value to the number denoted by the date-token. Skip the remaining sub-steps and + // continue to the next date-token. + // @spec #section-5.1.1-2.2.2.4 + if (flags.foundYear === undefined && YEAR.test(dateToken)) { + const parsedYear = parseInt(dateToken, 10) + if (!isNaN(parsedYear)) { + flags.foundYear = parsedYear continue } } + } - /* 2.4. If the found-year flag is not set and the date-token matches the - * year production, set the found-year flag and set the year-value to the - * number denoted by the date-token. Skip the remaining sub-steps and - * continue to the next date-token. - */ - if (year === undefined) { - // "year = 2*4DIGIT ( non-digit *OCTET )" - const result = parseDigits(token, 2, 4, true) - if (result !== undefined) { - year = result - /* From S5.1.1: - * 3. If the year-value is greater than or equal to 70 and less - * than or equal to 99, increment the year-value by 1900. - * 4. If the year-value is greater than or equal to 0 and less - * than or equal to 69, increment the year-value by 2000. - */ - if (year >= 70 && year <= 99) { - year += 1900 - } else if (year >= 0 && year <= 69) { - year += 2000 - } - } - } + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-21#section-5.1.1-2.3.1 + // 3. If the year-value is greater than or equal to 70 and less than or equal to 99, increment the year-value by 1900. + if ( + flags.foundYear !== undefined && + flags.foundYear >= 70 && + flags.foundYear <= 99 + ) { + flags.foundYear += 1900 } - /* RFC 6265 S5.1.1 - * "5. Abort these steps and fail to parse the cookie-date if: - * * at least one of the found-day-of-month, found-month, found- - * year, or found-time flags is not set, - * * the day-of-month-value is less than 1 or greater than 31, - * * the year-value is less than 1601, - * * the hour-value is greater than 23, - * * the minute-value is greater than 59, or - * * the second-value is greater than 59. - * (Note that leap seconds cannot be represented in this syntax.)" - * - * So, in order as above: - */ + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-21#section-5.1.1-2.4.1 + // 4. If the year-value is greater than or equal to 0 and less than or equal to 69, increment the year-value by 2000. if ( - dayOfMonth === undefined || - month === undefined || - year === undefined || - hour === undefined || - minute === undefined || - second === undefined || - dayOfMonth < 1 || - dayOfMonth > 31 || - year < 1601 || - hour > 23 || - minute > 59 || - second > 59 + flags.foundYear !== undefined && + flags.foundYear >= 0 && + flags.foundYear <= 69 ) { - return + flags.foundYear += 2000 + } + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-21#section-5.1.1-2.4.2.1.1 + // NOTE: Some existing user agents interpret two-digit years differently. + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-21#section-5.1.1-2.5.1 + // 5. Abort these steps and fail to parse the cookie-date if: + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-21#section-5.1.1-2.5.2.1.1 + // - at least one of the found-day-of-month, found-month, found-year, or found-time flags is not set, + if ( + flags.foundDayOfMonth === undefined || + flags.foundMonth === undefined || + flags.foundYear === undefined || + flags.foundTime === undefined + ) { + return undefined + } + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-21#section-5.1.1-2.5.2.2.1 + // - the day-of-month-value is less than 1 or greater than 31, + if (flags.foundDayOfMonth < 1 || flags.foundDayOfMonth > 31) { + return undefined } - return new Date(Date.UTC(year, month, dayOfMonth, hour, minute, second)) + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-21#section-5.1.1-2.5.2.3.1 + // the year-value is less than 1601, + if (flags.foundYear < 1601) { + return undefined + } + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-21#section-5.1.1-2.5.2.4.1 + // the hour-value is greater than 23, + if (flags.foundTime.hours > 23) { + return undefined + } + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-21#section-5.1.1-2.5.2.5.1 + // the minute-value is greater than 59, or + if (flags.foundTime.minutes > 59) { + return undefined + } + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-21#section-5.1.1-2.5.2.6.1 + // the second-value is greater than 59. + if (flags.foundTime.seconds > 59) { + return undefined + } + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-21#section-5.1.1-2.5.3 + // (Note that leap seconds cannot be represented in this syntax.) + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-21#section-5.1.1-2.6.1 + // 6. Let the parsed-cookie-date be the date whose day-of-month, month, year, hour, minute, and second (in UTC) + // are the day-of-month-value, the month-value, the year-value, the hour-value, the minute-value, and the + // second-value, respectively. If no such date exists, abort these steps and fail to parse the cookie-date. + const date = new Date( + Date.UTC( + flags.foundYear, + flags.foundMonth, + flags.foundDayOfMonth, + flags.foundTime.hours, + flags.foundTime.minutes, + flags.foundTime.seconds, + ), + ) + + // NOTE: JavaScript's Date constructor silently rolls over invalid dates (e.g., Feb 30 → Mar 2). + // We must check if the date was rolled over and reject it as invalid. + if ( + date.getUTCFullYear() !== flags.foundYear || + date.getUTCMonth() !== flags.foundMonth || + date.getUTCDate() !== flags.foundDayOfMonth + ) { + return undefined + } + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-21#section-5.1.1-2.7.1 + // 7. Return the parsed-cookie-date as the result of this algorithm. + return date } + +type Flags = { + foundTime: undefined | { hours: number; minutes: number; seconds: number } + foundDayOfMonth: undefined | number + foundMonth: undefined | number + foundYear: undefined | number +} + +// Maps three-letter month abbreviations to their corresponding month index (0-11) +const months = [ + 'jan', + 'feb', + 'mar', + 'apr', + 'may', + 'jun', + 'jul', + 'aug', + 'sep', + 'oct', + 'nov', + 'dec', +] + +// delimiter = %x09 / %x20-2F / %x3B-40 / %x5B-60 / %x7B-7E +const DELIMITER = /[\x09\x20-\x2F\x3B-\x40\x5B-\x60\x7B-\x7E]/ + +// time = hms-time [ non-digit *OCTET ] +// hms-time = time-field ":" time-field ":" time-field +// time-field = 1*2DIGIT +// DIGIT = %x30-39; 0-9 (https://datatracker.ietf.org/doc/html/rfc5234#appendix-B.1) +// OPTIMIZATION: Use capture groups to extract hour, minute, second directly (avoids split + map) +const TIME = + /^(\d{1,2}):(\d{1,2}):(\d{1,2})(?:[\x00-\x2F\x3A-\xFF][\x00-\xFF]*)?$/ + +// day-of-month = 1*2DIGIT [ non-digit *OCTET ] +// non-digit = %x00-2F / %x3A-FF +// OCTET = %x00-FF; any 8-bit sequence of data (https://datatracker.ietf.org/doc/html/rfc5234#appendix-B.1) +const DAY_OF_MONTH = /^[0-9]{1,2}(?:[\x00-\x2F\x3A-\xFF][\x00-\xFF]*)?$/ + +// month = ( "jan" / "feb" / "mar" / "apr" / "may" / "jun" / "jul" / "aug" / "sep" / "oct" / "nov" / "dec" ) *OCTET +const MONTH = /^(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[\x00-\xFF]*$/i + +// year = 2*4DIGIT [ non-digit *OCTET ] +// non-digit = %x00-2F / %x3A-FF +// DIGIT = %x30-39; 0-9 (https://datatracker.ietf.org/doc/html/rfc5234#appendix-B.1) +// OCTET = %x00-FF; any 8-bit sequence of data (https://datatracker.ietf.org/doc/html/rfc5234#appendix-B.1) +const YEAR = /^[\x30-\x39]{2,4}(?:[\x00-\x2F\x3A-\xFF][\x00-\xFF]*)?$/