Skip to content

defaultParseSearch corrupts string search params that look like JSON numbers (e.g. "662E41", large integers) — lossy and unrecoverable on inbound URLs #7650

Description

@claudioquaglia

Which project does this relate to?

Router

Describe the bug

defaultParseSearch runs JSON.parse on every search-param value and keeps the
result whenever it parses to a number. This destructively coerces values that are
opaque strings
, not numbers — authorization codes, signatures/HMACs, hex IDs,
all-digit ULIDs, etc. — as long as they match the JSON number grammar.

Two consequences:

  1. Lossy / irreversible. 662E41 (a valid 6-char hex/auth code) → 6.62e+43;
    723421968459640832723421968459640800. The original string cannot be
    reconstructed.
  2. Unrecoverable for inbound/external URLs. When the value arrives from an
    external redirect (OAuth callback, payment-gateway return, …), the coercion
    happens inside parseSearch before validateSearch runs. By the time a Zod
    schema sees it, it is already a lossy number — z.coerce.string() / z.string()
    cannot recover the original, they only see 6.62e+43.

This is the data-corruption root cause underneath the quote-wrapping symptom in
#6044 and the string/number typing confusion in #537 / discussion #430, but the
failure mode here is destruction of non-numeric data, not formatting.

Complete minimal reproducer

https://stackblitz.com/edit/github-hjjlagts?file=src%2Froutes%2Findex.tsx

Steps to Reproduce the Bug

import { defaultParseSearch } from '@tanstack/router-core' // re-exported by @tanstack/react-router

defaultParseSearch('?codAut=662E41')
// actual:   { codAut: 6.62e+43 }
// expected: { codAut: "662E41" }

defaultParseSearch('?id=723421968459640832')
// actual:   { id: 723421968459640800 }   // precision loss
// expected: { id: "723421968459640832" }

A route with validateSearch: z.object({ codAut: z.string() }) cannot guard an
inbound value: it's already a number before Zod runs.

Expected behavior

A param value that does not survive a numeric round-trip should be kept as the
original string. Canonical numbers / booleans / objects should still be coerced.

Screenshots or Videos

No response

Platform

  • Router / Start Version: @tanstack/react-router 1.168.25 (router-core 1.168.17) · @tanstack/react-start 1.167.50
  • OS: macOS 26.5 (Darwin 25.5.0)
  • Browser: Chrome
  • Browser Version: 149.0.0
  • Bundler: vite
  • Bundler Version: 7.3.2

Additional context

Root cause
qss's toValue already guards against lossy coercion:

// qss.ts — toValue
return +str * 0 === 0 && +str + "" === str ? +str : str; // keep string if it doesn't round-trip

…but parseSearchWith(JSON.parse) re-applies JSON.parse to the strings toValue
deliberately left alone, defeating that guard:

// searchParams.ts — parseSearchWith
const query = decode(searchStr);          // toValue kept "662E41" as a string
for (const key in query) {
  if (typeof query[key] === 'string')
    query[key] = JSON.parse(query[key]);   // => 6.62e+43 (re-introduces precision loss)
}

Proposed fix
Apply the same round-trip guard in the default parser — accept JSON.parse's
result only when it isn't a number, or when String(result) === input; otherwise
keep the original string:

const parsed = JSON.parse(value)
return typeof parsed === 'number' && String(parsed) !== value ? value : parsed

Preserves coercion for "2", "true", "0.5", {"a":1}, …, while keeping
662E41, large integers, leading-zero strings and other non-round-tripping values
intact. Happy to open a PR if the team agrees with the direction.

Real-world impact
A payment-gateway return URL echoes an auth code (codAut) the backend uses to
recompute an HMAC. Coercing 662E41 → "6.62e+43" corrupts the field, the
signature no longer matches, and the capture is rejected (HTTP 403) even though the
payment succeeded.

Related
#6044 — quote-wrapping (downstream symptom of the same coercion)
#537 — string/number coercion typing
Discussion #430

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions