Skip to content

Commit 4559a8d

Browse files
Alix-007vincentkoc
andauthored
fix(cron): reject invalid absolute timestamps (#93903)
* fix(cron): reject invalid absolute timestamps * fix(cron): preserve ISO end of day --------- Co-authored-by: Vincent Koc <[email protected]>
1 parent 087e3f5 commit 4559a8d

2 files changed

Lines changed: 69 additions & 0 deletions

File tree

src/cron/parse.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,27 @@ describe("parseAbsoluteTimeMs", () => {
1010
it("rejects digit-only timestamps outside the Date range", () => {
1111
expect(parseAbsoluteTimeMs(String(Number.MAX_SAFE_INTEGER))).toBeNull();
1212
});
13+
14+
it("parses ISO timestamps with UTC defaults and explicit offsets", () => {
15+
expect(parseAbsoluteTimeMs("2026-02-28")).toBe(Date.parse("2026-02-28T00:00:00Z"));
16+
expect(parseAbsoluteTimeMs("2026-02-28T12:34:56.789Z")).toBe(
17+
Date.parse("2026-02-28T12:34:56.789Z"),
18+
);
19+
expect(parseAbsoluteTimeMs("2026-02-28T24:00:00Z")).toBe(Date.parse("2026-02-28T24:00:00Z"));
20+
expect(parseAbsoluteTimeMs("2026-02-28T12:34:56+08:00")).toBe(
21+
Date.parse("2026-02-28T12:34:56+08:00"),
22+
);
23+
});
24+
25+
it.each([
26+
"2023-02-29",
27+
"2026-02-31",
28+
"2026-02-31T00:00:00Z",
29+
"2026-04-31T12:34:56Z",
30+
"2026-01-01T25:00:00Z",
31+
"December 17, 2026 03:24:00",
32+
"2026/12/17",
33+
])("rejects invalid absolute timestamp %s", (input) => {
34+
expect(parseAbsoluteTimeMs(input)).toBeNull();
35+
});
1336
});

src/cron/parse.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js";
44
const ISO_TZ_RE = /(Z|[+-]\d{2}:?\d{2})$/i;
55
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
66
const ISO_DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T/;
7+
const ISO_ABSOLUTE_RE =
8+
/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(\.\d+)?)?(?:[Zz]|[+-]\d{2}:?\d{2})?)?$/;
79

810
function normalizeUtcIso(raw: string) {
911
if (ISO_TZ_RE.test(raw)) {
@@ -18,6 +20,47 @@ function normalizeUtcIso(raw: string) {
1820
return raw;
1921
}
2022

23+
function isValidIsoAbsolute(raw: string) {
24+
const match = ISO_ABSOLUTE_RE.exec(raw);
25+
if (!match) {
26+
return false;
27+
}
28+
29+
const [
30+
,
31+
yearRaw,
32+
monthRaw,
33+
dayRaw,
34+
hourRaw = "0",
35+
minuteRaw = "0",
36+
secondRaw = "0",
37+
fractionRaw,
38+
] = match;
39+
const year = Number(yearRaw);
40+
const month = Number(monthRaw);
41+
const day = Number(dayRaw);
42+
const hour = Number(hourRaw);
43+
const minute = Number(minuteRaw);
44+
const second = Number(secondRaw);
45+
const millisecond = fractionRaw ? Number(fractionRaw.slice(1, 4).padEnd(3, "0")) : 0;
46+
const isEndOfDay = hour === 24 && minute === 0 && second === 0 && millisecond === 0;
47+
48+
// Date.parse rolls invalid calendar dates; cron must reject them before scheduling.
49+
const probe = new Date(0);
50+
probe.setUTCFullYear(year, month - 1, day);
51+
probe.setUTCHours(isEndOfDay ? 0 : hour, minute, second, millisecond);
52+
53+
return (
54+
probe.getUTCFullYear() === year &&
55+
probe.getUTCMonth() === month - 1 &&
56+
probe.getUTCDate() === day &&
57+
probe.getUTCHours() === (isEndOfDay ? 0 : hour) &&
58+
probe.getUTCMinutes() === minute &&
59+
probe.getUTCSeconds() === second &&
60+
probe.getUTCMilliseconds() === millisecond
61+
);
62+
}
63+
2164
/** Parses absolute cron timestamps from epoch milliseconds or ISO-like strings normalized to UTC. */
2265
export function parseAbsoluteTimeMs(input: string): number | null {
2366
const raw = input.trim();
@@ -31,6 +74,9 @@ export function parseAbsoluteTimeMs(input: string): number | null {
3174
}
3275
return null;
3376
}
77+
if (!isValidIsoAbsolute(raw)) {
78+
return null;
79+
}
3480
const parsed = Date.parse(normalizeUtcIso(raw));
3581
return Number.isFinite(parsed) ? parsed : null;
3682
}

0 commit comments

Comments
 (0)