From 8179cc048f3628ea2b3867c519413a3755101b8c Mon Sep 17 00:00:00 2001 From: winklemad Date: Wed, 22 Jul 2026 21:42:30 +0530 Subject: [PATCH 1/2] fix: reject partially numeric request parameters --- .changeset/strict-number-parameters.md | 5 +++ src/parameters.ts | 2 +- tests/integration/parameters.test.ts | 47 ++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 .changeset/strict-number-parameters.md diff --git a/.changeset/strict-number-parameters.md b/.changeset/strict-number-parameters.md new file mode 100644 index 0000000..62ec7e7 --- /dev/null +++ b/.changeset/strict-number-parameters.md @@ -0,0 +1,5 @@ +--- +"chanfana": patch +--- + +Reject partially numeric query, path, and header parameters instead of silently accepting their numeric prefix. diff --git a/src/parameters.ts b/src/parameters.ts index c9c7e01..050fa74 100644 --- a/src/parameters.ts +++ b/src/parameters.ts @@ -84,7 +84,7 @@ export function coerceInputs(data: Record, schema?: RouteParameter) params[key] = _val === "true"; } } else if (unwrapAndCheck(innerType, z.ZodNumber) && typeof params[key] === "string") { - params[key] = Number.parseFloat(params[key]); + params[key] = Number(params[key]); } else if (unwrapAndCheck(innerType, z.ZodBigInt) && typeof params[key] === "string") { try { params[key] = BigInt(params[key]); diff --git a/tests/integration/parameters.test.ts b/tests/integration/parameters.test.ts index 62b3488..966f311 100644 --- a/tests/integration/parameters.test.ts +++ b/tests/integration/parameters.test.ts @@ -1,7 +1,54 @@ +import { AutoRouter } from "itty-router"; import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { fromIttyRouter, OpenAPIRoute } from "../../src"; import { ToDoList, todoRouter } from "../router"; import { buildRequest, findError } from "../utils"; +class NumericParametersEndpoint extends OpenAPIRoute { + schema = { + request: { + params: z.object({ pathValue: z.number() }), + query: z.object({ queryValue: z.number() }), + headers: z.object({ "x-number": z.number() }), + }, + }; + + async handle() { + return this.getValidatedData(); + } +} + +const numericParametersRouter = fromIttyRouter(AutoRouter()); +numericParametersRouter.get("/numeric-parameters/:pathValue", NumericParametersEndpoint); + +describe("numeric parameter coercion", () => { + it.each([ + ["query", "https://example.com/numeric-parameters/1?queryValue=12abc", "1"], + ["path", "https://example.com/numeric-parameters/12abc?queryValue=1", "1"], + ["header", "https://example.com/numeric-parameters/1?queryValue=1", "12abc"], + ])("rejects a partially numeric %s parameter", async (_location, url, headerValue) => { + const response = await numericParametersRouter.fetch(new Request(url, { headers: { "x-number": headerValue } })); + + expect(response.status).toBe(400); + }); + + it("preserves signed decimals and exponent notation", async () => { + const response = await numericParametersRouter.fetch( + new Request("https://example.com/numeric-parameters/-.5?queryValue=%2B1.25e2", { + headers: { "x-number": "-2.5E-1" }, + }), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + params: { pathValue: -0.5 }, + query: { queryValue: 125 }, + headers: { "x-number": -0.25 }, + }); + }); +}); + describe("queryParametersValidation", () => { it("requiredFields", async () => { const request = await todoRouter.fetch(buildRequest({ method: "GET", path: "/todos" })); From 750c6b938085372db345e5f319a8d64055f4d0d4 Mon Sep 17 00:00:00 2001 From: winklemad Date: Wed, 22 Jul 2026 22:08:11 +0530 Subject: [PATCH 2/2] fix: constrain numeric parameter formats --- src/parameters.ts | 5 ++++- tests/integration/parameters.test.ts | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/parameters.ts b/src/parameters.ts index 050fa74..b2b98cf 100644 --- a/src/parameters.ts +++ b/src/parameters.ts @@ -4,6 +4,8 @@ import type { AnyZodObject, RouteParameter } from "./types"; extendZodWithOpenApi(z); +const DECIMAL_NUMBER_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/; + /** * Helper function to unwrap optional/nullable types and check instanceof. * Handles Zod's wrapper types like ZodOptional, ZodNullable, ZodDefault. @@ -84,7 +86,8 @@ export function coerceInputs(data: Record, schema?: RouteParameter) params[key] = _val === "true"; } } else if (unwrapAndCheck(innerType, z.ZodNumber) && typeof params[key] === "string") { - params[key] = Number(params[key]); + const numericValue = params[key].trim(); + params[key] = DECIMAL_NUMBER_PATTERN.test(numericValue) ? Number(numericValue) : Number.NaN; } else if (unwrapAndCheck(innerType, z.ZodBigInt) && typeof params[key] === "string") { try { params[key] = BigInt(params[key]); diff --git a/tests/integration/parameters.test.ts b/tests/integration/parameters.test.ts index 966f311..4c791bd 100644 --- a/tests/integration/parameters.test.ts +++ b/tests/integration/parameters.test.ts @@ -33,6 +33,21 @@ describe("numeric parameter coercion", () => { expect(response.status).toBe(400); }); + it.each([ + ["empty query", "https://example.com/numeric-parameters/1?queryValue=", "1"], + ["empty header", "https://example.com/numeric-parameters/1?queryValue=1", ""], + ["whitespace query", "https://example.com/numeric-parameters/1?queryValue=%20%20%20", "1"], + ["whitespace path", "https://example.com/numeric-parameters/%20%20%20?queryValue=1", "1"], + ["whitespace header", "https://example.com/numeric-parameters/1?queryValue=1", " "], + ["hex query", "https://example.com/numeric-parameters/1?queryValue=0x10", "1"], + ["binary path", "https://example.com/numeric-parameters/0b10?queryValue=1", "1"], + ["octal header", "https://example.com/numeric-parameters/1?queryValue=1", "0o10"], + ])("rejects an unsupported %s parameter", async (_case, url, headerValue) => { + const response = await numericParametersRouter.fetch(new Request(url, { headers: { "x-number": headerValue } })); + + expect(response.status).toBe(400); + }); + it("preserves signed decimals and exponent notation", async () => { const response = await numericParametersRouter.fetch( new Request("https://example.com/numeric-parameters/-.5?queryValue=%2B1.25e2", {