diff --git a/.changeset/response-validation.md b/.changeset/response-validation.md new file mode 100644 index 0000000..6b5a1df --- /dev/null +++ b/.changeset/response-validation.md @@ -0,0 +1,20 @@ +--- +"chanfana": minor +--- + +Add `validateResponse` router option to validate and sanitize response bodies against their Zod schemas at runtime. + +When enabled, responses are parsed through `z.object().parseAsync()`, which strips unknown fields and validates required fields/types. This prevents accidental data leaks (e.g., internal fields like `passwordHash` reaching the client) and catches handler bugs where the response doesn't match the declared schema. + +```typescript +const router = fromHono(app, { validateResponse: true }); +``` + +**Behavior:** +- Plain object responses are validated against the `200` response schema +- `Response` objects with `application/json` content are cloned, validated, and reconstructed with corrected headers +- Non-JSON responses and responses without a matching Zod schema are passed through unchanged +- Validation failures return `500 Internal Server Error` (code `7013`) and log the full error via `console.error` + +**New exports:** +- `ResponseValidationException` — thrown when a handler's response doesn't match its declared schema (status 500, code 7013, `isVisible: false`) diff --git a/docs/error-handling.md b/docs/error-handling.md index 8d640d0..21b1191 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -367,6 +367,30 @@ if (maintenanceMode) { * **`code`:** `7012` * **`default_message`:** `"Gateway Timeout"` +### `ResponseValidationException`: Response Schema Mismatch (500) + +`ResponseValidationException` is thrown automatically when the [`validateResponse`](./openapi-configuration-customization.md#validateresponse-response-body-validation) option is enabled and a handler's response doesn't match its declared Zod response schema (e.g., a required field is missing or has the wrong type). + +**Key features:** +* **`status`:** `500 Internal Server Error` +* **`code`:** `7013` +* **`default_message`:** `"Response Validation Error"` +* **`isVisible`:** `false` (error details hidden from clients to avoid leaking internal schema information) + +This exception is **not intended to be thrown manually**. It is raised internally by chanfana when response validation fails. The full validation error (including Zod issue details) is logged via `console.error` for server-side debugging. + +```json +{ + "success": false, + "errors": [{ "code": 7013, "message": "Internal Error" }], + "result": {} +} +``` + +::: tip +A response validation failure indicates a **server-side bug** — the handler produced data that doesn't match its declared schema. Check your server logs (`console.error`) for the specific Zod validation issues, then fix the handler or update the response schema. +::: + ## Exception Summary Table | Exception | Status | Code | Default Message | Special Properties | @@ -384,6 +408,7 @@ if (maintenanceMode) { | `BadGatewayException` | 502 | 7010 | "Bad Gateway" | - | | `ServiceUnavailableException` | 503 | 7011 | "Service Unavailable" | `retryAfter` | | `GatewayTimeoutException` | 504 | 7012 | "Gateway Timeout" | - | +| `ResponseValidationException` | 500 | 7013 | "Response Validation Error" | `isVisible: false` | ### `MultiException`: Managing Multiple Errors diff --git a/docs/openapi-configuration-customization.md b/docs/openapi-configuration-customization.md index 42f667e..5d4faba 100644 --- a/docs/openapi-configuration-customization.md +++ b/docs/openapi-configuration-customization.md @@ -224,6 +224,82 @@ This option is most useful with Hono, where `app.onError` provides centralized e See [Error Handling - Bypassing Chanfana's Error Formatting](./error-handling.md#bypassing-chanfanas-error-formatting) for a detailed walkthrough. +### `validateResponse`: Response Body Validation + +* **Type:** `boolean` +* **Default:** `false` + +The `validateResponse` option enables runtime validation of response bodies against their declared Zod schemas. When enabled, every response from `handle()` is parsed through the matching Zod response schema before being sent to the client. + +This provides two key benefits: + +1. **Data leak prevention:** Unknown fields are stripped from responses, so internal properties (e.g., `passwordHash`, `internalNotes`) never accidentally reach the client. +2. **Handler bug detection:** If a handler returns data missing required fields or with wrong types, the mismatch is caught immediately instead of silently returning malformed responses. + +**Example:** + +```typescript +import { Hono } from 'hono'; +import { fromHono, OpenAPIRoute, contentJson } from 'chanfana'; +import { z } from 'zod'; + +class GetUserEndpoint extends OpenAPIRoute { + schema = { + responses: { + "200": { + description: "User details", + ...contentJson(z.object({ + id: z.number(), + name: z.string(), + })), + }, + }, + }; + + async handle(c) { + const user = await db.getUser(1); + // Even if `user` contains { id: 1, name: "Alice", passwordHash: "..." }, + // the response will only include { id: 1, name: "Alice" } + return user; + } +} + +const app = new Hono(); +const router = fromHono(app, { validateResponse: true }); +router.get("/user/:id", GetUserEndpoint); +``` + +**How it works:** + +| Response type | Behavior | +|---|---| +| Plain object | Validated against the `200` response schema | +| `Response` with `application/json` | Body is cloned, validated against the schema matching `resp.status`, and reconstructed with corrected headers | +| `Response` with non-JSON content type | Passed through unchanged | +| No Zod schema defined for the status code | Passed through unchanged | +| `null` or `undefined` | Passed through unchanged | + +**When validation fails** (e.g., a required field is missing from the response), chanfana: + +1. Logs the full error details via `console.error` for server-side debugging. +2. Returns a `500 Internal Server Error` response with error code `7013`. The error message is hidden from clients (`isVisible: false`) to avoid leaking internal schema details. + +```json +{ + "success": false, + "errors": [{ "code": 7013, "message": "Internal Error" }], + "result": {} +} +``` + +::: tip +Response validation failures are **server-side bugs** (the handler doesn't match its declared schema), which is why they return `500` — not `400`. Check `console.error` output for the specific Zod validation issues. +::: + +::: warning +This option adds a parsing step to every response. For most APIs the overhead is negligible, but for extremely high-throughput endpoints returning large payloads you may want to enable it selectively or only in development/staging. +::: + ## Customizing OpenAPI Schema Output While `RouterOptions` allows you to configure the overall OpenAPI document, you can also customize the schema output for individual endpoints and parameters using Zod's OpenAPI metadata features. diff --git a/src/exceptions.ts b/src/exceptions.ts index 0c403ab..635e2b6 100644 --- a/src/exceptions.ts +++ b/src/exceptions.ts @@ -288,3 +288,16 @@ export class GatewayTimeoutException extends ApiException { status = 504; code = 7012; } + +/** + * Exception for response validation errors (500). + * Used when a handler's response doesn't match its declared Zod response schema. + * This is a server-side bug (the handler produced invalid data), not a client error. + * Error details are hidden from clients (isVisible=false) and logged via console.error. + */ +export class ResponseValidationException extends ApiException { + isVisible = false; + default_message = "Response Validation Error"; + status = 500; + code = 7013; +} diff --git a/src/openapi.ts b/src/openapi.ts index 3bc2341..c81f78d 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -300,6 +300,7 @@ export class OpenAPIHandler { route: parsedRoute, urlParams: urlParams, raiseOnError: this.options?.raiseOnError, + validateResponse: this.options?.validateResponse, raiseUnknownParameters: this.options?.raiseUnknownParameters, passthroughErrors: this.options?.passthroughErrors, }).execute(...params); diff --git a/src/route.ts b/src/route.ts index f94f355..d5fcdc8 100644 --- a/src/route.ts +++ b/src/route.ts @@ -1,6 +1,6 @@ import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; import { z } from "zod"; -import { InputValidationException, MultiException } from "./exceptions"; +import { InputValidationException, MultiException, ResponseValidationException } from "./exceptions"; import { coerceInputs } from "./parameters"; import type { AnyZodObject, OpenAPIRouteSchema, RouteOptions, ValidatedData } from "./types"; import { formatChanfanaError, jsonResp } from "./utils"; @@ -191,6 +191,15 @@ export class OpenAPIRoute = any> { let resp; try { resp = await this.handle(...args); + + if (this.params?.validateResponse) { + try { + resp = await this.validateResponse(resp); + } catch (validationError) { + console.error("[chanfana] Response validation failed:", validationError); + throw new ResponseValidationException(); + } + } } catch (rawError) { if (this.params?.passthroughErrors) { throw rawError; @@ -218,6 +227,79 @@ export class OpenAPIRoute = any> { return resp; } + /** + * Finds the Zod schema for a response with the given status code. + * Falls back to the "default" response if no exact match is found. + * @param statusCode - HTTP status code to look up + * @returns Zod schema for the response body, or undefined if not found + */ + getResponseSchema(statusCode: number): z.ZodType | undefined { + const schema = this.getSchemaZod(); + const responses = schema.responses; + if (!responses) return undefined; + + const responseConfig = responses[String(statusCode)] ?? responses.default; + if (!responseConfig) return undefined; + + const jsonContent = responseConfig.content?.["application/json"]; + if (!jsonContent?.schema) return undefined; + + // Skip non-Zod schemas (e.g. empty {} from default response) + const zodSchema = jsonContent.schema; + if (!(zodSchema instanceof z.ZodType)) return undefined; + + return zodSchema; + } + + /** + * Validates a response body against the response schema. + * For plain objects, parses through Zod to strip unknown fields and validate types. + * For Response objects with JSON content, clones the body, parses, and reconstructs + * with corrected headers (Content-Length/Transfer-Encoding are removed). + * Responses without a matching Zod schema (including non-JSON responses) are passed through unchanged. + * @param resp - The response from handle() + * @returns The validated/stripped response + * @throws ZodError if the response body fails schema validation + */ + async validateResponse(resp: any): Promise { + if (resp === null || resp === undefined) return resp; + + if (resp instanceof Response) { + const contentType = resp.headers.get("content-type") || ""; + if (!contentType.includes("application/json")) return resp; + + const responseSchema = this.getResponseSchema(resp.status); + if (!responseSchema) return resp; + + // Clone before consuming the body stream so the original remains readable on failure + const cloned = resp.clone(); + const body = await cloned.json(); + const parsed = await responseSchema.parseAsync(body); + + // Reconstruct the Response with validated body and original status/headers. + // Delete Content-Length and Transfer-Encoding since the body size may have changed + // after stripping unknown fields. + const newHeaders = new Headers(resp.headers); + newHeaders.delete("content-length"); + newHeaders.delete("transfer-encoding"); + return new Response(JSON.stringify(parsed), { + status: resp.status, + statusText: resp.statusText, + headers: newHeaders, + }); + } + + if (typeof resp === "object") { + // Plain objects are auto-converted to 200 JSON responses + const responseSchema = this.getResponseSchema(200); + if (!responseSchema) return resp; + + return await responseSchema.parseAsync(resp); + } + + return resp; + } + /** * Validates the incoming request against the schema. * @param request - The incoming Request object diff --git a/src/types.ts b/src/types.ts index 3e7a722..6167821 100644 --- a/src/types.ts +++ b/src/types.ts @@ -76,6 +76,13 @@ export interface RouterOptions { openapiVersion?: "3" | "3.1"; raiseOnError?: boolean; passthroughErrors?: boolean; + /** + * When enabled, response bodies are parsed through their Zod schema, + * stripping unknown fields and validating required fields/types. + * Validation failures result in a 500 error response and a console.error log. + * Responses without a Zod schema are passed through unchanged. + */ + validateResponse?: boolean; } export interface RouteOptions { @@ -83,6 +90,7 @@ export interface RouteOptions { raiseUnknownParameters: boolean; raiseOnError?: boolean; passthroughErrors?: boolean; + validateResponse?: boolean; route: string; urlParams: Array; } diff --git a/tests/integration/router-options.test.ts b/tests/integration/router-options.test.ts index 89f9be7..21dc007 100644 --- a/tests/integration/router-options.test.ts +++ b/tests/integration/router-options.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { fromHono, fromIttyRouter } from "../../src"; import { contentJson } from "../../src/contentTypes"; -import { ApiException, MultiException, NotFoundException } from "../../src/exceptions"; +import { ApiException, MultiException, NotFoundException, ResponseValidationException } from "../../src/exceptions"; import { OpenAPIRoute } from "../../src/route"; import { buildRequest } from "../utils"; @@ -783,3 +783,444 @@ describe("itty-router error handling", () => { expect((resp as any).errors[0].code).toEqual(7002); }); }); + +// --- validateResponse tests --- + +class EndpointWithExtraFields extends OpenAPIRoute { + schema = { + responses: { + "200": { + description: "Success", + ...contentJson( + z.object({ + id: z.number(), + name: z.string(), + }), + ), + }, + }, + }; + + async handle() { + // Return extra fields that are not in the schema + return { + id: 1, + name: "Test", + secret: "should-be-stripped", + internal_notes: "also-stripped", + }; + } +} + +class EndpointReturningResponse extends OpenAPIRoute { + schema = { + responses: { + "201": { + description: "Created", + ...contentJson( + z.object({ + success: z.boolean(), + result: z.object({ + id: z.number(), + title: z.string(), + }), + }), + ), + }, + }, + }; + + async handle() { + return Response.json( + { + success: true, + result: { + id: 42, + title: "New Item", + passwordHash: "secret123", + }, + }, + { status: 201 }, + ); + } +} + +class EndpointWithNoResponseSchema extends OpenAPIRoute { + async handle() { + return { data: "anything", extra: "allowed" }; + } +} + +class EndpointReturningHtml extends OpenAPIRoute { + schema = { + responses: { + "200": { + description: "Success", + ...contentJson( + z.object({ + id: z.number(), + }), + ), + }, + }, + }; + + async handle() { + return new Response("

