Skip to content

Stop double-encoding %2F in match.pathname - #15398

Open
MahinAnowar wants to merge 2 commits into
remix-run:mainfrom
MahinAnowar:fix/match-pathname-encoded-slash
Open

Stop double-encoding %2F in match.pathname#15398
MahinAnowar wants to merge 2 commits into
remix-run:mainfrom
MahinAnowar:fix/match-pathname-encoded-slash

Conversation

@MahinAnowar

Copy link
Copy Markdown
Contributor

Description

useRoutesImpl re-encodes the pathname that matchRoutes decoded, and it did that by replacing every % with %25. But matchRoutes decodes a segment at a time through decodePath, which escapes any / that decoding produced back to %2F so it doesn't turn into a segment boundary:

value.split("/").map((v) => decodeURIComponent(v).replace(/\//g, "%2F")).join("/")

Those % characters get re-encoded along with everything else, so a URL like /abc%2Fxyz/child comes out as /abc%252Fxyz/child in <Form> action attributes and <Link> hrefs.

The decoded pathname can't be re-encoded on its own. A %2F in it may have come from %2F in the URL (an escaped slash) or from %252F (a literal "%2F"), and both decode to the same string. I checked both of the fixes suggested on the issue against the four shapes and each one trades one bug for another:

URL on main drop the % replacement % replacement, skipping %2F this PR
/abc%2Fxyz/child %252F
/a%25b/child /a%b
/a%252Fb/child %2F
/2%25%200/child

Dropping the % replacement regresses #14510, which is what added it in #14786.

Decoding preserves the number of segments though — decodePath splits on / and joins on / — so the decoded match pathname can be mapped back onto the encoded pathname by segment index. That avoids re-encoding entirely, and it's the same approach used about twenty lines above to strip the parent base, for the same reason:

Determine the remaining pathname by removing the # of URL segments the parentPathnameBase has, instead of removing based on character count. This is because we can't guarantee that incoming/outgoing encodings/decodings will match exactly.

%25 and %252F keep working because their encoded segments are now taken verbatim rather than reconstructed.

Related Issue

Fixes #14619

There's an earlier PR for this issue, #14621, which has been conflicting since June and takes a different approach (a safeEncode in generatePath that skips already-encoded sequences). I went at it from the match.pathname side that @MicheleBertoli identified in the issue thread — the bisect to #14786 in that comment is what pointed me at the right line. Happy to close this if that one gets picked back up.

Worth noting the originally reported symptom (params coming out as 2%%200%20g%20-%202) no longer reproduces on main — params decode correctly now. What's left is the match.pathname shape from the later comment.

Motivation and Context

%2F in a path segment is how a value containing a slash gets through a URL, which is common for base64 tokens and encoded IDs. Right now any route matching such a segment renders a <Form> whose action points at a different URL than the one the user is on, so the POST 404s or hits the wrong route.

How Has This Been Tested?

Added handles encoded slashes in ancestor splat route segments to special-characters-test.tsx, next to the existing handles encoded percent signs test it mirrors. It asserts the <Link> href stays /parent/child/slash-%2F-sign/grandchild and the param decodes to slash-/-sign. Reverting just hooks.tsx and keeping the test fails with %252F.

I also ran the four-shape table above as a temporary SSR test using createStaticHandler + renderToStaticMarkup (the repro from the issue) before and after, which is where the comparison numbers come from.

Full package suite: 2208 passing, 824 snapshots, up one test from the 2207 on main. Two suites (rsc/server-test.ts, dom/dom-export-test.tsx) fail identically before and after on a clean checkout here — they resolve build output that isn't present without a build. tsc --noEmit, eslint and scripts/changes/validate.ts are clean.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist:

  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have added tests to cover my changes.
  • I have signed the CLA (already in contributors.yml).

`useRoutesImpl` re-encodes the pathname that `matchRoutes` decoded, and did so
by replacing every `%` with `%25`. But `matchRoutes` decodes a segment at a
time via `decodePath`, which escapes any `/` that decoding produced back to
`%2F` so it does not become a segment boundary. Those `%` characters were then
re-encoded too, so a URL like `/abc%2Fxyz/child` resolved to
`/abc%252Fxyz/child` in `<Form>` actions and `<Link>` hrefs.

The decoded pathname cannot be re-encoded on its own: a `%2F` in it may have
come from `%2F` (an escaped slash) or from `%252F` (a literal "%2F"), and both
decode to the same string. Dropping the `%` replacement fixes the first and
breaks `%25`; keeping it does the reverse.

Decoding preserves the number of segments, so map the decoded match pathname
back onto the encoded pathname by segment index instead. That is the same trick
used just above to strip the parent base, and it keeps `%25` and `%252F` intact
because their encoded segments are taken verbatim.

Fixes remix-run#14619
@github-actions

Copy link
Copy Markdown
Contributor

✅ CLA Signed

Thanks for signing the Contributor License Agreement.

@github-actions

Copy link
Copy Markdown
Contributor

✅ Change File Found

One or more change files found.

Type Change
patch Fix double-encoding of %2F in match.pathname/match.pathnameBase, which produced %252F in resolved paths such as <Form> action attributes and <Link> hrefs

Resolves a conflict in useRoutesImpl: main restructured the matches
computation into an if/else that adds a
dataRouterOpts.router.match(state.location) branch. Kept that structure
verbatim and left the toEncodedPathname helper ahead of it, so the
re-encoding fix applies to every branch as before.
@MahinAnowar

Copy link
Copy Markdown
Contributor Author

Merged main in — this had gone conflicted, which meant CI wasn't running on it at all.

The conflict was in useRoutesImpl, where main reworked the matches computation into an if/else and added the dataRouterOpts.router.match(state.location) branch. I kept that structure exactly as it is on main and left the toEncodedPathname helper sitting above it, so the re-encoding applies to every branch the same way it did before. Diff against main is still just the three original files.

special-characters-test.tsx passes 31/31 locally including the new case, useRoutes-test.tsx is green, and tsc on the package is clean.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Route params are incorrectly percent-encoded/decoded when containing spaces and percent signs

2 participants