diff --git a/src/utils/http-date.ts b/src/utils/http-date.ts new file mode 100644 index 0000000..6d39f29 --- /dev/null +++ b/src/utils/http-date.ts @@ -0,0 +1,295 @@ +/** + * Valid months that are allowed to be in an IMF date + */ +const IMF_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; + +/** + * Specific locations that are expected to be spaces in an IMF date + */ +const IMF_SPACES = [4, 7, 11, 16, 25]; + +/** + * Valid months that are allowed to be in an IMF date + */ +const IMF_MONTHS = [ + 'jan', + 'feb', + 'mar', + 'apr', + 'may', + 'jun', + 'jul', + 'aug', + 'sep', + 'oct', + 'nov', + 'dec', +]; + +/** + * Specific locations that are expected to be colons in an IMF date + */ +const IMF_COLONS = [19, 22]; + +/** + * Specific locations that are expected to be spaces in an asctime() date + */ +const ASCTIME_SPACES = [3, 7, 10, 19]; + +/** + * Valid days allowed in an RF850 date + */ +const RFC850_DAYS = [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', +]; + +/** + * @see https://www.rfc-editor.org/rfc/rfc9110.html#name-date-time-formats + */ +export function parseHttpDate( + date: string | null, + now = new Date() +): Date | undefined { + // Sun, 06 Nov 1994 08:49:37 GMT ; IMF-fixdate + // Sun Nov 6 08:49:37 1994 ; ANSI C's asctime() format + // Sunday, 06-Nov-94 08:49:37 GMT ; obsolete RFC 850 format + + if (date === null) { + return undefined; + } + + date = date.toLowerCase(); + + switch (date[3]) { + case ',': + return parseImfDate(date); + case ' ': + return parseAscTimeDate(date); + default: + return parseRfc850Date(date, now); + } +} + +/** + * @see https://httpwg.org/specs/rfc9110.html#preferred.date.format + */ +function parseImfDate(date: string): Date | undefined { + if (date.length !== 29) { + return undefined; + } + + if (!date.endsWith('gmt')) { + // Unsupported timezone + return undefined; + } + + // Ensure there are spaces in the expected locations + for (const spaceInx of IMF_SPACES) { + if (date[spaceInx] !== ' ') { + return undefined; + } + } + + // Ensure there are colons in the expected locations + for (const colonIdx of IMF_COLONS) { + if (date[colonIdx] !== ':') { + return undefined; + } + } + + const dayName = date.substring(0, 3); + if (!IMF_DAYS.includes(dayName)) { + return undefined; + } + + const dayString = date.substring(5, 7); + const day = Number.parseInt(dayString); + if (isNaN(day) || (day < 10 && dayString[0] !== '0')) { + // Not a number, 0, or it's less than 10 and didn't start with a 0 + return undefined; + } + + const month = date.substring(8, 11); + const monthIdx = IMF_MONTHS.indexOf(month); + if (monthIdx === -1) { + return undefined; + } + + const year = Number.parseInt(date.substring(12, 16)); + if (isNaN(year)) { + return undefined; + } + + const hourString = date.substring(17, 19); + const hour = Number.parseInt(hourString); + if (isNaN(hour) || (hour < 10 && hourString[0] !== '0')) { + return undefined; + } + + const minuteString = date.substring(20, 22); + const minute = Number.parseInt(minuteString); + if (isNaN(minute) || (minute < 10 && minuteString[0] !== '0')) { + return undefined; + } + + const secondString = date.substring(23, 25); + const second = Number.parseInt(secondString); + if (isNaN(second) || (second < 10 && secondString[0] !== '0')) { + return undefined; + } + + return new Date(Date.UTC(year, monthIdx, day, hour, minute, second)); +} + +/** + * @see https://httpwg.org/specs/rfc9110.html#obsolete.date.formats + */ +function parseAscTimeDate(date: string): Date | undefined { + // This is assumed to be in UTC + + if (date.length !== 24) { + return undefined; + } + + // Ensure there are spaces in the expected locations + for (const spaceIdx of ASCTIME_SPACES) { + if (date[spaceIdx] !== ' ') { + return undefined; + } + } + + const dayName = date.substring(0, 3); + if (!IMF_DAYS.includes(dayName)) { + return undefined; + } + + const month = date.substring(4, 7); + const monthIdx = IMF_MONTHS.indexOf(month); + if (monthIdx === -1) { + return undefined; + } + + const dayString = date.substring(8, 10); + const day = Number.parseInt(dayString); + if (isNaN(day) || (day < 10 && dayString[0] !== ' ')) { + return undefined; + } + + const hourString = date.substring(11, 13); + const hour = Number.parseInt(hourString); + if (isNaN(hour) || (hour < 10 && hourString[0] !== '0')) { + return undefined; + } + + const minuteString = date.substring(14, 16); + const minute = Number.parseInt(minuteString); + if (isNaN(minute) || (minute < 10 && minuteString[0] !== '0')) { + return undefined; + } + + const secondString = date.substring(17, 19); + const second = Number.parseInt(secondString); + if (isNaN(second) || (second < 10 && secondString[0] !== '0')) { + return undefined; + } + + const year = Number.parseInt(date.substring(20, 24)); + if (isNaN(year)) { + return undefined; + } + + return new Date(Date.UTC(year, monthIdx, day, hour, minute, second)); +} + +/** + * @see https://httpwg.org/specs/rfc9110.html#obsolete.date.formats + */ +function parseRfc850Date(date: string, now = new Date()): Date | undefined { + if (!date.endsWith('gmt')) { + // Unsupported timezone + return undefined; + } + + const commaIndex = date.indexOf(','); + if (commaIndex === -1) { + return undefined; + } + + if (date.length - commaIndex - 1 !== 23) { + return undefined; + } + + const dayName = date.substring(0, commaIndex); + if (!RFC850_DAYS.includes(dayName)) { + return undefined; + } + + // Ensure there are spaces, dashes, and colons in the expected locations + if ( + date[commaIndex + 1] !== ' ' || + date[commaIndex + 4] !== '-' || + date[commaIndex + 8] !== '-' || + date[commaIndex + 11] !== ' ' || + date[commaIndex + 14] !== ':' || + date[commaIndex + 17] !== ':' || + date[commaIndex + 20] !== ' ' + ) { + return undefined; + } + + const dayString = date.substring(commaIndex + 2, commaIndex + 4); + const day = Number.parseInt(dayString); + if (isNaN(day) || (day < 10 && dayString[0] !== '0')) { + // Not a number, or it's less than 10 and didn't start with a 0 + return undefined; + } + + const month = date.substring(commaIndex + 5, commaIndex + 8); + const monthIdx = IMF_MONTHS.indexOf(month); + if (monthIdx === -1) { + return undefined; + } + + // As of this point year is just the decade (i.e. 94) + let year = Number.parseInt(date.substring(commaIndex + 9, commaIndex + 11)); + if (isNaN(year)) { + return undefined; + } + + const currentYear = now.getUTCFullYear(); + const currentDecade = currentYear % 100; + const currentCentury = Math.floor(currentYear / 100); + + if (year > currentDecade && year - currentDecade >= 50) { + // Over 50 years in future, go to previous century + year += (currentCentury - 1) * 100; + } else { + year += currentCentury * 100; + } + + const hourString = date.substring(commaIndex + 12, commaIndex + 14); + const hour = Number.parseInt(hourString); + if (isNaN(hour) || (hour < 10 && hourString[0] !== '0')) { + return undefined; + } + + const minuteString = date.substring(commaIndex + 15, commaIndex + 17); + const minute = Number.parseInt(minuteString); + if (isNaN(minute) || (minute < 10 && minuteString[0] !== '0')) { + return undefined; + } + + const secondString = date.substring(commaIndex + 18, commaIndex + 20); + const second = Number.parseInt(secondString); + if (isNaN(second) || (second < 10 && secondString[0] !== '0')) { + return undefined; + } + + return new Date(Date.UTC(year, monthIdx, day, hour, minute, second)); +} diff --git a/src/utils/request.ts b/src/utils/request.ts index 56fcf09..62c0b89 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -1,3 +1,5 @@ +import { parseHttpDate } from './http-date'; + /** * Etags will have quotes removed from them * R2 supports every conditional header except `If-Range` @@ -30,7 +32,7 @@ export function parseUrl(request: Request): URL | undefined { } export function parseConditionalHeaders(headers: Headers): ConditionalHeaders { - const ifModifiedSince = getDateFromHeader(headers.get('if-modified-since')); + const ifModifiedSince = parseHttpDate(headers.get('if-modified-since')); const ifMatch = headers.has('if-match') ? headers.get('if-match')!.replaceAll('"', '') @@ -40,9 +42,7 @@ export function parseConditionalHeaders(headers: Headers): ConditionalHeaders { ? headers.get('if-none-match')!.replaceAll('"', '') : undefined; - const ifUnmodifiedSince = getDateFromHeader( - headers.get('if-unmodified-since') - ); + const ifUnmodifiedSince = parseHttpDate(headers.get('if-unmodified-since')); const range = headers.has('range') ? parseRangeHeader(headers.get('range')!) @@ -57,15 +57,6 @@ export function parseConditionalHeaders(headers: Headers): ConditionalHeaders { }; } -function getDateFromHeader(dateString: string | null): Date | undefined { - if (dateString === null) { - return undefined; - } - - const date = new Date(dateString); - return !isNaN(date.getTime()) ? date : undefined; -} - /** * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range * @returns undefined if header is invalid diff --git a/tests/unit/index.test.ts b/tests/unit/index.test.ts index 6300633..683c96b 100644 --- a/tests/unit/index.test.ts +++ b/tests/unit/index.test.ts @@ -2,5 +2,6 @@ import './utils/object.test'; import './utils/path.test'; import './utils/request.test'; import './utils/memo.test'; +import './utils/http-date.test'; import './router/router.test'; import './middleware/substituteMiddleware.test'; diff --git a/tests/unit/utils/http-date.test.ts b/tests/unit/utils/http-date.test.ts new file mode 100644 index 0000000..12b6f38 --- /dev/null +++ b/tests/unit/utils/http-date.test.ts @@ -0,0 +1,89 @@ +import { describe, test } from 'node:test'; +import { deepStrictEqual } from 'node:assert'; +import { parseHttpDate } from '../../../src/utils/http-date'; + +describe('parseHttpDate', () => { + test('IMF-fixdate', () => { + const values = { + 'Sun, 06 Nov 1994 08:49:37 GMT': new Date( + Date.UTC(1994, 10, 6, 8, 49, 37) + ), + 'Thu, 18 Aug 1950 02:01:18 GMT': new Date( + Date.UTC(1950, 7, 18, 2, 1, 18) + ), + 'Wed, 11 Dec 2024 23:20:57 GMT': new Date( + Date.UTC(2024, 11, 11, 23, 20, 57) + ), + 'Wed, aa Dec 2024 23:20:57 GMT': undefined, // NaN daty + 'aaa, 06 Dec 2024 23:20:57 GMT': undefined, // Invalid day name + 'Wed, 01 aaa 2024 23:20:57 GMT': undefined, // Invalid month + 'Wed, 6 Dec 2024 23:20:07 GMT': undefined, // No leading zero + 'Wed, 06 Dec 2024 3:20:07 GMT': undefined, // No leading zero + 'Wed, 06 Dec 2024 23:1:07 GMT': undefined, // No leading zero + 'Wed, 06 Dec 2024 23:01:7 GMT': undefined, // No leading zero + 'Wed, 06 Dec aaaa 23:01:07 GMT': undefined, // NaN year + 'Wed, 06 Dec 2024 aa:01:07 GMT': undefined, // NaN hour + 'Wed, 06 Dec 2024 23:aa:07 GMT': undefined, // NaN min + 'Wed, 06 Dec 2024 23:01:aa GMT': undefined, // NaN sec + }; + + for (const date of Object.keys(values)) { + // @ts-expect-error date isn't type keyof values + deepStrictEqual(parseHttpDate(date), values[date], date); + } + }); + + test('RFC850', () => { + const values = { + 'Sunday, 06-Nov-94 08:49:37 GMT': new Date( + Date.UTC(1994, 10, 6, 8, 49, 37) + ), + 'Thursday, 18-Aug-50 02:01:18 GMT': new Date( + Date.UTC(2050, 7, 18, 2, 1, 18) + ), + 'Wednesday, 11-Dec-24 23:20:57 GMT': new Date( + Date.UTC(2024, 11, 11, 23, 20, 57) + ), + 'Wednesday, aa Dec 2024 23:20:57 GMT': undefined, // NaN daty + 'aaa, 06 Dec 2024 23:20:57 GMT': undefined, // Invalid day name + 'Wednesday, 01-aaa-24 23:20:57 GMT': undefined, // Invalid month + 'Wednesday, 6-Dec-24 23:20:07 GMT': undefined, // No leading zero + 'Wednesday, 06-Dec-24 3:20:07 GMT': undefined, // No leading zero + 'Wednesday, 06-Dec-24 23:1:07 GMT': undefined, // No leading zero + 'Wednesday, 06-Dec-24 23:01:7 GMT': undefined, // No leading zero + 'Wednesday, 06 Dec-aa 23:01:07 GMT': undefined, // NaN year + 'Wednesday, 06-Dec-24 aa:01:07 GMT': undefined, // NaN hour + 'Wednesday, 06-Dec-24 23:aa:07 GMT': undefined, // NaN min + 'Wednesday, 06-Dec-24 23:01:aa GMT': undefined, // NaN sec + }; + + for (const date of Object.keys(values)) { + // @ts-expect-error date isn't type keyof values + deepStrictEqual(parseHttpDate(date), values[date], date); + } + }); + + test('asctime()', () => { + const values = { + 'Sun Nov 6 08:49:37 1994': new Date(Date.UTC(1994, 10, 6, 8, 49, 37)), + 'Thu Aug 18 02:01:18 1950': new Date(Date.UTC(1950, 7, 18, 2, 1, 18)), + 'Wed Dec 11 23:20:57 2024': new Date(Date.UTC(2024, 11, 11, 23, 20, 57)), + 'Wed Dec aa 23:20:57 2024': undefined, // NaN daty + 'aaa Dec 06 23:20:57 2024': undefined, // Invalid day name + 'Wed aaa 01 23:20:57 2024': undefined, // Invalid month + 'Wed Dec 6 23:20:07 2024': undefined, // No leading zero + 'Wed Dec 06 3:20:07 2024': undefined, // No leading zero + 'Wed Dec 06 23:1:07 2024': undefined, // No leading zero + 'Wed Dec 06 23:01:7 2024': undefined, // No leading zero + 'Wed 06 Dec 23:01:07 aaaa': undefined, // NaN year + 'Wed Dec 06 aa:01:07 2024': undefined, // NaN hour + 'Wed Dec 06 23:aa:07 2024': undefined, // NaN min + 'Wed Dec 06 23:01:aa 2024': undefined, // NaN sec + }; + + for (const date of Object.keys(values)) { + // @ts-expect-error date isn't type keyof values + deepStrictEqual(parseHttpDate(date), values[date], date); + } + }); +});