Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 4 additions & 58 deletions api/docs/tough-cookie.parsedate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export default config(
rules: {
'@typescript-eslint/explicit-function-return-type': 'error',
'import/no-nodejs-modules': 'error',
'no-control-regex': 'off',
},
},
{
Expand Down
252 changes: 252 additions & 0 deletions lib/__tests__/date.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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)
})
})
})
})
2 changes: 1 addition & 1 deletion lib/cookie/canonicalDomain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
1 change: 0 additions & 1 deletion lib/cookie/cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading