Skip to content

Commit e0cc15c

Browse files
committed
fix: do spec-compliant date parsing
Signed-off-by: flakey5 <[email protected]>
1 parent 46c71de commit e0cc15c

File tree

4 files changed

+389
-13
lines changed

4 files changed

+389
-13
lines changed

src/utils/http-date.ts

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
/**
2+
* Valid months that are allowed to be in an IMF date
3+
*/
4+
const IMF_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
5+
6+
/**
7+
* Specific locations that are expected to be spaces in an IMF date
8+
*/
9+
const IMF_SPACES = [4, 7, 11, 16, 25];
10+
11+
/**
12+
* Valid months that are allowed to be in an IMF date
13+
*/
14+
const IMF_MONTHS = [
15+
'jan',
16+
'feb',
17+
'mar',
18+
'apr',
19+
'may',
20+
'jun',
21+
'jul',
22+
'aug',
23+
'sep',
24+
'oct',
25+
'nov',
26+
'dec',
27+
];
28+
29+
/**
30+
* Specific locations that are expected to be colons in an IMF date
31+
*/
32+
const IMF_COLONS = [19, 22];
33+
34+
/**
35+
* Specific locations that are expected to be spaces in an asctime() date
36+
*/
37+
const ASCTIME_SPACES = [3, 7, 10, 19];
38+
39+
/**
40+
* Valid days allowed in an RF850 date
41+
*/
42+
const RFC850_DAYS = [
43+
'monday',
44+
'tuesday',
45+
'wednesday',
46+
'thursday',
47+
'friday',
48+
'saturday',
49+
'sunday',
50+
];
51+
52+
/**
53+
* @see https://www.rfc-editor.org/rfc/rfc9110.html#name-date-time-formats
54+
*/
55+
export function parseHttpDate(
56+
date: string | null,
57+
now = new Date()
58+
): Date | undefined {
59+
// Sun, 06 Nov 1994 08:49:37 GMT ; IMF-fixdate
60+
// Sun Nov 6 08:49:37 1994 ; ANSI C's asctime() format
61+
// Sunday, 06-Nov-94 08:49:37 GMT ; obsolete RFC 850 format
62+
63+
if (date === null) {
64+
return undefined;
65+
}
66+
67+
date = date.toLowerCase();
68+
69+
switch (date[3]) {
70+
case ',':
71+
return parseImfDate(date);
72+
case ' ':
73+
return parseAscTimeDate(date);
74+
default:
75+
return parseRfc850Date(date, now);
76+
}
77+
}
78+
79+
/**
80+
* @see https://httpwg.org/specs/rfc9110.html#preferred.date.format
81+
*/
82+
function parseImfDate(date: string): Date | undefined {
83+
if (date.length !== 29) {
84+
return undefined;
85+
}
86+
87+
if (!date.endsWith('gmt')) {
88+
// Unsupported timezone
89+
return undefined;
90+
}
91+
92+
// Ensure there are spaces in the expected locations
93+
for (const spaceInx of IMF_SPACES) {
94+
if (date[spaceInx] !== ' ') {
95+
return undefined;
96+
}
97+
}
98+
99+
// Ensure there are colons in the expected locations
100+
for (const colonIdx of IMF_COLONS) {
101+
if (date[colonIdx] !== ':') {
102+
return undefined;
103+
}
104+
}
105+
106+
const dayName = date.substring(0, 3);
107+
if (!IMF_DAYS.includes(dayName)) {
108+
return undefined;
109+
}
110+
111+
const dayString = date.substring(5, 7);
112+
const day = Number.parseInt(dayString);
113+
if (isNaN(day) || (day < 10 && dayString[0] !== '0')) {
114+
// Not a number, 0, or it's less than 10 and didn't start with a 0
115+
return undefined;
116+
}
117+
118+
const month = date.substring(8, 11);
119+
const monthIdx = IMF_MONTHS.indexOf(month);
120+
if (monthIdx === -1) {
121+
return undefined;
122+
}
123+
124+
const year = Number.parseInt(date.substring(12, 16));
125+
if (isNaN(year)) {
126+
return undefined;
127+
}
128+
129+
const hourString = date.substring(17, 19);
130+
const hour = Number.parseInt(hourString);
131+
if (isNaN(hour) || (hour < 10 && hourString[0] !== '0')) {
132+
return undefined;
133+
}
134+
135+
const minuteString = date.substring(20, 22);
136+
const minute = Number.parseInt(minuteString);
137+
if (isNaN(minute) || (minute < 10 && minuteString[0] !== '0')) {
138+
return undefined;
139+
}
140+
141+
const secondString = date.substring(23, 25);
142+
const second = Number.parseInt(secondString);
143+
if (isNaN(second) || (second < 10 && secondString[0] !== '0')) {
144+
return undefined;
145+
}
146+
147+
return new Date(Date.UTC(year, monthIdx, day, hour, minute, second));
148+
}
149+
150+
/**
151+
* @see https://httpwg.org/specs/rfc9110.html#obsolete.date.formats
152+
*/
153+
function parseAscTimeDate(date: string): Date | undefined {
154+
// This is assumed to be in UTC
155+
156+
if (date.length !== 24) {
157+
return undefined;
158+
}
159+
160+
// Ensure there are spaces in the expected locations
161+
for (const spaceIdx of ASCTIME_SPACES) {
162+
if (date[spaceIdx] !== ' ') {
163+
return undefined;
164+
}
165+
}
166+
167+
const dayName = date.substring(0, 3);
168+
if (!IMF_DAYS.includes(dayName)) {
169+
return undefined;
170+
}
171+
172+
const month = date.substring(4, 7);
173+
const monthIdx = IMF_MONTHS.indexOf(month);
174+
if (monthIdx === -1) {
175+
return undefined;
176+
}
177+
178+
const dayString = date.substring(8, 10);
179+
const day = Number.parseInt(dayString);
180+
if (isNaN(day) || (day < 10 && dayString[0] !== ' ')) {
181+
return undefined;
182+
}
183+
184+
const hourString = date.substring(11, 13);
185+
const hour = Number.parseInt(hourString);
186+
if (isNaN(hour) || (hour < 10 && hourString[0] !== '0')) {
187+
return undefined;
188+
}
189+
190+
const minuteString = date.substring(14, 16);
191+
const minute = Number.parseInt(minuteString);
192+
if (isNaN(minute) || (minute < 10 && minuteString[0] !== '0')) {
193+
return undefined;
194+
}
195+
196+
const secondString = date.substring(17, 19);
197+
const second = Number.parseInt(secondString);
198+
if (isNaN(second) || (second < 10 && secondString[0] !== '0')) {
199+
return undefined;
200+
}
201+
202+
const year = Number.parseInt(date.substring(20, 24));
203+
if (isNaN(year)) {
204+
return undefined;
205+
}
206+
207+
return new Date(Date.UTC(year, monthIdx, day, hour, minute, second));
208+
}
209+
210+
/**
211+
* @see https://httpwg.org/specs/rfc9110.html#obsolete.date.formats
212+
*/
213+
function parseRfc850Date(date: string, now = new Date()): Date | undefined {
214+
if (!date.endsWith('gmt')) {
215+
// Unsupported timezone
216+
return undefined;
217+
}
218+
219+
const commaIndex = date.indexOf(',');
220+
if (commaIndex === -1) {
221+
return undefined;
222+
}
223+
224+
if (date.length - commaIndex - 1 !== 23) {
225+
return undefined;
226+
}
227+
228+
const dayName = date.substring(0, commaIndex);
229+
if (!RFC850_DAYS.includes(dayName)) {
230+
return undefined;
231+
}
232+
233+
// Ensure there are spaces, dashes, and colons in the expected locations
234+
if (
235+
date[commaIndex + 1] !== ' ' ||
236+
date[commaIndex + 4] !== '-' ||
237+
date[commaIndex + 8] !== '-' ||
238+
date[commaIndex + 11] !== ' ' ||
239+
date[commaIndex + 14] !== ':' ||
240+
date[commaIndex + 17] !== ':' ||
241+
date[commaIndex + 20] !== ' '
242+
) {
243+
return undefined;
244+
}
245+
246+
const dayString = date.substring(commaIndex + 2, commaIndex + 4);
247+
const day = Number.parseInt(dayString);
248+
if (isNaN(day) || (day < 10 && dayString[0] !== '0')) {
249+
// Not a number, or it's less than 10 and didn't start with a 0
250+
return undefined;
251+
}
252+
253+
const month = date.substring(commaIndex + 5, commaIndex + 8);
254+
const monthIdx = IMF_MONTHS.indexOf(month);
255+
if (monthIdx === -1) {
256+
return undefined;
257+
}
258+
259+
// As of this point year is just the decade (i.e. 94)
260+
let year = Number.parseInt(date.substring(commaIndex + 9, commaIndex + 11));
261+
if (isNaN(year)) {
262+
return undefined;
263+
}
264+
265+
const currentYear = now.getUTCFullYear();
266+
const currentDecade = currentYear % 100;
267+
const currentCentury = Math.floor(currentYear / 100);
268+
269+
if (year > currentDecade && year - currentDecade >= 50) {
270+
// Over 50 years in future, go to previous century
271+
year += (currentCentury - 1) * 100;
272+
} else {
273+
year += currentCentury * 100;
274+
}
275+
276+
const hourString = date.substring(commaIndex + 12, commaIndex + 14);
277+
const hour = Number.parseInt(hourString);
278+
if (isNaN(hour) || (hour < 10 && hourString[0] !== '0')) {
279+
return undefined;
280+
}
281+
282+
const minuteString = date.substring(commaIndex + 15, commaIndex + 17);
283+
const minute = Number.parseInt(minuteString);
284+
if (isNaN(minute) || (minute < 10 && minuteString[0] !== '0')) {
285+
return undefined;
286+
}
287+
288+
const secondString = date.substring(commaIndex + 18, commaIndex + 20);
289+
const second = Number.parseInt(secondString);
290+
if (isNaN(second) || (second < 10 && secondString[0] !== '0')) {
291+
return undefined;
292+
}
293+
294+
return new Date(Date.UTC(year, monthIdx, day, hour, minute, second));
295+
}

