diff --git a/packages/router-core/src/searchParams.ts b/packages/router-core/src/searchParams.ts index 740d36441c..cc299f12c3 100644 --- a/packages/router-core/src/searchParams.ts +++ b/packages/router-core/src/searchParams.ts @@ -15,11 +15,23 @@ export const defaultStringifySearch = stringifySearchWith( * The returned function strips a leading `?`, decodes values, and attempts to * JSON-parse string values using the given `parser`. * + * To prevent lossy coercion of opaque strings (hex codes, large integers, + * scientific-notation numbers, etc.) the parsed value is only accepted when + * re-serializing it with `stringify` reproduces the original string. + * This rejects conversions like `'662E41'` → `6.62e+43` and + * `'723421968459640832'` → `723421968459640800`. + * * @param parser Function to parse a string value (e.g. `JSON.parse`). + * @param stringify Function to re-serialize the parsed value for the + * roundtrip check. Defaults to `JSON.stringify`. Pass a matching + * serializer when `parser` is something other than `JSON.parse`. * @returns A `parseSearch` function compatible with `Router` options. * @link https://tanstack.com/router/latest/docs/framework/react/guide/custom-search-param-serialization */ -export function parseSearchWith(parser: (str: string) => any) { +export function parseSearchWith( + parser: (str: string) => any, + stringify: (val: any) => string = JSON.stringify, +) { return (searchStr: string): AnySchema => { if (searchStr[0] === '?') { searchStr = searchStr.substring(1) @@ -27,12 +39,19 @@ export function parseSearchWith(parser: (str: string) => any) { const query: Record = decode(searchStr) - // Try to parse any query params that might be json + // Try to parse any query params that might be json. A roundtrip check + // guards against lossy coercion: strings like `"662E41"` or + // `"723421968459640832"` parse to valid JSON values, but the original + // string cannot be recovered once the parsed number/string has been + // re-serialized. for (const key in query) { const value = query[key] if (typeof value === 'string') { try { - query[key] = parser(value) + const parsed = parser(value) + if (stringify(parsed) === value) { + query[key] = parsed + } } catch (_err) { // silent } diff --git a/packages/router-core/tests/searchParams.test.ts b/packages/router-core/tests/searchParams.test.ts index 006e119be5..4270c1e3fd 100644 --- a/packages/router-core/tests/searchParams.test.ts +++ b/packages/router-core/tests/searchParams.test.ts @@ -78,6 +78,24 @@ describe('Search Params serialization and deserialization', () => { expect(defaultStringifySearch(obj)).not.toBe(input) }) + /* + * Regression coverage for #7650: opaque strings that happen to be valid + * JSON (hex codes, large integers, scientific-notation numbers) must + * not be destructively coerced into JSON values. The default parser + * uses a roundtrip check that rejects parses whose re-serialization + * does not match the original string. + */ + test.each([ + ['?codAut=662E41', { codAut: '662E41' }], + ['?id=723421968459640832', { id: '723421968459640832' }], + ['?sig=abcdef0123456789', { sig: 'abcdef0123456789' }], + ['?ulid=01H8XGJWBWBAQ4ZEXAMPLE0000', { + ulid: '01H8XGJWBWBAQ4ZEXAMPLE0000', + }], + ])('opaque string roundtrip %s', (input, expected) => { + expect(defaultParseSearch(input)).toEqual(expected) + }) + /* * It can serialize stuff that really shouldn't be passed as input. * But just in case, this test serves as documentation of "what would happen"