Skip to content

fix: decode only percent-encoded escapes in decodeURIComponent - #25672

Open
totally-not-ai[bot] wants to merge 4 commits into
mainfrom
fix/decode-uri-component-keeps-literal-non-ascii
Open

fix: decode only percent-encoded escapes in decodeURIComponent#25672
totally-not-ai[bot] wants to merge 4 commits into
mainfrom
fix/decode-uri-component-keeps-literal-non-ascii

Conversation

@totally-not-ai

@totally-not-ai totally-not-ai Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Summary

UrlUtil.decodeURIComponent treated every non-ASCII character as a raw UTF-8 byte, even when it was never percent-encoded. Because of that, paths that already contain literal characters like ü or were corrupted into , so routes with non-ASCII segments did not match and wildcard parameters lost their text. Now only real %XX escapes are decoded and all other characters are left untouched.

What changed

Behavior change: UrlUtil.decodeURIComponent no longer rewrites non-ASCII characters that are not percent-encoded. This affects anyone who passes an already decoded (or partly decoded) string: before it came back mangled, now it comes back unchanged. Strings that only contain %XX escapes decode exactly as before, so normal encoded input is unaffected.

  • decodeURIComponent now collects consecutive %XX escapes into a byte sequence and decodes that sequence as UTF-8. Text between escapes is copied as-is, so a multi-byte character split over several escapes still decodes to one character.
  • Input without any escape is returned directly.
  • Javadoc now states that unescaped characters are kept as they are.

Why this matters in practice: a servlet container decodes the path info, so the first server-side navigation sees literal characters. Static route segments with a non-ASCII character never matched, and @WildcardParameter values were corrupted. Jar URLs are also not required to be percent-encoded, so ResourceFolderUtil silently found no resources in a folder whose entry name contains a non-ASCII character.

No public or protected API was added, removed, or changed.

Fixes #25671

Test summary

# Status What the test verifies Why it matters
1 A string with literal non-ASCII characters (grüße, 日本, an emoji) is returned unchanged This is the bug: such input used to become
2 A string mixing %XX escapes and literal characters decodes to grüße-ü-äxö Both forms must work in the same string; also pins that multi-byte escapes still decode
3 A static route grüße matches both the literal and the percent-encoded location Server-side navigation sees literal text, client-side sees encoded text
4 A @WildcardParameter value keeps grüße for literal input and decodes it for encoded input Corrupted parameter values were the visible symptom for apps
5 PathUtil.getSegmentsListWithDecoding keeps literal UTF-8 segments and splits them correctly Route resolution is built on this splitting step
6 ResourceFolderUtil.visitFiles finds files in a jar folder named thèmes/ Resources in such folders were silently skipped
7 gap Behaviour for a malformed or truncated escape (for example a lone %C3) Invalid input should degrade predictably, not throw
  • UrlUtilTest.decodeURIComponent_literalNonAsciiCharacters_returnedUnchanged → 1
  • UrlUtilTest.decodeURIComponent_literalAndEncodedNonAsciiCharacters_bothDecoded → 2
  • RouterTest.static_route_with_non_ascii_character → 3
  • RouterTest.wildcard_parameter_with_non_ascii_characters → 4
  • PathUtilTest.getSegmentsListWithDecoding_handlesUtf8Characters (extended) → 5
  • ResourceFolderUtilTest.folderPathContainsLiteralNonAsciiCharacter_filesInTheJarAreVisited → 6

Deliberately not tested: the new private appendDecoded helper, which is covered through the public method, and plain ASCII or %2F decoding, which existing tests in UrlUtilTest and PathUtilTest already pin.

UrlUtil.decodeURIComponent reassembled UTF-8 bytes from every character
with a code point above 127, including characters that were never
percent-encoded. A literal non-ASCII character was thus reinterpreted as
a UTF-8 byte and replaced with U+FFFD.

A servlet container decodes the path info, so the initial server side
navigation sees literal characters. Static route segments containing a
non-ASCII character therefore never matched, and @WildcardParameter
values were corrupted.

