Skip to content

fix(router-core): preserve percent-encoded URL-unsafe chars in decodeSegment#7695

Draft
CDillinger wants to merge 1 commit into
TanStack:mainfrom
CDillinger:fix/decode-path-preserve-unsafe-chars
Draft

fix(router-core): preserve percent-encoded URL-unsafe chars in decodeSegment#7695
CDillinger wants to merge 1 commit into
TanStack:mainfrom
CDillinger:fix/decode-path-preserve-unsafe-chars

Conversation

@CDillinger

Copy link
Copy Markdown

What

decodeSegment (called by decodePath) previously used decodeURI() which decoded all percent-encoded characters — including those that are unsafe in URL paths per the WHATWG spec. This caused the router's internal path representation to differ from the raw request URL, which the SSR redirect comparator interpreted as a URL change, triggering infinite 307 redirect loops.

This PR replaces the decodeURI()-based approach with per-character decoding that preserves:

  • ASCII control characters (0x00-0x1F, 0x7F)
  • The WHATWG URL "path percent-encode set": space, ", <, >, `, {, }

Reproduction

Any TanStack Start app with a path param route will infinite-loop on URLs containing encoded curly braces, angle brackets, etc:

http://localhost:3000/some-route/%7B%7Btemplate%7D%7D

Why this approach

The previous implementation decoded everything in decodeSegment and then tried to fix problems after the fact (sanitizePathSegment stripped control chars, encodePathLikeUrl was supposed to re-encode). This "decode then patch" approach is fragile — any character missed by the downstream fixups creates a mismatch.

The cleaner fix is to not decode these characters in the first place. The router still decodes all "safe" characters (unicode, regular ASCII letters/symbols) so route matching and param extraction work as expected.

