Skip to content

Commit 1a49b2e

Browse files
mshabarovtltvArtur-
authored
fix: Sanitize percent characters in resource URLs (#24031)(CP:25.0) (#24170)
Cherry-pick for #24031 Co-authored-by: Tomi Virtanen <tltv@vaadin.com> Co-authored-by: Artur Signell <artur@vaadin.com>
1 parent 076385f commit 1a49b2e

5 files changed

Lines changed: 88 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: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,18 @@ public void streamResourceNameContainsPlusAndSpaces_resourceWriter_resourceIsStr
155155
"readme + mine.md");
156156
}
157157

158+
@Test
159+
public void streamResourceNameContainsPercent_streamFactory_resourceIsStreamed()
160+
throws IOException {
161+
testStreamResourceInputStreamFactory("percent in name", "file%.txt");
162+
}
163+
164+
@Test
165+
public void streamResourceNameContainsPercent_resourceWriter_resourceIsStreamed()
166+
throws IOException {
167+
testStreamResourceStreamResourceWriter("percent in name", "file%.txt");
168+
}
169+
158170
@Test
159171
public void stateNodeStates_handlerMustNotReplyWhenNodeDisabled()
160172
throws IOException {
@@ -249,9 +261,10 @@ private VaadinResponse stateNodeStatesTestInternal(
249261
ServletOutputStream outputStream = Mockito
250262
.mock(ServletOutputStream.class);
251263
Mockito.when(response.getOutputStream()).thenReturn(outputStream);
252-
Mockito.when(request.getPathInfo())
253-
.thenReturn(String.format("/%s%s/%s/%s", DYN_RES_PREFIX,
254-
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())));
255268

256269
handler.handleRequest(session, request, response);
257270

@@ -270,9 +283,10 @@ private void testStreamResourceInputStreamFactory(String testString,
270283
ServletOutputStream outputStream = Mockito
271284
.mock(ServletOutputStream.class);
272285
Mockito.when(response.getOutputStream()).thenReturn(outputStream);
273-
Mockito.when(request.getPathInfo())
274-
.thenReturn(String.format("/%s%s/%s/%s", DYN_RES_PREFIX,
275-
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())));
276290

277291
handler.handleRequest(session, request, response);
278292

@@ -304,9 +318,10 @@ private void testStreamResourceStreamResourceWriter(String testString,
304318
ServletOutputStream outputStream = Mockito
305319
.mock(ServletOutputStream.class);
306320
Mockito.when(response.getOutputStream()).thenReturn(outputStream);
307-
Mockito.when(request.getPathInfo())
308-
.thenReturn(String.format("/%s%s/%s/%s", DYN_RES_PREFIX,
309-
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())));
310325

311326
handler.handleRequest(session, request, response);
312327

@@ -321,6 +336,22 @@ private void testStreamResourceStreamResourceWriter(String testString,
321336
Mockito.verify(response).setContentType("application/octet-stream");
322337
}
323338

339+
@Test
340+
public void sanitizeNameForUrl_percentIsReplaced() {
341+
Assert.assertEquals("file_.txt",
342+
StreamRequestHandler.sanitizeNameForUrl("file%.txt"));
343+
}
344+
345+
@Test
346+
public void sanitizeNameForUrl_nullReturnsNull() {
347+
Assert.assertNull(StreamRequestHandler.sanitizeNameForUrl(null));
348+
}
349+
350+
@Test
351+
public void sanitizeNameForUrl_emptyReturnsEmpty() {
352+
Assert.assertEquals("", StreamRequestHandler.sanitizeNameForUrl(""));
353+
}
354+
324355
private static final class TestElementHandlerBuilder {
325356
private final TestElementHandlerProperties elementHandlerProperties;
326357
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)