From 432bab86d0292bd8a4b0486e09bf040544538616 Mon Sep 17 00:00:00 2001 From: "totally-not-ai[bot]" <290682512+totally-not-ai[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:28:04 +0000 Subject: [PATCH 1/3] fix: decode only percent-encoded escapes in decodeURIComponent 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 --- .../com/vaadin/flow/internal/UrlUtil.java | 62 +++++++++++-------- .../com/vaadin/flow/internal/UrlUtilTest.java | 17 +++++ .../com/vaadin/flow/router/RouterTest.java | 46 ++++++++++++++ .../flow/router/internal/PathUtilTest.java | 9 +++ 4 files changed, 109 insertions(+), 25 deletions(-) diff --git a/flow-server/src/main/java/com/vaadin/flow/internal/UrlUtil.java b/flow-server/src/main/java/com/vaadin/flow/internal/UrlUtil.java index 042f3700a05..02ed7dedfb7 100644 --- a/flow-server/src/main/java/com/vaadin/flow/internal/UrlUtil.java +++ b/flow-server/src/main/java/com/vaadin/flow/internal/UrlUtil.java @@ -17,6 +17,7 @@ import jakarta.servlet.http.HttpServletRequest; +import java.io.ByteArrayOutputStream; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; @@ -147,6 +148,10 @@ public static String encodeURIComponent(String path) { * space character, making it suitable for decoding strings encoded with * JavaScript's {@code encodeURIComponent()} or * {@link #encodeURIComponent(String)}. + *
+ * Only percent-encoded escapes are decoded. Characters that are not escaped
+ * are kept as they are, so an already decoded string containing for example
+ * {@code ü} is returned unchanged.
*
* @param encoded
* the percent-encoded string
@@ -159,40 +164,47 @@ public static String decodeURIComponent(String encoded) {
}
Matcher matcher = PERCENT_ENCODED.matcher(encoded);
+ if (!matcher.find()) {
+ // Nothing is percent-encoded, so the input is already decoded
+ return encoded;
+ }
+
StringBuilder result = new StringBuilder();
+ // Consecutive escapes are collected so that a multi-byte UTF-8
+ // character split over several escapes is decoded as one character
+ ByteArrayOutputStream escapedBytes = new ByteArrayOutputStream();
int lastEnd = 0;
- while (matcher.find()) {
- // Append text before the match
- result.append(encoded, lastEnd, matcher.start());
-
- // Decode the hex value
- String hex = matcher.group(1);
- int value = Integer.parseInt(hex, 16);
- result.append((char) value);
-
+ do {
+ if (matcher.start() != lastEnd) {
+ // Text between two escapes ends the current byte sequence
+ appendDecoded(result, escapedBytes);
+ result.append(encoded, lastEnd, matcher.start());
+ }
+ escapedBytes.write(Integer.parseInt(matcher.group(1), 16));
lastEnd = matcher.end();
- }
+ } while (matcher.find());
+
+ appendDecoded(result, escapedBytes);
- // Append remaining text
+ // Append remaining text, which is not encoded and thus kept as-is
result.append(encoded, lastEnd, encoded.length());
- // Handle multi-byte UTF-8 sequences
- byte[] bytes = new byte[result.length()];
- boolean hasMultibyte = false;
- for (int i = 0; i < result.length(); i++) {
- char c = result.charAt(i);
- if (c > 127) {
- hasMultibyte = true;
- }
- bytes[i] = (byte) c;
- }
+ return result.toString();
+ }
- if (hasMultibyte) {
- return new String(bytes, StandardCharsets.UTF_8);
+ /**
+ * Decodes the collected percent-encoded bytes as UTF-8 into the given
+ * builder and resets the byte sequence. Characters that were not
+ * percent-encoded are appended separately so that they are not mistaken for
+ * UTF-8 bytes.
+ */
+ private static void appendDecoded(StringBuilder result,
+ ByteArrayOutputStream escapedBytes) {
+ if (escapedBytes.size() > 0) {
+ result.append(escapedBytes.toString(StandardCharsets.UTF_8));
+ escapedBytes.reset();
}
-
- return result.toString();
}
/**
diff --git a/flow-server/src/test/java/com/vaadin/flow/internal/UrlUtilTest.java b/flow-server/src/test/java/com/vaadin/flow/internal/UrlUtilTest.java
index 3ec6f6e5f5b..61014577f50 100644
--- a/flow-server/src/test/java/com/vaadin/flow/internal/UrlUtilTest.java
+++ b/flow-server/src/test/java/com/vaadin/flow/internal/UrlUtilTest.java
@@ -177,6 +177,23 @@ void decodeURIComponent_unicodeCharacters_decoded() {
assertEquals("åäö.txt", result);
}
+ @Test
+ void decodeURIComponent_literalNonAsciiCharacters_returnedUnchanged() {
+ // Characters that were never percent-encoded, for example because a
+ // servlet container already decoded the path, must not be treated as
+ // UTF-8 bytes
+ assertEquals("grüße", UrlUtil.decodeURIComponent("grüße"));
+ assertEquals("日本", UrlUtil.decodeURIComponent("日本"));
+ assertEquals("emoji 😀", UrlUtil.decodeURIComponent("emoji 😀"));
+ }
+
+ @Test
+ void decodeURIComponent_literalAndEncodedNonAsciiCharacters_bothDecoded() {
+ String result = UrlUtil
+ .decodeURIComponent("gr%C3%BC%C3%9Fe-ü-%C3%A4x%C3%B6");
+ assertEquals("grüße-ü-äxö", result);
+ }
+
@Test
void decodeURIComponent_specialCharacters_decoded() {
String result = UrlUtil.decodeURIComponent("special%26%3Dchars.txt");
diff --git a/flow-server/src/test/java/com/vaadin/flow/router/RouterTest.java b/flow-server/src/test/java/com/vaadin/flow/router/RouterTest.java
index 046da2b113b..4fe76ec2231 100644
--- a/flow-server/src/test/java/com/vaadin/flow/router/RouterTest.java
+++ b/flow-server/src/test/java/com/vaadin/flow/router/RouterTest.java
@@ -122,6 +122,12 @@ public static class FooNavigationTarget extends Component {
}
+ @Route("grüße")
+ @Tag(Tag.DIV)
+ public static class NonAsciiNavigationTarget extends Component {
+
+ }
+
@Route("foo/bar")
@Tag(Tag.DIV)
public static class FooBarNavigationTarget extends Component
@@ -2562,6 +2568,46 @@ public void wildcard_parameter_with_mixed_encoded_segments()
"Should decode individual segments but preserve literal slashes");
}
+ @Test
+ public void static_route_with_non_ascii_character()
+ throws InvalidRouteConfigurationException {
+ setNavigationTargets(NonAsciiNavigationTarget.class);
+
+ // A servlet container decodes the path info, so the route is resolved
+ // from literal characters
+ assertEquals(HttpStatusCode.OK.getCode(),
+ router.navigate(ui, new Location("grüße"),
+ NavigationTrigger.PROGRAMMATIC),
+ "A literal non-ASCII segment should match the route");
+ assertEquals(NonAsciiNavigationTarget.class, getUIComponentClass());
+
+ // The same route is also resolved when the segment is still encoded,
+ // which is the case for client side navigation
+ assertEquals(HttpStatusCode.OK.getCode(),
+ router.navigate(ui, new Location("gr%C3%BC%C3%9Fe"),
+ NavigationTrigger.PROGRAMMATIC),
+ "A percent-encoded non-ASCII segment should match the route");
+ assertEquals(NonAsciiNavigationTarget.class, getUIComponentClass());
+ }
+
+ @Test
+ public void wildcard_parameter_with_non_ascii_characters()
+ throws InvalidRouteConfigurationException {
+ WildParameter.events.clear();
+ WildParameter.param = null;
+ setNavigationTargets(WildParameter.class);
+
+ router.navigate(ui, new Location("wild/grüße"),
+ NavigationTrigger.PROGRAMMATIC);
+ assertEquals("grüße", WildParameter.param,
+ "Literal non-ASCII characters should be preserved");
+
+ router.navigate(ui, new Location("wild/gr%C3%BC%C3%9Fe"),
+ NavigationTrigger.PROGRAMMATIC);
+ assertEquals("grüße", WildParameter.param,
+ "Encoded non-ASCII characters should be decoded");
+ }
+
@Test
public void wildcard_parameter_encoded_vs_literal_slashes()
throws InvalidRouteConfigurationException {
diff --git a/flow-server/src/test/java/com/vaadin/flow/router/internal/PathUtilTest.java b/flow-server/src/test/java/com/vaadin/flow/router/internal/PathUtilTest.java
index 0ca5ff76cf7..15b61c44d7b 100644
--- a/flow-server/src/test/java/com/vaadin/flow/router/internal/PathUtilTest.java
+++ b/flow-server/src/test/java/com/vaadin/flow/router/internal/PathUtilTest.java
@@ -175,6 +175,15 @@ void getSegmentsListWithDecoding_handlesUtf8Characters() {
assertEquals(1, segments.size(), "Should have one segment");
assertEquals("helloäöü", segments.get(0),
"Should decode UTF-8 characters");
+
+ // A path that the servlet container has already decoded has literal
+ // UTF-8 characters, which must be kept as they are
+ segments = PathUtil.getSegmentsListWithDecoding("helloäöü/日本");
+ assertEquals(2, segments.size(), "Should have two segments");
+ assertEquals("helloäöü", segments.get(0),
+ "Should keep literal UTF-8 characters");
+ assertEquals("日本", segments.get(1),
+ "Should keep literal UTF-8 characters");
}
@Test
From bfdd3b2363873044b4983de193a47a25d4f78b38 Mon Sep 17 00:00:00 2001
From: "totally-not-ai[bot]"
<290682512+totally-not-ai[bot]@users.noreply.github.com>
Date: Fri, 11 Sep 2026 15:46:58 +0000
Subject: [PATCH 2/3] test: cover a literal non-ASCII jar entry name in a
folder URL
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.
---
.../flow/internal/ResourceFolderUtilTest.java | 20 +++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/flow-server/src/test/java/com/vaadin/flow/internal/ResourceFolderUtilTest.java b/flow-server/src/test/java/com/vaadin/flow/internal/ResourceFolderUtilTest.java
index 7a45be2fcfe..e5aa8889510 100644
--- a/flow-server/src/test/java/com/vaadin/flow/internal/ResourceFolderUtilTest.java
+++ b/flow-server/src/test/java/com/vaadin/flow/internal/ResourceFolderUtilTest.java
@@ -84,6 +84,26 @@ void folderPathContainsSpace_filesInTheJarAreVisited() throws IOException {
assertEquals(List.of("one.txt"), names);
}
+ @Test
+ void folderPathContainsLiteralNonAsciiCharacter_filesInTheJarAreVisited()
+ throws IOException {
+ File jar = new File(temporaryFolder, "themes.jar");
+ try (JarOutputStream jarStream = new JarOutputStream(
+ new FileOutputStream(jar))) {
+ writeEntry(jarStream, "thèmes/");
+ writeEntry(jarStream, "thèmes/one.txt");
+ }
+
+ // A jar URL does not have to be percent-encoded, so the entry name can
+ // reach the utility with the characters it has in the jar
+ List
+ * The path is expected to be percent-encoded. A path that is already
+ * decoded, such as the one of a servlet request or the one an application
+ * passes to {@link com.vaadin.flow.component.UI#navigate(String)}, is
+ * decoded a second time here, which consumes a percent sign that the path
+ * contains as a character of its own. See
+ * #25690.
*
* @param path
* url path to split into segments and decode. The path may also