src/utils/request.ts

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { parseHttpDate } from './http-date';
2+
13
/**
24
* Etags will have quotes removed from them
35
* R2 supports every conditional header except `If-Range`
@@ -30,7 +32,7 @@ export function parseUrl(request: Request): URL | undefined {
3032
}
3133

3234
export function parseConditionalHeaders(headers: Headers): ConditionalHeaders {
33-
const ifModifiedSince = getDateFromHeader(headers.get('if-modified-since'));
35+
const ifModifiedSince = parseHttpDate(headers.get('if-modified-since'));
3436

3537
const ifMatch = headers.has('if-match')
3638
? headers.get('if-match')!.replaceAll('"', '')
@@ -40,9 +42,7 @@ export function parseConditionalHeaders(headers: Headers): ConditionalHeaders {
4042
? headers.get('if-none-match')!.replaceAll('"', '')
4143
: undefined;
4244

43-
const ifUnmodifiedSince = getDateFromHeader(
44-
headers.get('if-unmodified-since')
45-
);
45+
const ifUnmodifiedSince = parseHttpDate(headers.get('if-unmodified-since'));
4646

4747
const range = headers.has('range')
4848
? parseRangeHeader(headers.get('range')!)
@@ -57,15 +57,6 @@ export function parseConditionalHeaders(headers: Headers): ConditionalHeaders {
5757
};
5858
}
5959

60-
function getDateFromHeader(dateString: string | null): Date | undefined {
61-
if (dateString === null) {
62-
return undefined;
63-
}
64-
65-
const date = new Date(dateString);
66-
return !isNaN(date.getTime()) ? date : undefined;
67-
}
68-
6960
/**
7061
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range
7162
* @returns undefined if header is invalid

tests/unit/index.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ import './utils/object.test';
22
import './utils/path.test';
33
import './utils/request.test';
44
import './utils/memo.test';
5+
import './utils/http-date.test';
56
import './router/router.test';
67
import './middleware/substituteMiddleware.test';

0 commit comments

Comments
 (0)