From 55c0e724e76894beb80dfc82be7fdbb8e1c71d42 Mon Sep 17 00:00:00 2001 From: Franco Kaddour Date: Tue, 11 Aug 2026 22:17:30 +0000 Subject: [PATCH] fix(router-core): keep percent-encoded caret in paths to prevent redirect loop The WHATWG URL path percent-encode set includes U+005E (^), but the PATH_UNSAFE_RE set introduced in #7695 did not. decodeURI decodes %5E to a literal caret which nothing re-encodes, while browsers and the WHATWG URL parser (new URL) always re-encode it -- so the canonical publicHref built from the decoded pathname never matches the incoming raw href and the server canonicalization redirect fires on every request: GET /%5E -> 307 /^ -> browser re-encodes -> GET /%5E, forever (ERR_TOO_MANY_REDIRECTS). Adding ^ to the preserved set makes the decode/encode round-trip stable, matching the behavior of the other path percent-encode set characters. Fixes #8041. --- .changeset/caret-path-percent-encode.md | 5 +++++ packages/router-core/src/utils.ts | 4 ++-- packages/router-core/tests/utils.test.ts | 8 ++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 .changeset/caret-path-percent-encode.md diff --git a/.changeset/caret-path-percent-encode.md b/.changeset/caret-path-percent-encode.md new file mode 100644 index 0000000000..604f038519 --- /dev/null +++ b/.changeset/caret-path-percent-encode.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +preserve percent-encoded caret (U+005E) in paths to prevent an infinite redirect loop diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index e0665dde69..1cd184601d 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -532,7 +532,7 @@ export function findLast( /** * Re-encode characters that are unsafe in URL paths. * Includes ASCII control characters (0x00-0x1F, 0x7F) and a subset of the - * WHATWG URL "path percent-encode set" (", <, >, `, {, }). + * WHATWG URL "path percent-encode set" (", <, >, ^, `, {, }). * * Space (0x20) is intentionally excluded — decodeURI decodes %20 to space * and the router stores decoded spaces in location.pathname. The existing @@ -543,7 +543,7 @@ export function findLast( * interpret the URL, preventing infinite redirect loops and path mismatches. */ // eslint-disable-next-line no-control-regex -const PATH_UNSAFE_RE = /[\x00-\x1f\x7f"<>`{}]/g +const PATH_UNSAFE_RE = /[\x00-\x1f\x7f"<>^`{}]/g function sanitizePathSegment(segment: string): string { return segment.replace( diff --git a/packages/router-core/tests/utils.test.ts b/packages/router-core/tests/utils.test.ts index 9e5c14c413..d7ff34eee6 100644 --- a/packages/router-core/tests/utils.test.ts +++ b/packages/router-core/tests/utils.test.ts @@ -626,6 +626,14 @@ describe('decodePath', () => { expect(decodePath('/back%60tick').path).toBe('/back%60tick') }) + it('should keep caret encoded', () => { + expect(decodePath('/1000-1500%5EABC').path).toBe('/1000-1500%5EABC') + }) + + it('should normalize lowercase caret encoding to uppercase', () => { + expect(decodePath('/1000-1500%5eABC').path).toBe('/1000-1500%5EABC') + }) + it('should decode space (handled by encodePathLikeUrl for outgoing URLs)', () => { expect(decodePath('/file%20name').path).toBe('/file name') })