Skip to content

Commit 4346bdb

Browse files
vaadin-bottltvArtur-mshabarov
authored
fix: Sanitize percent characters in resource URLs (#24031) (CP: 25.1) (#24169)
This PR cherry-picks changes from the original PR #24031 to branch 25.1. --- #### Original PR description > Jetty 12 rejects URLs containing %25 (percent-encoded percent) as ambiguous URI path encoding, causing downloads to fail with HTTP 400 when filenames contain "%" characters. > > Add UrlUtil.sanitizeForUrl() that replaces "%" with "_" in the URL path segment. The actual download filename from Content-Disposition is unaffected since each resource has a unique ID for lookup. > > Fixes #22677 > Co-authored-by: Tomi Virtanen <tltv@vaadin.com> Co-authored-by: Artur Signell <artur@vaadin.com> Co-authored-by: Mikhail Shabarov <61410877+mshabarov@users.noreply.github.com>
1 parent 4ea30a7 commit 4346bdb

5 files changed

Lines changed: 90 additions & 16 deletions

File tree

flow-server/src/main/java/com/vaadin/flow/server/communication/StreamRequestHandler.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,9 +244,30 @@ private PathData parsePath(String pathInfo) {
244244
* @return generated URI string
245245
*/
246246
public static String generateURI(String name, String id) {
247+
// Replace '%' with '_' to avoid ambiguous percent-encoding (%25)
248+
// rejected by Jetty 12. The actual download filename is preserved
249+
// in the Content-Disposition header, so this only affects the URL
250+
// path used for resource lookup.
251+
String safeName = sanitizeNameForUrl(name);
247252
return DYN_RES_PREFIX + UI.getCurrentOrThrow().getUIId()
248253
+ PATH_SEPARATOR + id + PATH_SEPARATOR
249-
+ UrlUtil.encodeURIComponent(name);
254+
+ UrlUtil.encodeURIComponent(safeName);
255+
}
256+
257+
/**
258+
* Replaces characters that would produce ambiguous percent-encodings in URL
259+
* path segments.
260+
*
261+
* @param name
262+
* the resource name to sanitize
263+
* @return the sanitized name, or the original value if {@code null} or
264+
* empty
265+
*/
266+
static String sanitizeNameForUrl(String name) {
267+
if (name == null || name.isEmpty()) {
268+
return name;
269+
}
270+
return name.replace("%", "_");
250271
}
251272

252273
private static Optional<URI> getPathUri(String path) {

flow-server/src/test/java/com/vaadin/flow/server/communication/StreamRequestHandlerTest.java

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050

5151
import static com.vaadin.flow.server.communication.StreamRequestHandler.DYN_RES_PREFIX;
5252
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
53+
import static org.junit.jupiter.api.Assertions.assertEquals;
54+
import static org.junit.jupiter.api.Assertions.assertNull;
5355

5456
@NotThreadSafe
5557
class StreamRequestHandlerTest {
@@ -155,6 +157,18 @@ void streamResourceNameContainsPlusAndSpaces_resourceWriter_resourceIsStreamed()
155157
"readme + mine.md");
156158
}
157159

160+
@Test
161+
public void streamResourceNameContainsPercent_streamFactory_resourceIsStreamed()
162+
throws IOException {
163+
testStreamResourceInputStreamFactory("percent in name", "file%.txt");
164+
}
165+
166+
@Test
167+
public void streamResourceNameContainsPercent_resourceWriter_resourceIsStreamed()
168+
throws IOException {
169+
testStreamResourceStreamResourceWriter("percent in name", "file%.txt");
170+
}
171+
158172
@Test
159173
void stateNodeStates_handlerMustNotReplyWhenNodeDisabled()
160174
throws IOException {
@@ -247,9 +261,10 @@ private VaadinResponse stateNodeStatesTestInternal(
247261
ServletOutputStream outputStream = Mockito
248262
.mock(ServletOutputStream.class);
249263
Mockito.when(response.getOutputStream()).thenReturn(outputStream);
250-
Mockito.when(request.getPathInfo())
251-
.thenReturn(String.format("/%s%s/%s/%s", DYN_RES_PREFIX,
252-
ui.getId().orElse("-1"), res.getId(), res.getName()));
264+
Mockito.when(request.getPathInfo()).thenReturn(String.format(
265+
"/%s%s/%s/%s", DYN_RES_PREFIX, ui.getId().orElse("-1"),
266+
res.getId(),
267+
StreamRequestHandler.sanitizeNameForUrl(res.getName())));
253268

254269
handler.handleRequest(session, request, response);
255270

@@ -268,9 +283,10 @@ private void testStreamResourceInputStreamFactory(String testString,
268283
ServletOutputStream outputStream = Mockito
269284
.mock(ServletOutputStream.class);
270285
Mockito.when(response.getOutputStream()).thenReturn(outputStream);
271-
Mockito.when(request.getPathInfo())
272-
.thenReturn(String.format("/%s%s/%s/%s", DYN_RES_PREFIX,
273-
ui.getId().orElse("-1"), res.getId(), res.getName()));
286+
Mockito.when(request.getPathInfo()).thenReturn(String.format(
287+
"/%s%s/%s/%s", DYN_RES_PREFIX, ui.getId().orElse("-1"),
288+
res.getId(),
289+
StreamRequestHandler.sanitizeNameForUrl(res.getName())));
274290

275291
handler.handleRequest(session, request, response);
276292

@@ -302,9 +318,10 @@ private void testStreamResourceStreamResourceWriter(String testString,
302318
ServletOutputStream outputStream = Mockito
303319
.mock(ServletOutputStream.class);
304320
Mockito.when(response.getOutputStream()).thenReturn(outputStream);
305-
Mockito.when(request.getPathInfo())
306-
.thenReturn(String.format("/%s%s/%s/%s", DYN_RES_PREFIX,
307-
ui.getId().orElse("-1"), res.getId(), res.getName()));
321+
Mockito.when(request.getPathInfo()).thenReturn(String.format(
322+
"/%s%s/%s/%s", DYN_RES_PREFIX, ui.getId().orElse("-1"),
323+
res.getId(),
324+
StreamRequestHandler.sanitizeNameForUrl(res.getName())));
308325

309326
handler.handleRequest(session, request, response);
310327

@@ -319,6 +336,22 @@ private void testStreamResourceStreamResourceWriter(String testString,
319336
Mockito.verify(response).setContentType("application/octet-stream");
320337
}
321338

339+
@Test
340+
public void sanitizeNameForUrl_percentIsReplaced() {
341+
assertEquals("file_.txt",
342+
StreamRequestHandler.sanitizeNameForUrl("file%.txt"));
343+
}
344+
345+
@Test
346+
public void sanitizeNameForUrl_nullReturnsNull() {
347+
assertNull(StreamRequestHandler.sanitizeNameForUrl(null));
348+
}
349+
350+
@Test
351+
public void sanitizeNameForUrl_emptyReturnsEmpty() {
352+
assertEquals("", StreamRequestHandler.sanitizeNameForUrl(""));
353+
}
354+
322355
private static final class TestElementHandlerBuilder {
323356
private final TestElementHandlerProperties elementHandlerProperties;
324357
private final TestStateNodeProperties stateNodeProperties;

flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/DownloadHandlerView.java

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,27 @@ public String getUrlPostfix() {
130130
inputStreamCallbackError
131131
.setId("download-handler-input-stream-callback-error");
132132

133+
DownloadHandler percentDownloadHandler = new DownloadHandler() {
134+
@Override
135+
public void handleDownloadRequest(DownloadEvent event) {
136+
event.getWriter().print("foo");
137+
}
138+
139+
@Override
140+
public String getUrlPostfix() {
141+
return "file%.jpg";
142+
}
143+
};
144+
145+
Anchor percentDownload = new Anchor("", "Percent DownloadHandler");
146+
percentDownload.setHref(percentDownloadHandler);
147+
percentDownload.setId("download-handler-percent");
148+
133149
add(handlerDownload, fileDownload, fileDownloadUnicodeName,
134150
fileDownloadUnicodeNameWithQuote, classDownload,
135151
servletDownload, inputStreamDownload, inputStreamErrorDownload,
136-
inputStreamExceptionDownload, inputStreamCallbackError);
152+
inputStreamExceptionDownload, inputStreamCallbackError,
153+
percentDownload);
137154

138155
NativeButton reattach = new NativeButton("Remove and add back",
139156
event -> {

flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/DownloadHandlerIT.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,13 @@ public void getDynamicDownloadHandlerFailingInputStreamCallback_errorIsReceived(
222222
.withTextContaining("java.io.IOException: Callback").exists());
223223
}
224224

225+
@Test
226+
public void getDynamicDownloadHandlerPercentResource() throws IOException {
227+
open();
228+
229+
assertDownloadedContent("download-handler-percent", "file_.jpg");
230+
}
231+
225232
@Test
226233
public void detach_attachALink_getDynamicVaadinResource()
227234
throws IOException {

flow-tests/test-root-context/src/test/java/com/vaadin/flow/uitest/ui/StreamResourceIT.java

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
import org.apache.commons.io.FilenameUtils;
2727
import org.apache.commons.io.IOUtils;
2828
import org.junit.Assert;
29-
import org.junit.Ignore;
3029
import org.junit.Test;
3130
import org.openqa.selenium.By;
3231
import org.openqa.selenium.WebElement;
@@ -54,14 +53,11 @@ public void getDynamicVaadinPlusResource() throws IOException {
5453
assertDownloadedContent("plus-link", "file%2B.jpg");
5554
}
5655

57-
// Ignored due to
58-
// https://github.com/jetty/jetty.project/issues/9444#issuecomment-1677068428
5956
@Test
60-
@Ignore
6157
public void getDynamicVaadinPercentResource() throws IOException {
6258
open();
6359

64-
assertDownloadedContent("percent-link", "file%25.jpg");
60+
assertDownloadedContent("percent-link", "file_.jpg");
6561
}
6662

6763
@Test

0 commit comments

Comments
 (0)