Percent-encoded escapes are now collected into a byte sequence that is
decoded as UTF-8, while the surrounding characters are kept as they are.

Fixes #25671
@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Test Results

 1 440 files  ±0   1 524 suites  ±0   1h 30m 15s ⏱️ - 4m 22s
12 065 tests +5  11 997 ✅ +5  68 💤 ±0  0 ❌ ±0 
12 383 runs  +5  12 315 ✅ +5  68 💤 ±0  0 ❌ ±0 

Results for commit 8e05bcc. ± Comparison against base commit 980ac5b.

♻️ This comment has been updated with latest results.

@Artur-

Artur- commented Sep 11, 2026

Copy link
Copy Markdown
Member

Isn't what truly is wrong here that a decoded string is passed to decodeURIComponent?

A jar URL does not have to be percent-encoded, so ResourceFolderUtil
reads folder entry names that have the characters they have in the jar.
The test fails without the decodeURIComponent fix, where the resources
of such a folder are silently not found.
@totally-not-ai

Copy link
Copy Markdown
Contributor Author

@Artur- Yes — and that is a better framing than the one in the issue. The double decode is the cause of the routing symptom; this PR only stops the second decode from destroying data. Worth splitting into two questions.

Is decodeURIComponent itself wrong? It is, independently of routing. It is documented as the equivalent of JavaScript's decodeURIComponent(), and it reassembled UTF-8 bytes from every char above 127, including chars that were never escaped — so a code point above U+00FF was also truncated by the (byte) cast (日本�,). It has callers that legitimately pass strings that were never percent-encoded: ResourceFolderUtil.getFolderEntryName decodes the entry part of a jar URL, and a jar URL does not have to be encoded. I pushed a test for that case — a jar with a thèmes/ folder finds zero resources on main and the files on this branch, with no routing involved. So this change stands on its own merit.

Is it the fix for the reported issue? No, it is a narrowing of it. Route resolution has three input sources with two conventions:

  • BootstrapHandler builds the Location from HttpServletRequest#getPathInfo(), which the container has already decoded → decoded.
  • Flow.ts sends window.location.pathname undecoded, both as the location init parameter and for client-side navigation → encoded (deliberately, since fix: preserve URL-encoded characters in wildcard route parameters #22791).
  • UI.navigate(String) and BeforeEvent.forwardTo(String) take whatever the application wrote, which in practice is human-readable → decoded.

PathUtil.getSegmentsListWithDecoding assumes the encoded convention, so the other two get decoded twice. This PR makes the second decode harmless for unescaped characters, but it is still wrong for literal text that looks like an escape: an already decoded 50%20off still becomes 50 off, so a route or wildcard value containing a literal percent is still corrupted.

Fixing that properly means picking one canonical form for the path that reaches the router, and both choices have consequences:

  1. Canonical encoded — build the bootstrap Location from the raw request URI instead of getPathInfo(), and require pre-encoded input from UI.navigate/forwardTo (as RouteConfiguration#getUrl already documents). Makes the eager page load agree with client-side navigation, which is encoded today. But it leaves UI.navigate("grüße") broken, and Location#getPath() starts returning percent-encoded text on the initial load, which applications can observe.
  2. Canonical decoded — decode exactly once, at the boundary where the encoded form is known, and never in the router. That is what applications want downstream, but it needs Location to carry the path as segments rather than one string, otherwise rejoining them loses the %2F distinction that fix: preserve URL-encoded characters in wildcard route parameters #22791 was about.

Either one changes Location semantics, so I did not put it in this PR. Which direction would you prefer? I am happy to do it here, or keep this as the narrow data-corruption fix (it is also the part that backports cleanly to 24.9) and open a separate issue for the contract.

@Artur-

Artur- commented Sep 12, 2026

Copy link
Copy Markdown
Member

Please create an issue about the cases where it is decoded even though it should not be, so that can be fixed separately.

@Artur-

Artur- commented Sep 12, 2026

Copy link
Copy Markdown
Member

@claude review and check that decodeURIComponent now works as in the browser

@claude

claude Bot commented Sep 12, 2026

Copy link
Copy Markdown

Claude finished @Artur-'s task in 3m 12s —— View job


Review: does decodeURIComponent now behave like the browser?

  • Read the new implementation
  • Compare against browser decodeURIComponent (valid + malformed input)
  • Check tests
  • Post review

Verdict: for every well‑formed input the browser can decode, this implementation now returns the identical result. The only divergence is error handling on malformed input, which is exactly the gap (#7) already called out in the PR description.

I verified this empirically rather than by reasoning: I ran each input through a real browser's decodeURIComponent() (via Playwright) and through the exact code from UrlUtil.java (line‑for‑line copy). Side by side:

Input Browser This PR Match
test%20file.txt test file.txt test file.txt
test+file.txt test+file.txt test+file.txt ✅ (+ not treated as space, like the browser)
%C3%A5%C3%A4%C3%B6.txt åäö.txt åäö.txt
grüße grüße grüße (the bug being fixed)
日本 日本 日本 ✅ (code point > U+00FF, was truncated before)
emoji 😀 emoji 😀 emoji 😀
gr%C3%BC%C3%9Fe-ü-%C3%A4x%C3%B6 grüße-ü-äxö grüße-ü-äxö ✅ (literal + encoded mixed)
%F0%9F%98%80 😀 😀 ✅ (4‑byte char across 4 escapes)
100%25 100% 100%
%00 `` ``
% throws URIError % ⚠️
%2 throws URIError %2 ⚠️
%zz throws URIError %zz ⚠️
%C3 throws URIError ⚠️
%C3%28 throws URIError �( ⚠️
a%b%C3%BC throws URIError a%bü ⚠️
%ED%A0%80 (surrogate) throws URIError ��� ⚠️

What's correct

  • The core fix is right and well implemented. Collecting consecutive %XX escapes into one byte buffer and flushing it whenever a literal run interrupts (UrlUtil.java:178-191) is the correct way to keep multi‑byte UTF‑8 whole while never re‑interpreting literal characters as bytes. This is precisely what the old (byte) c cast got wrong for code points above U+00FF (日本).
  • The early return encoded when there is no escape (UrlUtil.java:167-170) matches the browser's pass‑through of already‑decoded strings.
  • + is preserved rather than turned into a space, matching decodeURIComponent (and unlike URLDecoder) — confirmed above.

The one divergence — malformed input

The browser's decodeURIComponent throws URIError: URI malformed on every malformed escape; this method instead degrades gracefully (keeps the literal %xx text when the two hex digits are absent, or emits U+FFFD when the bytes aren't valid UTF‑8, because ByteArrayOutputStream.toString(UTF_8) substitutes rather than throws).

For the internal routing / resource‑lookup use case this is arguably the better behaviour — you don't want a stray % in a path to blow up navigation with an exception — so I would not change the behaviour. But two small things are worth doing:

  1. Javadoc accuracy. The doc says it "Corresponds to decodeURIComponent in JavaScript" (UrlUtil.java:144-145). That's now true for valid input but not for malformed input, where the browser throws and this doesn't. A one‑line note ("malformed or incomplete escapes are left as‑is / replaced rather than throwing, unlike the browser") would make the contract honest. Fix this →
  2. Test the gap. Add a test pinning the malformed cases above (%, %2, %zz, %C3, %C3%28) so the graceful‑degradation contract is intentional and can't silently regress into throwing. Fix this →

Note on scope

This confirms what you and the author already discussed: the change is a sound, self‑contained data‑corruption fix and correctly aligns decodeURIComponent with the browser for valid input. The remaining double‑decode contract issue (e.g. a literal 50%20off still decoding to 50 off) is separate and, per your last comment, belongs in its own issue — it is not something this method can fix in isolation, since the caller passes an already‑decoded string.
· branch fix/decode-uri-component-keeps-literal-non-ascii

Records where the assumption is made, as the callers that pass a path
that is already decoded are fixed separately in #25690.
@sonarqubecloud

Copy link
Copy Markdown

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Static route segments containing a literal non-ASCII character never match

1 participant