Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 37 additions & 25 deletions flow-server/src/main/java/com/vaadin/flow/internal/UrlUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)}.
* <p>
* 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
Expand All @@ -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();
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ public static List<String> 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.
* <p>
* 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
* <a href="https://github.com/vaadin/flow/issues/25690">#25690</a>.
*
* @param path
* url path to split into segments and decode. The path may also
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
46 changes: 46 additions & 0 deletions flow-server/src/test/java/com/vaadin/flow/router/RouterTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading