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/main/java/com/vaadin/flow/router/internal/PathUtil.java b/flow-server/src/main/java/com/vaadin/flow/router/internal/PathUtil.java index 4d981eef4ce..138b2eaaba0 100644 --- a/flow-server/src/main/java/com/vaadin/flow/router/internal/PathUtil.java +++ b/flow-server/src/main/java/com/vaadin/flow/router/internal/PathUtil.java @@ -68,6 +68,13 @@ public static List getSegmentsList(String path) { * may contain URL-encoded data that should be preserved after decoding. For * example, a path segment containing {@code %2F} will be decoded to * {@code /}, but this slash will not be treated as a path separator. + *

+ * 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 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 names = new ArrayList<>(); + ResourceFolderUtil.visitFiles( + new URL("jar:" + jar.toURI().toURL() + "!/thèmes/"), + file -> names.add(file.getName())); + + assertEquals(List.of("one.txt"), names); + } + @Test void unknownProtocol_folderIsReadAsAPath() throws IOException { File folder = new File(temporaryFolder, "exploded"); 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