sanitizePathSegment is no longer needed since control characters are never decoded. The protocol-relative URL defense (// collapsing) is kept as defense-in-depth.

Fixes #7587.

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 18d68507-af59-4323-90bb-443f3da5b66e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@nx-cloud

nx-cloud Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

View your CI Pipeline Execution ↗ for commit dd05e5d

Command Status Duration Result
nx affected --targets=test:eslint,test:unit,tes... ❌ Failed 13m 10s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 2m 2s View ↗

☁️ Nx Cloud last updated this comment at 2026-06-26 16:57:23 UTC

@pkg-pr-new

pkg-pr-new Bot commented Jun 25, 2026

Copy link
Copy Markdown
More templates

@tanstack/arktype-adapter

npm i https://pkg.pr.new/@tanstack/arktype-adapter@7695

@tanstack/eslint-plugin-router

npm i https://pkg.pr.new/@tanstack/eslint-plugin-router@7695

@tanstack/eslint-plugin-start

npm i https://pkg.pr.new/@tanstack/eslint-plugin-start@7695

@tanstack/history

npm i https://pkg.pr.new/@tanstack/history@7695

@tanstack/nitro-v2-vite-plugin

npm i https://pkg.pr.new/@tanstack/nitro-v2-vite-plugin@7695

@tanstack/react-router

npm i https://pkg.pr.new/@tanstack/react-router@7695

@tanstack/react-router-devtools

npm i https://pkg.pr.new/@tanstack/react-router-devtools@7695

@tanstack/react-router-ssr-query

npm i https://pkg.pr.new/@tanstack/react-router-ssr-query@7695

@tanstack/react-start

npm i https://pkg.pr.new/@tanstack/react-start@7695

@tanstack/react-start-client

npm i https://pkg.pr.new/@tanstack/react-start-client@7695

@tanstack/react-start-rsc

npm i https://pkg.pr.new/@tanstack/react-start-rsc@7695

@tanstack/react-start-server

npm i https://pkg.pr.new/@tanstack/react-start-server@7695

@tanstack/router-cli

npm i https://pkg.pr.new/@tanstack/router-cli@7695

@tanstack/router-core

npm i https://pkg.pr.new/@tanstack/router-core@7695

@tanstack/router-devtools

npm i https://pkg.pr.new/@tanstack/router-devtools@7695

@tanstack/router-devtools-core

npm i https://pkg.pr.new/@tanstack/router-devtools-core@7695

@tanstack/router-generator

npm i https://pkg.pr.new/@tanstack/router-generator@7695

@tanstack/router-plugin

npm i https://pkg.pr.new/@tanstack/router-plugin@7695

@tanstack/router-ssr-query-core

npm i https://pkg.pr.new/@tanstack/router-ssr-query-core@7695

@tanstack/router-utils

npm i https://pkg.pr.new/@tanstack/router-utils@7695

@tanstack/router-vite-plugin

npm i https://pkg.pr.new/@tanstack/router-vite-plugin@7695

@tanstack/solid-router

npm i https://pkg.pr.new/@tanstack/solid-router@7695

@tanstack/solid-router-devtools

npm i https://pkg.pr.new/@tanstack/solid-router-devtools@7695

@tanstack/solid-router-ssr-query

npm i https://pkg.pr.new/@tanstack/solid-router-ssr-query@7695

@tanstack/solid-start

npm i https://pkg.pr.new/@tanstack/solid-start@7695

@tanstack/solid-start-client

npm i https://pkg.pr.new/@tanstack/solid-start-client@7695

@tanstack/solid-start-server

npm i https://pkg.pr.new/@tanstack/solid-start-server@7695

@tanstack/start-client-core

npm i https://pkg.pr.new/@tanstack/start-client-core@7695

@tanstack/start-fn-stubs

npm i https://pkg.pr.new/@tanstack/start-fn-stubs@7695

@tanstack/start-plugin-core

npm i https://pkg.pr.new/@tanstack/start-plugin-core@7695

@tanstack/start-server-core

npm i https://pkg.pr.new/@tanstack/start-server-core@7695

@tanstack/start-static-server-functions

npm i https://pkg.pr.new/@tanstack/start-static-server-functions@7695

@tanstack/start-storage-context

npm i https://pkg.pr.new/@tanstack/start-storage-context@7695

@tanstack/valibot-adapter

npm i https://pkg.pr.new/@tanstack/valibot-adapter@7695

@tanstack/virtual-file-routes

npm i https://pkg.pr.new/@tanstack/virtual-file-routes@7695

@tanstack/vue-router

npm i https://pkg.pr.new/@tanstack/vue-router@7695

@tanstack/vue-router-devtools

npm i https://pkg.pr.new/@tanstack/vue-router-devtools@7695

@tanstack/vue-router-ssr-query

npm i https://pkg.pr.new/@tanstack/vue-router-ssr-query@7695

@tanstack/vue-start

npm i https://pkg.pr.new/@tanstack/vue-start@7695

@tanstack/vue-start-client

npm i https://pkg.pr.new/@tanstack/vue-start-client@7695

@tanstack/vue-start-server

npm i https://pkg.pr.new/@tanstack/vue-start-server@7695

@tanstack/zod-adapter

npm i https://pkg.pr.new/@tanstack/zod-adapter@7695

commit: dd05e5d

@codspeed-hq

codspeed-hq Bot commented Jun 25, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 11.41%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 3 improved benchmarks
❌ 1 regressed benchmark
✅ 140 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Memory mem streaming-peak chunked (vue) 11.9 MB 12.3 MB -3.2%
Memory mem aborted-requests (solid) 2.4 MB 1.7 MB +35.53%
Memory mem aborted-requests (vue) 1,021 KB 900.1 KB +13.43%
Memory mem peak-large-page (solid) 3.9 MB 3.7 MB +3.54%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing CDillinger:fix/decode-path-preserve-unsafe-chars (dd05e5d) with main (ba52d2b)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (bb2daa6) during the generation of this report, so ba52d2b was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

nx-cloud[bot]

This comment was marked as outdated.

@CDillinger CDillinger force-pushed the fix/decode-path-preserve-unsafe-chars branch from 782242e to 7123395 Compare June 26, 2026 02:12
@nlynzaad

nlynzaad commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

There are two issues with the current approach.

First, a single Unicode character can span multiple percent-encoded bytes. For example:

'ш' // %D1%88
'🚀' // %F0%9F%9A%80

Decoding each %XX group independently will not handle these correctly. We need to process adjacent percent-encoded bytes as a run, decode the smallest valid UTF-8 sequence, then continue with the remainder.

This also needs to work when safe and unsafe characters are adjacent. For example:

'🚀@' // %F0%9F%9A%80%40

Here, 🚀 should decode, while @ must remain %40.

decodeURI largely handled router-reserved characters for us by leaving them encoded. With decodeURIComponent, those characters are decoded, so we need to explicitly preserve any characters that are unsafe or meaningful to routing. Processing each decoded unit individually is also important to avoid URL-poisoning behaviour.

Based on the current test suite, I compiled the following exclusion set:

const PATH_KEEP_ENCODED = /^[\x00-\x1F\x7F\x20"#$%&+,/:;<=>?@`^\\{}]$/

This retains control characters, spaces, router-reserved characters, and other path-sensitive values in their encoded form. It intentionally does not exclude the full component percent-encode set, as doing so would also preserve characters such as [ , ], and |, which would be a breaking change.

The remaining test differences are expected:

  • %20 now remains encoded rather than becoming a literal space.
  • Control characters are retained as encoded values rather than dropped.

I have an update to the PR ready to handle this. Since this runs on a hot path, it would be useful for @Sheraff to review the implementation and suggest any performance refinements.

@CDillinger CDillinger force-pushed the fix/decode-path-preserve-unsafe-chars branch 2 times, most recently from 4fca828 to 00296a3 Compare June 26, 2026 02:48
nx-cloud[bot]

This comment was marked as outdated.

@Sheraff

Sheraff commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

This is the fastest I got (so far) that passes the new tests and the olds ones:

function decodeSegment(segment: string): string {
  if (segment.indexOf('%') !== -1) {
    try {
      return decodeURI(segment)
    } catch {}
  }
  return segment
}

// ...

  // Match percent-encoded bytes that `decodeURI` would expose but that must
  // stay encoded in paths: percent signs, backslashes, controls, and the
  // WHATWG path percent-encode set.
  const re = /%(?:[01][\dA-F]|2[025]|3[CE]|5C|60|7[BDF])/gi
  let cursor = 0
  let result = ''
  let match
  while (null !== (match = re.exec(path))) {
    result += decodeSegment(path.slice(cursor, match.index)) + match[0]
    cursor = re.lastIndex
  }
  if (cursor) {
    result += decodeSegment(path.slice(cursor))
    // eslint-disable-next-line no-control-regex
    if (/[\x00-\x1f\x7f]/.test(path)) {
      result = sanitizePathSegment(result)
    }
  } else {
    result = sanitizePathSegment(decodeSegment(path))
  }

But I think correctness matters more here, so @nlynzaad and @CDillinger you should make sure the tests cover what we need and to have a working version, and we can merge as soon as that is ready. I can work on perf afterwards.

BTW: it is expected for the Bundle Size, and the Labeler workflows to break on forks, but PR/Test should pass

…Segment

Replace sanitizePathSegment (which stripped control characters) with a
re-encode step that keeps WHATWG path percent-encode set characters and
control characters in their encoded form after decodeURI.

This preserves the existing decodeURI-based approach which correctly
handles multi-byte UTF-8 sequences, while fixing the mismatch between
the original request URL and the router's internal representation that
caused infinite 307 redirect loops on paths containing these characters.

Fixes TanStack#7587.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@CDillinger CDillinger force-pushed the fix/decode-path-preserve-unsafe-chars branch from 00296a3 to dd05e5d Compare June 26, 2026 16:22

@nx-cloud nx-cloud Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nx Cloud is proposing a fix for your failed CI:

We updated the open-redirect e2e test in react-router/basic-file-based to align with the new decodeSegment behavior introduced by this PR. The stale assertion (expect(url.pathname).toMatch(/^\/test-path\/?$/)) assumed the old "strip CR then collapse //" approach, but %0d is now kept encoded so the path resolves to /%0D/test-path rather than /test-path. This mirrors the identical fix already applied to the equivalent react-start/basic test in the PR.

Tip

We verified this fix by re-running tanstack-router-e2e-react-basic-file-based:test:e2e, tanstack-react-start-e2e-basic:test:e2e--rsbuild-prerender.

diff --git a/e2e/react-router/basic-file-based/tests/open-redirect-prevention.spec.ts b/e2e/react-router/basic-file-based/tests/open-redirect-prevention.spec.ts
index 3ad83fb4..2f0fe256 100644
--- a/e2e/react-router/basic-file-based/tests/open-redirect-prevention.spec.ts
+++ b/e2e/react-router/basic-file-based/tests/open-redirect-prevention.spec.ts
@@ -69,10 +69,7 @@ test.describe('Open redirect prevention', () => {
       page,
       baseURL,
     }) => {
-      // When control characters are stripped from paths like /%0d/evil.com/
-      // the result could be //evil.com/ which is a protocol-relative URL
-      // Our fix collapses these to /evil.com/ to prevent external redirects
-      // This is already tested above, but we verify the collapsed path works
+      // %0d is kept encoded, so /%0d/test-path/ stays as-is and won't become //test-path/
       await page.goto('/%0d/test-path/')
       await page.waitForLoadState('networkidle')
 
@@ -80,8 +77,6 @@ test.describe('Open redirect prevention', () => {
       expect(page.url().startsWith(baseURL!)).toBe(true)
       const url = new URL(page.url())
       expect(url.origin).toBe(new URL(baseURL!).origin)
-      // Path should be collapsed to /test-path (not //test-path/)
-      expect(url.pathname).toMatch(/^\/test-path\/?$/)
     })
   })
 

Because this branch comes from a fork, it is not possible for us to apply fixes directly, but you can apply the changes locally using the available options below.

Apply changes locally with:

npx nx-cloud apply-locally N4iC-eU6c

Apply fix locally with your editor ↗   View interactive diff ↗



🎓 Learn more about Self-Healing CI on nx.dev

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Infinite redirect loop ("ERR_TOO_MANY_REDIRECTS") caused by encoded unsafe characters in URL pathname

3 participants