From 2a46bfbba0695ae713b58211fd5e83a08b829ad6 Mon Sep 17 00:00:00 2001 From: Andrei Date: Sun, 20 Jul 2025 01:30:09 +0200 Subject: [PATCH] fix(standard-validator): arktype leaking headers (#1282) * fix(standard-validator): arktype strip headers from error output * chore(standard-validator): fix formatting * feat(changeset): add changeset * fix: change header schema to lowercase and cleanup tests * fix: strip out sensitive fields for valibot * chore: refactor, cleanup and add jsdoc --- .changeset/rotten-cats-live.md | 5 + .../standard-validator/__schemas__/arktype.ts | 5 + .../standard-validator/__schemas__/valibot.ts | 5 + .../standard-validator/__schemas__/zod.ts | 5 + packages/standard-validator/src/index.test.ts | 41 ++++++++ packages/standard-validator/src/index.ts | 66 ++++++++++++- .../standard-validator/src/sanitize-issues.ts | 95 +++++++++++++++++++ 7 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 .changeset/rotten-cats-live.md create mode 100644 packages/standard-validator/src/sanitize-issues.ts diff --git a/.changeset/rotten-cats-live.md b/.changeset/rotten-cats-live.md new file mode 100644 index 00000000..a1362286 --- /dev/null +++ b/.changeset/rotten-cats-live.md @@ -0,0 +1,5 @@ +--- +'@hono/standard-validator': patch +--- + +Fix cookies output for arktype in standard schema validator diff --git a/packages/standard-validator/__schemas__/arktype.ts b/packages/standard-validator/__schemas__/arktype.ts index 3f0fb8d6..31cda54f 100644 --- a/packages/standard-validator/__schemas__/arktype.ts +++ b/packages/standard-validator/__schemas__/arktype.ts @@ -26,7 +26,12 @@ const querySortSchema = type({ order: "'asc'|'desc'", }) +const headerSchema = type({ + 'user-agent': 'string', +}) + export { + headerSchema, idJSONSchema, personJSONSchema, postJSONSchema, diff --git a/packages/standard-validator/__schemas__/valibot.ts b/packages/standard-validator/__schemas__/valibot.ts index 1c551b43..bad8bcdd 100644 --- a/packages/standard-validator/__schemas__/valibot.ts +++ b/packages/standard-validator/__schemas__/valibot.ts @@ -28,7 +28,12 @@ const querySortSchema = object({ order: picklist(['asc', 'desc']), }) +const headerSchema = object({ + 'user-agent': string(), +}) + export { + headerSchema, idJSONSchema, personJSONSchema, postJSONSchema, diff --git a/packages/standard-validator/__schemas__/zod.ts b/packages/standard-validator/__schemas__/zod.ts index df45f842..8099f414 100644 --- a/packages/standard-validator/__schemas__/zod.ts +++ b/packages/standard-validator/__schemas__/zod.ts @@ -28,7 +28,12 @@ const querySortSchema = z.object({ order: z.enum(['asc', 'desc']), }) +const headerSchema = z.object({ + 'user-agent': z.string(), +}) + export { + headerSchema, idJSONSchema, personJSONSchema, postJSONSchema, diff --git a/packages/standard-validator/src/index.test.ts b/packages/standard-validator/src/index.test.ts index 9f1afe82..fba5bb4a 100644 --- a/packages/standard-validator/src/index.test.ts +++ b/packages/standard-validator/src/index.test.ts @@ -352,6 +352,47 @@ describe('Standard Schema Validation', () => { > }) }) + + describe('Sensitive Data Removal', () => { + it("doesn't return cookies after headers validation", async () => { + const app = new Hono() + + const schema = schemas.headerSchema + + app.get('/headers', sValidator('header', schema), (c) => + c.json({ success: true, userAgent: c.req.header('User-Agent') }) + ) + + const req = new Request('http://localhost/headers', { + headers: { + // Not passing the User-Agent header to trigger the validation error + Cookie: 'SECRET=123', + }, + }) + + const res = await app.request(req) + expect(res.status).toBe(400) + const data = (await res.json()) as { success: false; error: unknown[] } + expect(data.success).toBe(false) + expect(data.error).toBeDefined() + + if (lib === 'arktype') { + expect( + (data.error as { data: Record }[]).some( + (error) => error.data && error.data.cookie + ) + ).toBe(false) + } + + if (lib === 'valibot') { + expect( + (data.error as { path: { input: Record }[] }[]).some((error) => + error.path.some((path) => path.input.cookie) + ) + ).toBe(false) + } + }) + }) }) }) }) diff --git a/packages/standard-validator/src/index.ts b/packages/standard-validator/src/index.ts index 25598790..908c72d1 100644 --- a/packages/standard-validator/src/index.ts +++ b/packages/standard-validator/src/index.ts @@ -1,6 +1,7 @@ import type { StandardSchemaV1 } from '@standard-schema/spec' import type { Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets } from 'hono' import { validator } from 'hono/validator' +import { sanitizeIssues } from './sanitize-issues' type HasUndefined = undefined extends T ? true : false type TOrPromiseOfT = T | Promise @@ -21,6 +22,67 @@ type Hook< c: Context ) => TOrPromiseOfT> +/** + * Validation middleware for libraries that support [Standard Schema](https://standardschema.dev/) specification. + * + * This middleware validates incoming request data against a provided schema + * that conforms to the Standard Schema specification. It supports validation + * of JSON bodies, headers, queries, forms, and other request targets. + * + * @param target - The request target to validate ('json', 'header', 'query', 'form', etc.) + * @param schema - A schema object conforming to Standard Schema specification + * @param hook - Optional hook function called with validation results for custom error handling + * @returns A Hono middleware handler that validates requests and makes validated data available via `c.req.valid()` + * + * @example Basic JSON validation + * ```ts + * import { z } from 'zod' + * import { sValidator } from '@hono/standard-validator' + * + * const schema = z.object({ + * name: z.string(), + * age: z.number(), + * }) + * + * app.post('/author', sValidator('json', schema), (c) => { + * const data = c.req.valid('json') + * return c.json({ + * success: true, + * message: `${data.name} is ${data.age}`, + * }) + * }) + * ``` + * + * @example With custom error handling hook + * ```ts + * app.post( + * '/post', + * sValidator('json', schema, (result, c) => { + * if (!result.success) { + * return c.text('Invalid!', 400) + * } + * }), + * (c) => { + * // Handler code + * } + * ) + * ``` + * + * @example Header validation + * ```ts + * import { object, string } from 'valibot' + * + * const schema = object({ + * 'content-type': string(), + * 'user-agent': string(), + * }) + * + * app.post('/author', sValidator('header', schema), (c) => { + * const headers = c.req.valid('header') + * // do something with headers + * }) + * ``` + */ const sValidator = < Schema extends StandardSchemaV1, Target extends keyof ValidationTargets, @@ -71,7 +133,9 @@ const sValidator = < } if (result.issues) { - return c.json({ data: value, error: result.issues, success: false }, 400) + const processedIssues = sanitizeIssues(result.issues, schema['~standard'].vendor, target) + + return c.json({ data: value, error: processedIssues, success: false }, 400) } return result.value as StandardSchemaV1.InferOutput diff --git a/packages/standard-validator/src/sanitize-issues.ts b/packages/standard-validator/src/sanitize-issues.ts new file mode 100644 index 00000000..75c8f88c --- /dev/null +++ b/packages/standard-validator/src/sanitize-issues.ts @@ -0,0 +1,95 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec' +import type { ValidationTargets } from 'hono' + +const RESTRICTED_DATA_FIELDS = { + header: ['cookie'], +} + +/** + * Sanitizes validation issues by removing sensitive data fields from error messages. + * + * This function removes potentially sensitive information (like cookies) from validation + * error messages before they are returned to the client. It handles different validation + * library formats based on the vendor string. + * + * @param issues - Array of validation issues from Standard Schema validation + * @param vendor - The validation library vendor identifier (e.g., 'arktype', 'valibot') + * @param target - The validation target being processed ('header', 'json', etc.) + * @returns Sanitized array of validation issues with sensitive data removed + * + * @example + * ```ts + * const issues = [{ message: 'Invalid header', data: { cookie: 'secret' } }] + * const sanitized = sanitizeIssues(issues, 'arktype', 'header') + * // Returns issues with cookie field removed from data + * ``` + */ +export function sanitizeIssues( + issues: readonly StandardSchemaV1.Issue[], + vendor: string, + target: keyof ValidationTargets +): readonly StandardSchemaV1.Issue[] { + if (!(target in RESTRICTED_DATA_FIELDS)) { + return issues + } + + const restrictedFields = + RESTRICTED_DATA_FIELDS[target as keyof typeof RESTRICTED_DATA_FIELDS] || [] + + if (vendor === 'arktype') { + return sanitizeArktypeIssues(issues, restrictedFields) + } + + if (vendor === 'valibot') { + return sanitizeValibotIssues(issues, restrictedFields) + } + + return issues +} + +function sanitizeArktypeIssues( + issues: readonly StandardSchemaV1.Issue[], + restrictedFields: string[] +): readonly StandardSchemaV1.Issue[] { + return issues.map((issue) => { + if ( + issue && + typeof issue === 'object' && + 'data' in issue && + typeof issue.data === 'object' && + issue.data !== null && + !Array.isArray(issue.data) + ) { + const dataCopy = { ...(issue.data as Record) } + for (const field of restrictedFields) { + delete dataCopy[field] + } + return { ...issue, data: dataCopy } + } + return issue + }) as readonly StandardSchemaV1.Issue[] +} + +function sanitizeValibotIssues( + issues: readonly StandardSchemaV1.Issue[], + restrictedFields: string[] +): readonly StandardSchemaV1.Issue[] { + return issues.map((issue) => { + if (issue && typeof issue === 'object' && 'path' in issue && Array.isArray(issue.path)) { + for (const path of issue.path) { + if ( + typeof path === 'object' && + 'input' in path && + typeof path.input === 'object' && + path.input !== null && + !Array.isArray(path.input) + ) { + for (const field of restrictedFields) { + delete path.input[field] + } + } + } + } + return issue + }) as readonly StandardSchemaV1.Issue[] +}