From 74b0424e09b328e9f74982b5bab50b3b63f9da58 Mon Sep 17 00:00:00 2001 From: Yarchik Date: Wed, 10 Jun 2026 16:32:46 +0100 Subject: [PATCH] fix(router-core): encode URL-unsafe ASCII chars in encodePathLikeUrl to break infinite redirect on ` `<` (and the rest of the WHATWG URL "path percent-encode set": `<`, `>`, `"`, `` ` ``, `{`, `}`), but `encodePathLikeUrl` only encoded whitespace and non-ASCII characters. The pair therefore failed to round-trip for those characters. The SSR redirect comparator in router.ts uses `latestLocation.publicHref !== nextLocation.publicHref` as the signal that the URL was rewritten and needs a redirect. For requests like `/`, `"`, `` ` ``, `{`, `}`). `encodePathLikeUrl` now percent-encodes the WHATWG URL "path percent-encode set" so it round-trips with `decodePath` and the SSR redirect comparator no longer sees the URL as having changed. diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index f017215b5c..8baa685e43 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -675,14 +675,24 @@ export function decodePath(path: string) { * encodePathLikeUrl('/path/already%20encoded') // '/path/already%20encoded' (preserved) */ export function encodePathLikeUrl(path: string): string { - // Encode whitespace and non-ASCII characters that browsers encode in URLs + // Encode whitespace, non-ASCII characters, and the URL-unsafe ASCII chars + // in the WHATWG URL "path percent-encode set" (`< > " ` { }`). Browsers + // always percent-encode these when they appear in a path, and `decodePath` + // turns their `%XX` forms into the literal characters - leaving them + // un-encoded here causes encode/decode to fail to round-trip, which the + // SSR redirect comparator interprets as a URL change and triggers an + // infinite redirect loop for inputs like `/"`{}]|[^\u0000-\u007F]/.test(path)) return path // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ASCII range check // eslint-disable-next-line no-control-regex - return path.replace(/\s|[^\u0000-\u007F]/gu, encodeURIComponent) + return path.replace(/[\s<>"`{}]|[^\u0000-\u007F]/gu, encodeURIComponent) } /** diff --git a/packages/router-core/tests/utils.test.ts b/packages/router-core/tests/utils.test.ts index 95874f230e..aed86e85a6 100644 --- a/packages/router-core/tests/utils.test.ts +++ b/packages/router-core/tests/utils.test.ts @@ -1019,4 +1019,46 @@ describe('encodePathLikeUrl', () => { '/path/%F0%9F%98%80/file', ) }) + + // https://github.com/TanStack/router/issues/7587 — URL-unsafe ASCII characters + // were being preserved by encodePathLikeUrl, but decodePath decoded their + // percent-encoded forms (e.g. %3C -> <). The encode/decode pair therefore + // failed to round-trip, which the SSR redirect comparator in the router used + // as a signal that the URL had changed — driving an infinite redirect loop + // on requests like `/ { + it('encodes < > " ` { } in path segments', () => { + expect(encodePathLikeUrl('/')).toBe('/test%3E') + expect(encodePathLikeUrl('/foo"bar')).toBe('/foo%22bar') + expect(encodePathLikeUrl('/back`tick')).toBe('/back%60tick') + expect(encodePathLikeUrl('/curly{open}close')).toBe( + '/curly%7Bopen%7Dclose', + ) + }) + + it('round-trips with decodePath for unsafe ASCII chars', () => { + // The whole point of the fix: encode and decode are inverses. + const cases = ['/%3Ctest', '/path/%3C%3Estuff', '/%22quoted%22'] + for (const raw of cases) { + expect(encodePathLikeUrl(decodePath(raw).path)).toBe(raw) + } + }) + + it('does not double-encode already-percent-encoded sequences', () => { + // encodeURIComponent of a `%` would yield `%25`, but the input string + // does not actually contain a `%` character we want to encode — the + // sequence `%3C` is three ASCII letters that pass through unchanged. + expect(encodePathLikeUrl('/%3Ctest')).toBe('/%3Ctest') + expect(encodePathLikeUrl('/already%20encoded')).toBe('/already%20encoded') + }) + + it('preserves URL component separators (?, #, /) so search and hash survive', () => { + expect(encodePathLikeUrl('/path?query=1')).toBe('/path?query=1') + expect(encodePathLikeUrl('/path#anchor')).toBe('/path#anchor') + expect(encodePathLikeUrl('/a/b/c')).toBe('/a/b/c') + expect(encodePathLikeUrl('/path/?q=1#h')).toBe('/path/%3Cx%3E?q=1#h') + }) + }) })