Hello

", { + status: 200, + headers: { "Content-Type": "text/html" }, + }); + } +} + +class EndpointReturningNull extends OpenAPIRoute { + schema = { + responses: { + "200": { + description: "Success", + ...contentJson( + z.object({ + id: z.number(), + }), + ), + }, + }, + }; + + async handle() { + return null; + } +} + +class EndpointWithDefaultResponseSchema extends OpenAPIRoute { + schema = { + responses: { + default: { + description: "Default response", + ...contentJson( + z.object({ + status: z.string(), + }), + ), + }, + }, + }; + + async handle() { + return { status: "ok", debug: "should-be-stripped" }; + } +} + +class EndpointReturningUnmatchedStatus extends OpenAPIRoute { + schema = { + responses: { + "200": { + description: "Success", + ...contentJson( + z.object({ + id: z.number(), + }), + ), + }, + }, + }; + + async handle() { + // Return 202 (only 200 is defined in schema) to test fallback passthrough + return Response.json({ id: 1, extra: "data" }, { status: 202 }); + } +} + +class EndpointWithResponseDefaults extends OpenAPIRoute { + schema = { + responses: { + "200": { + description: "Success", + ...contentJson( + z.object({ + id: z.number(), + status: z.string().default("active"), + }), + ), + }, + }, + }; + + async handle() { + // Only return id; the "status" field should get its default from Zod + return { id: 1 }; + } +} + +class EndpointReturningInvalidData extends OpenAPIRoute { + schema = { + responses: { + "200": { + description: "Success", + ...contentJson( + z.object({ + id: z.number(), + name: z.string(), + }), + ), + }, + }, + }; + + async handle() { + // Missing required "name" field + return { id: 1 }; + } +} + +describe("validateResponse option", () => { + describe("itty-router with validateResponse enabled", () => { + it("should strip unknown fields from plain object responses", async () => { + const router = fromIttyRouter(AutoRouter(), { validateResponse: true }); + router.get("/items/:id", EndpointWithExtraFields); + + const request = await router.fetch(buildRequest({ method: "GET", path: "/items/1" })); + const resp = await request.json(); + + expect(request.status).toBe(200); + expect(resp.id).toBe(1); + expect(resp.name).toBe("Test"); + expect(resp.secret).toBeUndefined(); + expect(resp.internal_notes).toBeUndefined(); + }); + + it("should strip unknown fields from Response object bodies", async () => { + const router = fromIttyRouter(AutoRouter(), { validateResponse: true }); + router.post("/items", EndpointReturningResponse); + + const request = await router.fetch( + new Request("http://localhost/items", { + method: "POST", + body: JSON.stringify({}), + headers: { "Content-Type": "application/json" }, + }), + ); + const resp = await request.json(); + + expect(request.status).toBe(201); + expect(resp.success).toBe(true); + expect(resp.result.id).toBe(42); + expect(resp.result.title).toBe("New Item"); + expect(resp.result.passwordHash).toBeUndefined(); + }); + + it("should pass through responses when no response schema is defined", async () => { + const router = fromIttyRouter(AutoRouter(), { validateResponse: true }); + router.get("/items", EndpointWithNoResponseSchema); + + const request = await router.fetch(buildRequest({ method: "GET", path: "/items" })); + const resp = await request.json(); + + expect(request.status).toBe(200); + expect(resp.data).toBe("anything"); + expect(resp.extra).toBe("allowed"); + }); + + it("should return 500 when response is missing required fields", async () => { + const router = fromIttyRouter(AutoRouter(), { validateResponse: true }); + router.get("/items/:id", EndpointReturningInvalidData); + + const request = await router.fetch(buildRequest({ method: "GET", path: "/items/1" })); + const resp = await request.json(); + + // Response validation failure is a server-side bug → 500, not 400 + expect(request.status).toBe(500); + expect(resp.success).toBe(false); + expect(resp.errors[0].code).toBe(7013); + expect(resp.errors[0].message).toBe("Internal Error"); + }); + }); + + it("should pass through non-JSON Response objects unchanged", async () => { + const router = fromIttyRouter(AutoRouter(), { validateResponse: true }); + router.get("/html", EndpointReturningHtml); + + const request = await router.fetch(buildRequest({ method: "GET", path: "/html" })); + + expect(request.status).toBe(200); + expect(request.headers.get("content-type")).toBe("text/html"); + const body = await request.text(); + expect(body).toBe("

