Skip to content

Commit 8036497

Browse files
committed
fix(usage): reject invalid explicit dates in usage RPC date parsing
usage.cost and sessions.usage accepted shape-valid but impossible dates such as 2026-02-30: parseDateParts validated only the YYYY-MM-DD regex, so Date.* silently rolled them over (2026-02-30 -> 2026-03-02) and the RPC returned cost/usage for the wrong day. Out-of-range parts now fail a UTC round-trip check, and an explicitly provided unparseable date (bad format or impossible calendar date) returns INVALID_REQUEST instead of silently falling back to the default range. Absent/valid dates are unchanged. [AI-assisted]
1 parent 3630ce6 commit 8036497

2 files changed

Lines changed: 104 additions & 2 deletions

File tree

src/gateway/server-methods/usage.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,49 @@ describe("gateway usage helpers", () => {
9494
expect(testApi.parseDateToMs(undefined)).toBeUndefined();
9595
});
9696

97+
it("parseDateToMs rejects out-of-range calendar dates instead of rolling them over", () => {
98+
// Impossible dates that still match the YYYY-MM-DD shape must not silently shift to a real day.
99+
expect(testApi.parseDateToMs("2026-02-30")).toBeUndefined(); // would roll to Mar 2
100+
expect(testApi.parseDateToMs("2026-04-31")).toBeUndefined(); // would roll to May 1
101+
expect(testApi.parseDateToMs("2025-02-29")).toBeUndefined(); // non-leap Feb 29
102+
expect(testApi.parseDateToMs("2026-13-01")).toBeUndefined(); // month too large
103+
expect(testApi.parseDateToMs("2026-00-10")).toBeUndefined(); // month zero
104+
expect(testApi.parseDateToMs("2026-01-00")).toBeUndefined(); // day zero
105+
// Real leap day must stay valid (guard against over-rejection).
106+
expect(testApi.parseDateToMs("2024-02-29")).toBe(Date.UTC(2024, 1, 29));
107+
});
108+
109+
it("findInvalidExplicitDate flags provided-but-unparseable dates and ignores absent/valid ones", () => {
110+
// Explicitly provided invalid dates (bad format or impossible calendar date) are reported.
111+
expect(testApi.findInvalidExplicitDate({ startDate: "2026-02-30" })).toBe("startDate");
112+
expect(testApi.findInvalidExplicitDate({ endDate: "2026-2-5" })).toBe("endDate");
113+
expect(
114+
testApi.findInvalidExplicitDate({ startDate: "2026-02-01", endDate: "2026-13-01" }),
115+
).toBe("endDate");
116+
// Absent or valid dates are not flagged, so they still fall through to the default range.
117+
expect(testApi.findInvalidExplicitDate({})).toBeUndefined();
118+
expect(testApi.findInvalidExplicitDate({ startDate: "", endDate: undefined })).toBeUndefined();
119+
expect(
120+
testApi.findInvalidExplicitDate({ startDate: "2026-02-01", endDate: "2026-02-02" }),
121+
).toBeUndefined();
122+
});
123+
124+
it("usage.cost rejects an explicitly provided invalid date with INVALID_REQUEST", async () => {
125+
const respond = vi.fn();
126+
await usageHandlers["usage.cost"]({
127+
respond,
128+
params: { startDate: "2026-02-30" },
129+
context: { getRuntimeConfig: () => ({}) },
130+
} as unknown as Parameters<(typeof usageHandlers)["usage.cost"]>[0]);
131+
expect(respond).toHaveBeenCalledTimes(1);
132+
const [ok, payload, error] = respond.mock.calls[0];
133+
expect(ok).toBe(false);
134+
expect(payload).toBeUndefined();
135+
expect(JSON.stringify(error)).toContain("startDate");
136+
// A rejected request must not query the cost loader for an unrelated range.
137+
expect(vi.mocked(loadCostUsageSummaryFromCache)).not.toHaveBeenCalled();
138+
});
139+
97140
it("parseUtcOffsetToMinutes supports whole-hour and half-hour offsets", () => {
98141
expect(testApi.parseUtcOffsetToMinutes("UTC-4")).toBe(-240);
99142
expect(testApi.parseUtcOffsetToMinutes("UTC+5:30")).toBe(330);
@@ -327,7 +370,8 @@ describe("gateway usage helpers", () => {
327370

328371
expect(vi.mocked(loadCostUsageSummaryFromCache)).toHaveBeenCalledTimes(3);
329372
expect(
330-
vi.mocked(loadCostUsageSummaryFromCache)
373+
vi
374+
.mocked(loadCostUsageSummaryFromCache)
331375
.mock.calls.slice(1)
332376
.map((call) => call[0]?.agentId),
333377
).toEqual(["main", "opus"]);

src/gateway/server-methods/usage.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,9 +180,38 @@ const parseDateParts = (
180180
if (!Number.isFinite(year) || !Number.isFinite(monthIndex) || !Number.isFinite(day)) {
181181
return undefined;
182182
}
183+
// The regex only checks shape; Date.* silently rolls impossible calendar dates over
184+
// (e.g. 2026-02-30 -> 2026-03-02), so a typo'd day would return usage for the wrong day.
185+
// Reject parts that don't round-trip through a UTC probe (also catches the JS 2-digit-year remap).
186+
const probe = new Date(Date.UTC(year, monthIndex, day));
187+
if (
188+
probe.getUTCFullYear() !== year ||
189+
probe.getUTCMonth() !== monthIndex ||
190+
probe.getUTCDate() !== day
191+
) {
192+
return undefined;
193+
}
183194
return { year, monthIndex, day };
184195
};
185196

197+
// usage.cost / sessions.usage accept optional startDate/endDate. parseDateParts returns
198+
// undefined for both absent and invalid input, so an explicitly supplied but unparseable
199+
// date (bad format or impossible calendar date like 2026-02-30) would otherwise silently
200+
// fall through to the default range and return a successful response for an unrelated range.
201+
// Return the offending field so handlers can reject it instead of querying the wrong window.
202+
const findInvalidExplicitDate = (params: {
203+
startDate?: unknown;
204+
endDate?: unknown;
205+
}): "startDate" | "endDate" | undefined => {
206+
for (const field of ["startDate", "endDate"] as const) {
207+
const raw = params[field];
208+
if (typeof raw === "string" && raw.trim() !== "" && parseDateParts(raw) === undefined) {
209+
return field;
210+
}
211+
}
212+
return undefined;
213+
};
214+
186215
/**
187216
* Parse a UTC offset string in the format UTC+H, UTC-H, UTC+HH, UTC-HH, UTC+H:MM, UTC-HH:MM.
188217
* Returns the UTC offset in minutes (east-positive), or undefined if invalid.
@@ -902,6 +931,7 @@ function mergeUsageCacheStatus(
902931
// Exposed for unit tests (kept as a single export to avoid widening the public API surface).
903932
export const testApi = {
904933
parseDateParts,
934+
findInvalidExplicitDate,
905935
parseUtcOffsetToMinutes,
906936
resolveDateInterpretation,
907937
parseDateToMs,
@@ -922,6 +952,21 @@ export const usageHandlers: GatewayRequestHandlers = {
922952
respond(true, summary, undefined);
923953
},
924954
"usage.cost": async ({ respond, params, context }) => {
955+
const invalidDate = findInvalidExplicitDate({
956+
startDate: params?.startDate,
957+
endDate: params?.endDate,
958+
});
959+
if (invalidDate) {
960+
respond(
961+
false,
962+
undefined,
963+
errorShape(
964+
ErrorCodes.INVALID_REQUEST,
965+
`invalid ${invalidDate}: expected a valid YYYY-MM-DD calendar date`,
966+
),
967+
);
968+
return;
969+
}
925970
const config = context.getRuntimeConfig();
926971
const { startMs, endMs } = parseDateRange({
927972
startDate: params?.startDate,
@@ -956,6 +1001,18 @@ export const usageHandlers: GatewayRequestHandlers = {
9561001
}
9571002

9581003
const p = params;
1004+
const invalidDate = findInvalidExplicitDate({ startDate: p.startDate, endDate: p.endDate });
1005+
if (invalidDate) {
1006+
respond(
1007+
false,
1008+
undefined,
1009+
errorShape(
1010+
ErrorCodes.INVALID_REQUEST,
1011+
`invalid ${invalidDate}: expected a valid YYYY-MM-DD calendar date`,
1012+
),
1013+
);
1014+
return;
1015+
}
9591016
const config = context.getRuntimeConfig();
9601017
const { startMs, endMs } = parseDateRange({
9611018
startDate: p.startDate,
@@ -1335,7 +1392,8 @@ export const usageHandlers: GatewayRequestHandlers = {
13351392
}
13361393
const hiddenSession = hiddenSessions[hiddenIndex];
13371394
const merged = mergedEntries[hiddenSession.entryIndex];
1338-
const usage = usageByEntryIndex[hiddenSession.entryIndex] ?? createEmptySessionCostSummary();
1395+
const usage =
1396+
usageByEntryIndex[hiddenSession.entryIndex] ?? createEmptySessionCostSummary();
13391397
usage.sessionId = merged.sessionId;
13401398
usage.sessionFile = merged.sessionFile;
13411399
mergeSessionUsageInto(usage, summary);

0 commit comments

Comments
 (0)