From 86fde1c1a5e7858150291087095f04e2bc21e9c5 Mon Sep 17 00:00:00 2001 From: Tem Revil Date: Sat, 11 Jul 2026 03:55:12 +0300 Subject: [PATCH] fix(router-core): safely format Standard Schema validation issues Standard Schema validators can return issue objects that JSON.stringify cannot serialize, for example when a path segment is a symbol or the issue holds a circular reference. Both the server-function validator in start-client-core and router search validation stringified the raw issues array, so a value that could not be serialized threw and buried the real validation failure. Add a shared internal formatter in router-core that only reads each issue's message and path, so it never throws on unserializable values. Paths are rendered with bracketed, quoted segments (for example ["user"]["name"][0]) so a literal key such as "a.b" cannot be confused with a nested path, and a prototype-named key such as "__proto__" is only ever printed as text, never used to index into an object. Root issues render as (root). Wire the formatter into both call sites and cover root issues, nested paths, numeric keys, symbol and prototype-named keys, and a circular issue object with unit tests. --- packages/router-core/src/index.ts | 2 + packages/router-core/src/router.ts | 3 +- packages/router-core/src/validationError.ts | 63 ++++++++++++++ packages/router-core/src/validators.ts | 3 + .../router-core/tests/validationError.test.ts | 87 +++++++++++++++++++ .../start-client-core/src/createServerFn.ts | 9 +- 6 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 packages/router-core/src/validationError.ts create mode 100644 packages/router-core/tests/validationError.test.ts diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index fd673ca410..555e69bb55 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -388,6 +388,8 @@ export type { ResolveValidatorOutput, } from './validators' +export { formatValidationError } from './validationError' + export type { UseRouteContextBaseOptions, UseRouteContextOptions, diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 2197dab737..a251d70226 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -104,6 +104,7 @@ import type { ManifestRouteAssets, RouterManagedTag, } from './manifest' +import { formatValidationError } from './validationError' import type { AnySchema, AnyValidator } from './validators' import type { NavigateOptions, ResolveRelativePath, ToOptions } from './link' import type { NotFoundError } from './not-found' @@ -3078,7 +3079,7 @@ function validateSearch(validateSearch: AnyValidator, input: unknown): unknown { throw new SearchParamError('Async validation not supported') if (result.issues) - throw new SearchParamError(JSON.stringify(result.issues, undefined, 2), { + throw new SearchParamError(formatValidationError(result.issues), { cause: result, }) diff --git a/packages/router-core/src/validationError.ts b/packages/router-core/src/validationError.ts new file mode 100644 index 0000000000..c5132ce18f --- /dev/null +++ b/packages/router-core/src/validationError.ts @@ -0,0 +1,63 @@ +import type { AnyStandardSchemaValidateIssue } from './validators' + +type IssuePathSegment = PropertyKey | { readonly key: PropertyKey } + +function formatIssuePathSegment(segment: IssuePathSegment): string { + const key = + typeof segment === 'object' && segment !== null && 'key' in segment + ? segment.key + : segment + + if (typeof key === 'number') { + return `[${key}]` + } + if (typeof key === 'symbol') { + return `[${key.toString()}]` + } + // String keys are always bracketed and quoted. This keeps the path + // unambiguous when a key itself contains "." or "[" (so a literal key + // "a.b" can't be confused with the nested path a -> b), and it means a + // key such as "__proto__" is only ever rendered as text, never used to + // index into an object. + return `[${JSON.stringify(key)}]` +} + +function formatIssuePath( + path: ReadonlyArray | undefined, +): string { + if (!path || path.length === 0) { + return '(root)' + } + return path.map(formatIssuePathSegment).join('') +} + +/** + * Format Standard Schema validation issues into a readable string. + * + * Issue objects can hold values that `JSON.stringify` cannot serialize + * (symbols in paths, circular references), in which case stringifying them + * throws and hides the original validation failure. This walks the issues + * defensively and only ever reads the message and path, so the real + * validation errors survive. + * + * Intended for internal use across router-core and start-client-core; it is + * not part of the documented public API. + */ +export function formatValidationError( + issues: ReadonlyArray | undefined, +): string { + if (!issues || issues.length === 0) { + return 'Validation failed' + } + + return issues + .map((issue) => { + const path = formatIssuePath(issue.path) + const message = + typeof issue.message === 'string' + ? issue.message + : String(issue.message) + return `${path}: ${message}` + }) + .join('\n') +} diff --git a/packages/router-core/src/validators.ts b/packages/router-core/src/validators.ts index 9173080077..cde6227668 100644 --- a/packages/router-core/src/validators.ts +++ b/packages/router-core/src/validators.ts @@ -27,6 +27,9 @@ export interface AnyStandardSchemaValidateFailure { export interface AnyStandardSchemaValidateIssue { readonly message: string + readonly path?: + | ReadonlyArray + | undefined } export interface AnyStandardSchemaValidateInput { diff --git a/packages/router-core/tests/validationError.test.ts b/packages/router-core/tests/validationError.test.ts new file mode 100644 index 0000000000..6b2922a38e --- /dev/null +++ b/packages/router-core/tests/validationError.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'vitest' +import { formatValidationError } from '../src/validationError' +import type { AnyStandardSchemaValidateIssue } from '../src/validators' + +describe('formatValidationError', () => { + test('falls back when there are no issues', () => { + expect(formatValidationError([])).toBe('Validation failed') + expect(formatValidationError(undefined)).toBe('Validation failed') + }) + + test('renders a root issue without a path', () => { + const issues: Array = [ + { message: 'Required' }, + ] + expect(formatValidationError(issues)).toBe('(root): Required') + }) + + test('renders a nested string path', () => { + const issues: Array = [ + { message: 'Expected string', path: ['user', 'name'] }, + ] + expect(formatValidationError(issues)).toBe( + '["user"]["name"]: Expected string', + ) + }) + + test('renders numeric (array index) path segments', () => { + const issues: Array = [ + { message: 'Expected number', path: ['scores', 0] }, + ] + expect(formatValidationError(issues)).toBe('["scores"][0]: Expected number') + }) + + test('reads path segments given as { key } objects', () => { + const issues: Array = [ + { message: 'Invalid', path: [{ key: 'a' }, { key: 1 }] }, + ] + expect(formatValidationError(issues)).toBe('["a"][1]: Invalid') + }) + + test('does not confuse a literal dotted key with a nested path', () => { + const flat: Array = [ + { message: 'x', path: ['a.b'] }, + ] + const nested: Array = [ + { message: 'x', path: ['a', 'b'] }, + ] + expect(formatValidationError(flat)).not.toBe(formatValidationError(nested)) + expect(formatValidationError(flat)).toBe('["a.b"]: x') + }) + + test('renders a prototype-named key as text without indexing into objects', () => { + const issues: Array = [ + { message: 'nope', path: ['__proto__', 'polluted'] }, + ] + expect(formatValidationError(issues)).toBe( + '["__proto__"]["polluted"]: nope', + ) + }) + + test('renders symbol path segments instead of throwing', () => { + const sym = Symbol('id') + const issues: Array = [ + { message: 'bad symbol key', path: [sym] }, + ] + expect(formatValidationError(issues)).toBe( + `[${sym.toString()}]: bad symbol key`, + ) + }) + + test('joins multiple issues on separate lines', () => { + const issues: Array = [ + { message: 'Required', path: ['a'] }, + { message: 'Too short', path: ['b', 2] }, + ] + expect(formatValidationError(issues)).toBe( + '["a"]: Required\n["b"][2]: Too short', + ) + }) + + test('does not throw on a circular issue object', () => { + const circular: any = { message: 'circular' } + circular.self = circular + expect(() => formatValidationError([circular])).not.toThrow() + expect(formatValidationError([circular])).toBe('(root): circular') + }) +}) diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index bfa48039cf..e16339bb42 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -1,6 +1,10 @@ import { mergeHeaders } from '@tanstack/router-core/ssr/client' -import { isRedirect, parseRedirect } from '@tanstack/router-core' +import { + formatValidationError, + isRedirect, + parseRedirect, +} from '@tanstack/router-core' import { TSS_SERVER_FUNCTION_FACTORY } from './constants' import { getStartOptions } from './getStartOptions' import { getStartContextServerOnly } from './getStartContextServerOnly' @@ -897,8 +901,7 @@ export async function execValidator( if ('~standard' in validator) { const result = await validator['~standard'].validate(input) - if (result.issues) - throw new Error(JSON.stringify(result.issues, undefined, 2)) + if (result.issues) throw new Error(formatValidationError(result.issues)) return result.value }