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') })