diff --git a/packages/server/src/http/validate.ts b/packages/server/src/http/validate.ts index 409e0fa..0d7a048 100644 --- a/packages/server/src/http/validate.ts +++ b/packages/server/src/http/validate.ts @@ -42,8 +42,13 @@ export function requireValidId(c: Context, name: string): string { /** Parse a positive-integer path parameter (e.g. Trace file index). */ export function positiveIntParam(c: Context, name: string): number { - const v = Number.parseInt(pathParam(c, name), 10); - if (!Number.isInteger(v) || v < 1) throw badRequest(`${name} must be a positive integer.`); + // Match digits only: Number.parseInt would accept trailing garbage ("12abc" -> 12). + const raw = pathParam(c, name); + if (!/^\d+$/.test(raw)) throw badRequest(`${name} must be a positive integer.`); + const v = Number.parseInt(raw, 10); + // isSafeInteger (not isInteger) also rejects overlong indices like "99999999999999999999" + // that would otherwise parse to an imprecise float (1e20). + if (!Number.isSafeInteger(v) || v < 1) throw badRequest(`${name} must be a positive integer.`); return v; } @@ -163,8 +168,20 @@ export function optionalNumber( /** Validate a yyyy-mm-dd query parameter (defaults to undefined). */ export function optionalDateParam(value: string | undefined, label: string): string | undefined { if (value === undefined || value === "") return undefined; - if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { - throw badRequest(`${label} must be in YYYY-MM-DD format.`); + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + // The shape check alone accepts impossible dates (2026-13-40, 2026-02-30); verify it's a real + // calendar day by round-tripping through UTC (which never rolls over into an adjacent month). + if (m) { + const [, y, mo, d] = m; + const dt = new Date(`${value}T00:00:00Z`); + if ( + !Number.isNaN(dt.getTime()) && + dt.getUTCFullYear() === Number(y) && + dt.getUTCMonth() + 1 === Number(mo) && + dt.getUTCDate() === Number(d) + ) { + return value; + } } - return value; + throw badRequest(`${label} must be a valid date in YYYY-MM-DD format.`); } diff --git a/packages/server/test/validate.test.ts b/packages/server/test/validate.test.ts new file mode 100644 index 0000000..8323a84 --- /dev/null +++ b/packages/server/test/validate.test.ts @@ -0,0 +1,67 @@ +/** + * Request-validation helper unit tests: positiveIntParam rejects trailing garbage, and + * optionalDateParam rejects impossible calendar dates (shape-only checks let these through). + */ +import { describe, expect, it } from "vitest"; +import type { Context } from "hono"; +import { optionalDateParam, positiveIntParam } from "../src/http/validate.js"; +import { HttpError } from "../src/http/errors.js"; + +/** Minimal Context stub exposing a single path parameter. */ +function ctxWithParam(name: string, value: string | undefined): Context { + return { req: { param: (n: string) => (n === name ? value : undefined) } } as unknown as Context; +} + +describe("positiveIntParam", () => { + it("parses a plain positive integer", () => { + expect(positiveIntParam(ctxWithParam("idx", "12"), "idx")).toBe(12); + }); + + it("rejects trailing garbage (parseInt would accept it)", () => { + expect(() => positiveIntParam(ctxWithParam("idx", "12abc"), "idx")).toThrow(HttpError); + }); + + it("rejects a leading sign, whitespace, and non-digits", () => { + for (const bad of ["+1", " 1", "1 ", "1.5", "0x10"]) { + expect(() => positiveIntParam(ctxWithParam("idx", bad), "idx")).toThrow(HttpError); + } + }); + + it("rejects zero (must be >= 1)", () => { + expect(() => positiveIntParam(ctxWithParam("idx", "0"), "idx")).toThrow(HttpError); + }); + + it("rejects overlong indices that would parse to an imprecise float", () => { + // "99999999999999999999" parses to 1e20 — isSafeInteger rejects it, isInteger would not. + expect(() => positiveIntParam(ctxWithParam("idx", "9".repeat(20)), "idx")).toThrow(HttpError); + }); + + it("an empty/missing path param is rejected upstream by pathParam (404), not the digits guard", () => { + expect(() => positiveIntParam(ctxWithParam("idx", ""), "idx")).toThrow(HttpError); + expect(() => positiveIntParam(ctxWithParam("idx", undefined), "idx")).toThrow(HttpError); + }); +}); + +describe("optionalDateParam", () => { + it("returns undefined for missing or empty input", () => { + expect(optionalDateParam(undefined, "from")).toBeUndefined(); + expect(optionalDateParam("", "from")).toBeUndefined(); + }); + + it("accepts a real calendar date", () => { + expect(optionalDateParam("2026-07-20", "from")).toBe("2026-07-20"); + expect(optionalDateParam("2024-02-29", "from")).toBe("2024-02-29"); // leap day + }); + + it("rejects malformed shapes", () => { + for (const bad of ["2026/07/20", "20260720", "2026-7-20", "not-a-date"]) { + expect(() => optionalDateParam(bad, "from")).toThrow(HttpError); + } + }); + + it("rejects impossible dates that pass the shape check", () => { + for (const bad of ["2026-13-40", "2026-02-30", "2026-00-10", "2026-01-00", "2025-02-29"]) { + expect(() => optionalDateParam(bad, "from")).toThrow(HttpError); + } + }); +});