Hello

"); + }); + + it("should handle null responses without errors", async () => { + const app = new Hono(); + let caughtError: unknown = null; + + app.onError((err, c) => { + caughtError = err; + return c.json({ error: "Internal Server Error" }, 500); + }); + + const router = fromHono(app, { validateResponse: true }); + router.get("/null", EndpointReturningNull); + + const request = await router.fetch(new Request("http://localhost/null", { method: "GET" })); + + // null passes through validateResponse without throwing; + // no error should reach onError (Hono may return 404 for null responses) + expect(caughtError).toBeNull(); + expect(request.status).not.toBe(500); + }); + + it("should fall back to the default response schema", async () => { + const router = fromIttyRouter(AutoRouter(), { validateResponse: true }); + router.get("/default", EndpointWithDefaultResponseSchema); + + const request = await router.fetch(buildRequest({ method: "GET", path: "/default" })); + const resp = await request.json(); + + expect(request.status).toBe(200); + expect(resp.status).toBe("ok"); + expect(resp.debug).toBeUndefined(); + }); + + it("should pass through when status code has no matching schema", async () => { + const router = fromIttyRouter(AutoRouter(), { validateResponse: true }); + router.get("/unmatched", EndpointReturningUnmatchedStatus); + + const request = await router.fetch(buildRequest({ method: "GET", path: "/unmatched" })); + const resp = await request.json(); + + // 202 has no schema and no "default" → passed through unchanged (extra not stripped) + expect(request.status).toBe(202); + expect(resp.id).toBe(1); + expect(resp.extra).toBe("data"); + }); + + it("should apply Zod defaults to response fields", async () => { + const router = fromIttyRouter(AutoRouter(), { validateResponse: true }); + router.get("/defaults", EndpointWithResponseDefaults); + + const request = await router.fetch(buildRequest({ method: "GET", path: "/defaults" })); + const resp = await request.json(); + + expect(request.status).toBe(200); + expect(resp.id).toBe(1); + expect(resp.status).toBe("active"); + }); + + describe("validateResponse with passthroughErrors", () => { + it("should propagate raw ResponseValidationException to Hono onError", async () => { + const app = new Hono(); + let caughtError: unknown = null; + + app.onError((err, c) => { + caughtError = err; + return c.json({ error: "caught" }, 500); + }); + + const router = fromHono(app, { validateResponse: true, passthroughErrors: true }); + router.get("/items/:id", EndpointReturningInvalidData); + + const request = await router.fetch(new Request("http://localhost/items/1", { method: "GET" })); + + // With passthroughErrors, the raw ResponseValidationException reaches onError (not HTTPException) + expect(caughtError).toBeInstanceOf(ResponseValidationException); + expect(caughtError).not.toBeInstanceOf(HTTPException); + expect(request.status).toBe(500); + }); + }); + + describe("itty-router without validateResponse (default)", () => { + it("should NOT strip unknown fields when validateResponse is not set", async () => { + const router = fromIttyRouter(AutoRouter()); + router.get("/items/:id", EndpointWithExtraFields); + + const request = await router.fetch(buildRequest({ method: "GET", path: "/items/1" })); + const resp = await request.json(); + + expect(request.status).toBe(200); + expect(resp.id).toBe(1); + expect(resp.name).toBe("Test"); + // Extra fields should still be present + expect(resp.secret).toBe("should-be-stripped"); + expect(resp.internal_notes).toBe("also-stripped"); + }); + }); + + describe("Hono with validateResponse enabled", () => { + it("should strip unknown fields from plain object responses", async () => { + const app = new Hono(); + app.onError((err, c) => { + if (err instanceof HTTPException) { + return err.getResponse(); + } + return c.json({ error: "Internal Server Error" }, 500); + }); + + const router = fromHono(app, { validateResponse: true }); + router.get("/items/:id", EndpointWithExtraFields); + + const request = await router.fetch(new Request("http://localhost/items/1", { method: "GET" })); + const resp = (await request.json()) as any; + + expect(request.status).toBe(200); + expect(resp.id).toBe(1); + expect(resp.name).toBe("Test"); + expect(resp.secret).toBeUndefined(); + expect(resp.internal_notes).toBeUndefined(); + }); + + it("should return 500 with error code 7013 for invalid response data", async () => { + const app = new Hono(); + let caughtError: unknown = null; + + app.onError((err, c) => { + caughtError = err; + if (err instanceof HTTPException) { + return err.getResponse(); + } + return c.json({ error: "Internal Server Error" }, 500); + }); + + const router = fromHono(app, { validateResponse: true }); + router.get("/items/:id", EndpointReturningInvalidData); + + const request = await router.fetch(new Request("http://localhost/items/1", { method: "GET" })); + + // Hono adapter wraps ApiException as HTTPException via wrapHandler + expect(caughtError).toBeInstanceOf(HTTPException); + expect((caughtError as HTTPException).status).toBe(500); + + const resp = (await request.json()) as any; + expect(request.status).toBe(500); + expect(resp.success).toBe(false); + expect(resp.errors[0].code).toBe(7013); + expect(resp.errors[0].message).toBe("Internal Error"); + }); + + it("should strip unknown fields from Response object bodies", async () => { + const app = new Hono(); + app.onError((err, c) => { + if (err instanceof HTTPException) { + return err.getResponse(); + } + return c.json({ error: "Internal Server Error" }, 500); + }); + + const router = fromHono(app, { validateResponse: true }); + router.post("/items", EndpointReturningResponse); + + const request = await router.fetch( + new Request("http://localhost/items", { + method: "POST", + body: JSON.stringify({}), + headers: { "Content-Type": "application/json" }, + }), + ); + const resp = (await request.json()) as any; + + expect(request.status).toBe(201); + expect(resp.success).toBe(true); + expect(resp.result.id).toBe(42); + expect(resp.result.title).toBe("New Item"); + expect(resp.result.passwordHash).toBeUndefined(); + }); + }); +});