Skip to content

Commit 41f40c6

Browse files
committed
Escape quotes in filename
Closes gh-24220
1 parent 44da775 commit 41f40c6

File tree

2 files changed

+80
-21
lines changed

2 files changed

+80
-21
lines changed

spring-web/src/main/java/org/springframework/http/ContentDisposition.java

+20-2
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,11 @@ public interface Builder {
458458
Builder name(String name);
459459

460460
/**
461-
* Set the value of the {@literal filename} parameter.
461+
* Set the value of the {@literal filename} parameter. The given
462+
* filename will be formatted as quoted-string, as defined in RFC 2616,
463+
* section 2.2, and any quote characters within the filename value will
464+
* be escaped with a backslash, e.g. {@code "foo\"bar.txt"} becomes
465+
* {@code "foo\\\"bar.txt"}.
462466
*/
463467
Builder filename(String filename);
464468

@@ -539,10 +543,24 @@ public Builder name(String name) {
539543

540544
@Override
541545
public Builder filename(String filename) {
542-
this.filename = filename;
546+
Assert.hasText(filename, "No filename");
547+
this.filename = escapeQuotationMarks(filename);
543548
return this;
544549
}
545550

551+
private static String escapeQuotationMarks(String filename) {
552+
if (filename.indexOf('"') == -1) {
553+
return filename;
554+
}
555+
boolean escaped = false;
556+
StringBuilder sb = new StringBuilder();
557+
for (char c : filename.toCharArray()) {
558+
sb.append((c == '"' && !escaped) ? "\\\"" : c);
559+
escaped = (!escaped && c == '\\');
560+
}
561+
return sb.toString();
562+
}
563+
546564
@Override
547565
public Builder filename(String filename, Charset charset) {
548566
this.filename = filename;

spring-web/src/test/java/org/springframework/http/ContentDispositionTests.java

+60-19
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@
1919
import java.nio.charset.StandardCharsets;
2020
import java.time.ZonedDateTime;
2121
import java.time.format.DateTimeFormatter;
22+
import java.util.function.BiConsumer;
2223

2324
import org.junit.jupiter.api.Test;
2425

2526
import static org.assertj.core.api.Assertions.assertThat;
2627
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
28+
import static org.springframework.http.ContentDisposition.builder;
2729

2830
/**
2931
* Unit tests for {@link ContentDisposition}
@@ -38,7 +40,7 @@ public class ContentDispositionTests {
3840
@Test
3941
public void parse() {
4042
assertThat(parse("form-data; name=\"foo\"; filename=\"foo.txt\"; size=123"))
41-
.isEqualTo(ContentDisposition.builder("form-data")
43+
.isEqualTo(builder("form-data")
4244
.name("foo")
4345
.filename("foo.txt")
4446
.size(123L)
@@ -48,23 +50,23 @@ public void parse() {
4850
@Test
4951
public void parseFilenameUnquoted() {
5052
assertThat(parse("form-data; filename=unquoted"))
51-
.isEqualTo(ContentDisposition.builder("form-data")
53+
.isEqualTo(builder("form-data")
5254
.filename("unquoted")
5355
.build());
5456
}
5557

5658
@Test // SPR-16091
5759
public void parseFilenameWithSemicolon() {
5860
assertThat(parse("attachment; filename=\"filename with ; semicolon.txt\""))
59-
.isEqualTo(ContentDisposition.builder("attachment")
61+
.isEqualTo(builder("attachment")
6062
.filename("filename with ; semicolon.txt")
6163
.build());
6264
}
6365

6466
@Test
6567
public void parseEncodedFilename() {
6668
assertThat(parse("form-data; name=\"name\"; filename*=UTF-8''%E4%B8%AD%E6%96%87.txt"))
67-
.isEqualTo(ContentDisposition.builder("form-data")
69+
.isEqualTo(builder("form-data")
6870
.name("name")
6971
.filename("中文.txt", StandardCharsets.UTF_8)
7072
.build());
@@ -73,15 +75,15 @@ public void parseEncodedFilename() {
7375
@Test // gh-24112
7476
public void parseEncodedFilenameWithPaddedCharset() {
7577
assertThat(parse("attachment; filename*= UTF-8''some-file.zip"))
76-
.isEqualTo(ContentDisposition.builder("attachment")
78+
.isEqualTo(builder("attachment")
7779
.filename("some-file.zip", StandardCharsets.UTF_8)
7880
.build());
7981
}
8082

8183
@Test
8284
public void parseEncodedFilenameWithoutCharset() {
8385
assertThat(parse("form-data; name=\"name\"; filename*=test.txt"))
84-
.isEqualTo(ContentDisposition.builder("form-data")
86+
.isEqualTo(builder("form-data")
8587
.name("name")
8688
.filename("test.txt")
8789
.build());
@@ -104,18 +106,30 @@ public void parseEncodedFilenameWithInvalidName() {
104106

105107
@Test // gh-23077
106108
public void parseWithEscapedQuote() {
107-
assertThat(parse("form-data; name=\"file\"; filename=\"\\\"The Twilight Zone\\\".txt\"; size=123"))
108-
.isEqualTo(ContentDisposition.builder("form-data")
109-
.name("file")
110-
.filename("\\\"The Twilight Zone\\\".txt")
111-
.size(123L)
112-
.build());
109+
110+
BiConsumer<String, String> tester = (description, filename) -> {
111+
assertThat(parse("form-data; name=\"file\"; filename=\"" + filename + "\"; size=123"))
112+
.as(description)
113+
.isEqualTo(builder("form-data").name("file").filename(filename).size(123L).build());
114+
};
115+
116+
tester.accept("Escaped quotes should be ignored",
117+
"\\\"The Twilight Zone\\\".txt");
118+
119+
tester.accept("Escaped quotes preceded by escaped backslashes should be ignored",
120+
"\\\\\\\"The Twilight Zone\\\\\\\".txt");
121+
122+
tester.accept("Escaped backslashes should not suppress quote",
123+
"The Twilight Zone \\\\");
124+
125+
tester.accept("Escaped backslashes should not suppress quote",
126+
"The Twilight Zone \\\\\\\\");
113127
}
114128

115129
@Test
116130
public void parseWithExtraSemicolons() {
117131
assertThat(parse("form-data; name=\"foo\";; ; filename=\"foo.txt\"; size=123"))
118-
.isEqualTo(ContentDisposition.builder("form-data")
132+
.isEqualTo(builder("form-data")
119133
.name("foo")
120134
.filename("foo.txt")
121135
.size(123L)
@@ -133,7 +147,7 @@ public void parseDates() {
133147
"creation-date=\"" + creationTime.format(formatter) + "\"; " +
134148
"modification-date=\"" + modificationTime.format(formatter) + "\"; " +
135149
"read-date=\"" + readTime.format(formatter) + "\"")).isEqualTo(
136-
ContentDisposition.builder("attachment")
150+
builder("attachment")
137151
.creationDate(creationTime)
138152
.modificationDate(modificationTime)
139153
.readDate(readTime)
@@ -149,7 +163,7 @@ public void parseIgnoresInvalidDates() {
149163
"creation-date=\"-1\"; " +
150164
"modification-date=\"-1\"; " +
151165
"read-date=\"" + readTime.format(formatter) + "\"")).isEqualTo(
152-
ContentDisposition.builder("attachment")
166+
builder("attachment")
153167
.readDate(readTime)
154168
.build());
155169
}
@@ -177,7 +191,7 @@ private static ContentDisposition parse(String input) {
177191
@Test
178192
public void format() {
179193
assertThat(
180-
ContentDisposition.builder("form-data")
194+
builder("form-data")
181195
.name("foo")
182196
.filename("foo.txt")
183197
.size(123L)
@@ -188,7 +202,7 @@ public void format() {
188202
@Test
189203
public void formatWithEncodedFilename() {
190204
assertThat(
191-
ContentDisposition.builder("form-data")
205+
builder("form-data")
192206
.name("name")
193207
.filename("中文.txt", StandardCharsets.UTF_8)
194208
.build().toString())
@@ -198,18 +212,45 @@ public void formatWithEncodedFilename() {
198212
@Test
199213
public void formatWithEncodedFilenameUsingUsAscii() {
200214
assertThat(
201-
ContentDisposition.builder("form-data")
215+
builder("form-data")
202216
.name("name")
203217
.filename("test.txt", StandardCharsets.US_ASCII)
204218
.build()
205219
.toString())
206220
.isEqualTo("form-data; name=\"name\"; filename=\"test.txt\"");
207221
}
208222

223+
@Test // gh-24220
224+
public void formatWithFilenameWithQuotes() {
225+
226+
BiConsumer<String, String> tester = (input, output) -> {
227+
assertThat(builder("form-data").filename(input).build().toString())
228+
.isEqualTo("form-data; filename=\"" + output + "\"");
229+
};
230+
231+
String filename = "\"foo.txt";
232+
tester.accept(filename, "\\" + filename);
233+
234+
filename = "\\\"foo.txt";
235+
tester.accept(filename, filename);
236+
237+
filename = "\\\\\"foo.txt";
238+
tester.accept(filename, "\\" + filename);
239+
240+
filename = "\\\\\\\"foo.txt";
241+
tester.accept(filename, filename);
242+
243+
filename = "\\\\\\\\\"foo.txt";
244+
tester.accept(filename, "\\" + filename);
245+
246+
tester.accept("\"\"foo.txt", "\\\"\\\"foo.txt");
247+
tester.accept("\"\"\"foo.txt", "\\\"\\\"\\\"foo.txt");
248+
}
249+
209250
@Test
210251
public void formatWithEncodedFilenameUsingInvalidCharset() {
211252
assertThatIllegalArgumentException().isThrownBy(() ->
212-
ContentDisposition.builder("form-data")
253+
builder("form-data")
213254
.name("name")
214255
.filename("test.txt", StandardCharsets.UTF_16)
215256
.build()

0 commit comments

Comments
 (0)