Skip to content

Commit a24d06c

Browse files
authored
fix: filename encoding of download (#22039)
Applies utf-8 encoding by default in Content-Disposition header for DownloadEvent. Fixes: #22007
1 parent 1d12de6 commit a24d06c

File tree

6 files changed

+258
-10
lines changed

6 files changed

+258
-10
lines changed

flow-server/src/main/java/com/vaadin/flow/internal/EncodeUtil.java

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
*/
1616
package com.vaadin.flow.internal;
1717

18+
import java.nio.charset.StandardCharsets;
19+
import java.util.HexFormat;
20+
1821
import static java.nio.charset.StandardCharsets.UTF_8;
1922

2023
/**
@@ -27,6 +30,8 @@
2730
*/
2831
public final class EncodeUtil {
2932

33+
private static final HexFormat HEX_FORMAT = HexFormat.of().withUpperCase();
34+
3035
private EncodeUtil() {
3136
// Static utils only
3237
}
@@ -45,7 +50,7 @@ public static String rfc5987Encode(String value) {
4550
int i = 0;
4651
while (i < value.length()) {
4752
int cp = value.codePointAt(i);
48-
if (cp < 127 && (Character.isLetterOrDigit(cp) || cp == '.')) {
53+
if (cp < 127 && isRFC5987AttrChar(cp)) {
4954
builder.append((char) cp);
5055
} else {
5156
// Create string from a single code point
@@ -63,11 +68,59 @@ public static String rfc5987Encode(String value) {
6368

6469
private static void appendHexBytes(StringBuilder builder, byte[] bytes) {
6570
for (byte byteValue : bytes) {
66-
// mask with 0xFF to compensate for "negative" values
67-
int intValue = byteValue & 0xFF;
68-
String hexCode = Integer.toString(intValue, 16);
69-
builder.append('%').append(hexCode);
71+
builder.append('%');
72+
HEX_FORMAT.toHexDigits(builder, byteValue);
7073
}
7174
}
7275

76+
private static boolean isRFC5987AttrChar(int codePoint) {
77+
return Character.isLetterOrDigit(codePoint)
78+
|| "!#$&+-.^_`|~".indexOf(codePoint) >= 0;
79+
}
80+
81+
/**
82+
* Encodes the given header field param as defined in RFC 2047 for use in
83+
* e.g. the <code>Content-Disposition</code> HTTP header.
84+
*
85+
* @param value
86+
* the string to encode, not <code>null</code>
87+
* @return the encoded string
88+
*/
89+
public static String rfc2047Encode(String value) {
90+
byte[] source = value.getBytes(UTF_8);
91+
StringBuilder sb = new StringBuilder(source.length << 1);
92+
sb.append("=?").append(UTF_8.name()).append("?Q?");
93+
for (byte b : source) {
94+
if (b == 32) {
95+
sb.append('_'); // Replace space with underscore
96+
} else if (isPrintable(b)) {
97+
sb.append((char) b);
98+
} else {
99+
sb.append('=');
100+
HEX_FORMAT.toHexDigits(sb, b);
101+
}
102+
}
103+
sb.append("?=");
104+
return sb.toString();
105+
}
106+
107+
private static boolean isPrintable(byte c) {
108+
int b = c & 0xFF; // Convert to unsigned
109+
// RFC 2045, Section 6.7, and RFC 2047, Section 4.2
110+
// printable characters are 33-126, excluding "=?_
111+
return (b >= 33 && b <= 126) && b != 34 && b != 61 && b != 63
112+
&& b != 95;
113+
}
114+
115+
/**
116+
* Checks if the given string contains only US-ASCII characters.
117+
*
118+
* @param text
119+
* the string to check, not <code>null</code>
120+
* @return <code>true</code> if the string contains only US-ASCII
121+
* characters,
122+
*/
123+
public static boolean isPureUSASCII(String text) {
124+
return StandardCharsets.US_ASCII.newEncoder().canEncode(text);
125+
}
73126
}

flow-server/src/main/java/com/vaadin/flow/server/streams/DownloadEvent.java

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import com.vaadin.flow.component.Component;
2828
import com.vaadin.flow.component.UI;
2929
import com.vaadin.flow.dom.Element;
30+
import com.vaadin.flow.internal.EncodeUtil;
3031
import com.vaadin.flow.server.VaadinRequest;
3132
import com.vaadin.flow.server.VaadinResponse;
3233
import com.vaadin.flow.server.VaadinSession;
@@ -149,8 +150,20 @@ public void setFileName(String fileName) {
149150
if (fileName.isEmpty()) {
150151
response.setHeader("Content-Disposition", "attachment");
151152
} else {
152-
response.setHeader("Content-Disposition",
153-
"attachment; filename=\"" + fileName + "\"");
153+
StringBuilder value = new StringBuilder();
154+
value.append("attachment; ");
155+
if (EncodeUtil.isPureUSASCII(fileName)) {
156+
value.append("filename=\"").append(fileName).append("\"");
157+
} else {
158+
value
159+
// fallback legacy support
160+
.append("filename=\"")
161+
.append(EncodeUtil.rfc2047Encode(fileName))
162+
// used primarily
163+
.append("\"; filename*=UTF-8''")
164+
.append(EncodeUtil.rfc5987Encode(fileName));
165+
}
166+
response.setHeader("Content-Disposition", value.toString());
154167
}
155168
this.fileName = fileName;
156169
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* Copyright 2000-2025 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
17+
package com.vaadin.flow.internal;
18+
19+
import org.junit.Assert;
20+
import org.junit.Test;
21+
22+
public class EncodeUtilTest {
23+
24+
@Test(expected = NullPointerException.class)
25+
public void rfc5987Encode_withNull_nullPointerException() {
26+
EncodeUtil.rfc5987Encode(null);
27+
}
28+
29+
@Test(expected = NullPointerException.class)
30+
public void rfc2047Encode_withNull_nullPointerException() {
31+
EncodeUtil.rfc2047Encode(null);
32+
}
33+
34+
@Test
35+
public void rfc5987Encode_asciiCharacters() {
36+
String input = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$&'*+-.^_`|~";
37+
Assert.assertEquals(
38+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$&%27%2A+-.^_`|~",
39+
EncodeUtil.rfc5987Encode(input));
40+
}
41+
42+
// UTF-8 Basic Latin & Controls
43+
@Test
44+
public void rfc5987Encode_unicodeCharacters0to126() throws Exception {
45+
StringBuilder text = new StringBuilder();
46+
for (int codePoint = 0; codePoint <= 126; codePoint++) {
47+
text.append(new String(new int[] { codePoint }, 0, 1));
48+
}
49+
Assert.assertEquals(
50+
"%00%01%02%03%04%05%06%07%08%09%0A%0B%0C%0D%0E%0F%10%11%12%13%14%15%16%17%18%19%1A%1B%1C%1D%1E%1F%20!%22#$%25&%27%28%29%2A+%2C-.%2F0123456789%3A%3B%3C%3D%3E%3F%40ABCDEFGHIJKLMNOPQRSTUVWXYZ%5B%5C%5D^_`abcdefghijklmnopqrstuvwxyz%7B|%7D~",
51+
EncodeUtil.rfc5987Encode(text.toString()));
52+
}
53+
54+
// UTF-8 Latin-1 Supplement
55+
@Test
56+
public void rfc5987Encode_unicodeLatin1SupplementCharacters()
57+
throws Exception {
58+
Assert.assertEquals("%E2%82%AC%20%C3%BF",
59+
EncodeUtil.rfc5987Encode("€ ÿ"));
60+
}
61+
62+
// UTF-8 Latin Extended A
63+
@Test
64+
public void rfc5987Encode_unicodeLatinExtendACharacters() throws Exception {
65+
Assert.assertEquals("%C4%80%C4%81", EncodeUtil.rfc5987Encode("Āā"));
66+
}
67+
68+
@Test
69+
public void rfc2047Encode_asciiCharacters() {
70+
String input = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$&'*+-.^_`|~ ?=\"";
71+
Assert.assertEquals(
72+
"=?UTF-8?Q?abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$&'*+-.^=5F`|~_=3F=3D=22?=",
73+
EncodeUtil.rfc2047Encode(input));
74+
}
75+
76+
@Test
77+
public void rfc2047Encode_nonAsciiCharacters() {
78+
String input = "Řřüñîçødë 1中文 € ÿĀā";
79+
Assert.assertEquals(
80+
"=?UTF-8?Q?=C5=98=C5=99=C3=BC=C3=B1=C3=AE=C3=A7=C3=B8d=C3=AB_1=E4=B8=AD=E6=96=87_=E2=82=AC_=C3=BF=C4=80=C4=81?=",
81+
EncodeUtil.rfc2047Encode(input));
82+
}
83+
84+
@Test
85+
public void isPureUSASCII_withAsciiOnly_returnTrue() {
86+
String input = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$&'*+-.^_`|~ ?=\"";
87+
Assert.assertTrue(EncodeUtil.isPureUSASCII(input));
88+
}
89+
90+
@Test
91+
public void isPureUSASCII_withNonAscii_returnFalse() {
92+
String input = "Řřüñîçøë中文€ÿĀā";
93+
input.chars().forEach(c -> {
94+
Assert.assertFalse(
95+
"Character " + (char) c + " should not be US-ASCII",
96+
EncodeUtil.isPureUSASCII(Character.toString((char) c)));
97+
});
98+
}
99+
}

flow-server/src/test/java/com/vaadin/flow/server/streams/DownloadEventTest.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import org.junit.Test;
77
import org.mockito.Mockito;
88

9+
import com.vaadin.flow.internal.EncodeUtil;
910
import com.vaadin.flow.server.VaadinRequest;
1011
import com.vaadin.flow.server.VaadinResponse;
1112
import com.vaadin.flow.server.VaadinService;
@@ -34,7 +35,18 @@ public void setFileName_nonEmptyFileName_setsContentDispositionFilenameQuotedToR
3435
String fileName = "test.txt";
3536
downloadEvent.setFileName(fileName);
3637
Mockito.verify(response).setHeader("Content-Disposition",
37-
"attachment; filename=\"" + fileName + "\"");
38+
"attachment;" + " filename=\"" + fileName + "\"");
39+
}
40+
41+
@Test
42+
public void setFileName_nonEmptyFileName_setsContentDispositionEncodedFilenameQuotedToResponse() {
43+
DownloadEvent downloadEvent = new DownloadEvent(request, response,
44+
session, null);
45+
String fileName = "test üñîçødë.txt";
46+
downloadEvent.setFileName(fileName);
47+
Mockito.verify(response).setHeader("Content-Disposition", "attachment;"
48+
+ " filename=\"" + EncodeUtil.rfc2047Encode(fileName) + "\";"
49+
+ " filename*=UTF-8''" + EncodeUtil.rfc5987Encode(fileName));
3850
}
3951

4052
@Test

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,19 @@ public String getUrlPostfix() {
5858
fileDownload.setHref(DownloadHandler.forFile(jsonFile).inline());
5959
fileDownload.setId("download-handler-file");
6060

61+
Anchor fileDownloadUnicodeName = new Anchor("",
62+
"File (unicode name) DownloadHandler shorthand");
63+
fileDownloadUnicodeName.setHref(DownloadHandler.forFile(jsonFile,
64+
"download-Řřüñîçødë 1中文.json"));
65+
fileDownloadUnicodeName.setId("download-handler-file-unicode");
66+
67+
Anchor fileDownloadUnicodeNameWithQuote = new Anchor("",
68+
"File (unicode name with quote) DownloadHandler shorthand");
69+
fileDownloadUnicodeNameWithQuote
70+
.setHref(DownloadHandler.forFile(jsonFile, "download-\".json"));
71+
fileDownloadUnicodeNameWithQuote
72+
.setId("download-handler-file-unicode-quote");
73+
6174
Anchor classDownload = new Anchor("",
6275
"Class resource DownloadHandler shorthand");
6376
classDownload.setHref(DownloadHandler
@@ -117,8 +130,9 @@ public String getUrlPostfix() {
117130
inputStreamCallbackError
118131
.setId("download-handler-input-stream-callback-error");
119132

120-
add(handlerDownload, fileDownload, classDownload, servletDownload,
121-
inputStreamDownload, inputStreamErrorDownload,
133+
add(handlerDownload, fileDownload, fileDownloadUnicodeName,
134+
fileDownloadUnicodeNameWithQuote, classDownload,
135+
servletDownload, inputStreamDownload, inputStreamErrorDownload,
122136
inputStreamExceptionDownload, inputStreamCallbackError);
123137

124138
NativeButton reattach = new NativeButton("Remove and add back",

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

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,63 @@ public void getDynamicDownloadHandlerFileResource() throws IOException {
6565
Assert.assertEquals("download.json", FilenameUtils.getName(url));
6666
}
6767

68+
@Test
69+
public void getDynamicDownloadHandlerFileResourceUnicodeName()
70+
throws IOException {
71+
open();
72+
73+
WebElement link = findElement(By.id("download-handler-file-unicode"));
74+
Assert.assertEquals(
75+
"Anchor element should have router-ignore " + "attribute", "",
76+
link.getAttribute("router-ignore"));
77+
String url = link.getAttribute("href");
78+
79+
getDriver().manage().timeouts()
80+
.scriptTimeout(Duration.of(15, ChronoUnit.SECONDS));
81+
82+
try (InputStream stream = download(url)) {
83+
List<String> lines = IOUtils.readLines(stream,
84+
StandardCharsets.UTF_8);
85+
Assert.assertEquals("""
86+
{
87+
"download": true
88+
}""", String.join("\n", lines));
89+
}
90+
// Special characters in the file name in URL are encoded.
91+
Assert.assertEquals(
92+
"download-%C5%98%C5%99%C3%BC%C3%B1%C3%AE%C3%A7%C3%B8d%C3%AB%201%E4%B8%AD%E6%96%87.json",
93+
FilenameUtils.getName(url));
94+
}
95+
96+
// jetty 12 may throw MalformedURLException which Flow logs as a warning
97+
// when checking for static resource before handling the download request.
98+
@Test
99+
public void getDynamicDownloadHandlerFileResourceUnicodeNameWithQuote()
100+
throws IOException {
101+
open();
102+
103+
WebElement link = findElement(
104+
By.id("download-handler-file-unicode-quote"));
105+
Assert.assertEquals(
106+
"Anchor element should have router-ignore " + "attribute", "",
107+
link.getAttribute("router-ignore"));
108+
String url = link.getAttribute("href");
109+
110+
getDriver().manage().timeouts()
111+
.scriptTimeout(Duration.of(15, ChronoUnit.SECONDS));
112+
113+
try (InputStream stream = download(url)) {
114+
List<String> lines = IOUtils.readLines(stream,
115+
StandardCharsets.UTF_8);
116+
Assert.assertEquals("""
117+
{
118+
"download": true
119+
}""", String.join("\n", lines));
120+
}
121+
// Special characters in the file name in URL are encoded.
122+
Assert.assertEquals("download-%22.json", FilenameUtils.getName(url));
123+
}
124+
68125
@Test
69126
public void getDynamicDownloadHandlerClassResource() throws IOException {
70127
open();

0 commit comments

Comments
 (0)