Skip to content

Commit 85c5a0c

Browse files
committed
Escape quoted filename in HttpHeaders
This is primarily to align with similar changes applied to ContentDisposition in 5.x. Closes gh-24580
1 parent f60bb82 commit 85c5a0c

File tree

2 files changed

+97
-45
lines changed

2 files changed

+97
-45
lines changed

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

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2019 the original author or authors.
2+
* Copyright 2002-2020 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -672,7 +672,10 @@ public List<String> getConnection() {
672672

673673
/**
674674
* Set the {@code Content-Disposition} header when creating a
675-
* {@code "multipart/form-data"} request.
675+
* {@code "multipart/form-data"} request. The given filename is formatted
676+
* as a quoted-string, as defined in RFC 2616, section 2.2, and any quote
677+
* characters within the filename value will be escaped with a backslash,
678+
* e.g. {@code "foo\"bar.txt"} becomes {@code "foo\\\"bar.txt"}.
676679
* <p>Applications typically would not set this header directly but
677680
* rather prepare a {@code MultiValueMap<String, Object>}, containing an
678681
* Object or a {@link org.springframework.core.io.Resource} for each part,
@@ -686,7 +689,7 @@ public void setContentDispositionFormData(String name, String filename) {
686689
builder.append(name).append('\"');
687690
if (filename != null) {
688691
builder.append("; filename=\"");
689-
builder.append(filename).append('\"');
692+
builder.append(escapeQuotationsInFilename(filename)).append('\"');
690693
}
691694
set(CONTENT_DISPOSITION, builder.toString());
692695
}
@@ -707,20 +710,13 @@ public void setContentDispositionFormData(String name, String filename) {
707710
*/
708711
@Deprecated
709712
public void setContentDispositionFormData(String name, String filename, Charset charset) {
710-
Assert.notNull(name, "'name' must not be null");
711-
StringBuilder builder = new StringBuilder("form-data; name=\"");
712-
builder.append(name).append('\"');
713-
if (filename != null) {
714-
if (charset == null || charset.name().equals("US-ASCII")) {
715-
builder.append("; filename=\"");
716-
builder.append(filename).append('\"');
717-
}
718-
else {
719-
builder.append("; filename*=");
720-
builder.append(encodeHeaderFieldParam(filename, charset));
721-
}
713+
if (filename == null || charset == null || charset.name().equals("US-ASCII")) {
714+
setContentDispositionFormData(name, filename);
715+
return;
722716
}
723-
set(CONTENT_DISPOSITION, builder.toString());
717+
Assert.notNull(name, "'name' must not be null");
718+
String encodedFileName = encodeHeaderFieldParam(filename, charset);
719+
set(CONTENT_DISPOSITION, "form-data; name=\"" + name + '\"' + "; filename*=" + encodedFileName);
724720
}
725721

726722
/**
@@ -1324,19 +1320,34 @@ public static HttpHeaders readOnlyHttpHeaders(HttpHeaders headers) {
13241320
return new HttpHeaders(headers, true);
13251321
}
13261322

1323+
private static String escapeQuotationsInFilename(String filename) {
1324+
if (filename.indexOf('"') == -1 && filename.indexOf('\\') == -1) {
1325+
return filename;
1326+
}
1327+
boolean escaped = false;
1328+
StringBuilder sb = new StringBuilder();
1329+
for (char c : filename.toCharArray()) {
1330+
sb.append((c == '"' && !escaped) ? "\\\"" : c);
1331+
escaped = (!escaped && c == '\\');
1332+
}
1333+
// Remove backslash at the end..
1334+
if (escaped) {
1335+
sb.deleteCharAt(sb.length() - 1);
1336+
}
1337+
return sb.toString();
1338+
}
1339+
13271340
/**
13281341
* Encode the given header field param as describe in RFC 5987.
13291342
* @param input the header field param
13301343
* @param charset the charset of the header field param string
13311344
* @return the encoded header field param
13321345
* @see <a href="https://tools.ietf.org/html/rfc5987">RFC 5987</a>
13331346
*/
1334-
static String encodeHeaderFieldParam(String input, Charset charset) {
1347+
private static String encodeHeaderFieldParam(String input, Charset charset) {
13351348
Assert.notNull(input, "Input String should not be null");
13361349
Assert.notNull(charset, "Charset should not be null");
1337-
if (charset.name().equals("US-ASCII")) {
1338-
return input;
1339-
}
1350+
Assert.isTrue(!charset.name().equals("US-ASCII"), "ASCII does not require encoding");
13401351
Assert.isTrue(charset.name().equals("UTF-8") || charset.name().equals("ISO-8859-1"),
13411352
"Charset should be UTF-8 or ISO-8859-1");
13421353
byte[] source = input.getBytes(charset);

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

Lines changed: 66 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2016 the original author or authors.
2+
* Copyright 2002-2020 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -16,9 +16,13 @@
1616

1717
package org.springframework.http;
1818

19+
import org.hamcrest.Matchers;
20+
import org.junit.Test;
21+
1922
import java.net.URI;
2023
import java.net.URISyntaxException;
2124
import java.nio.charset.Charset;
25+
import java.nio.charset.StandardCharsets;
2226
import java.util.ArrayList;
2327
import java.util.Arrays;
2428
import java.util.Calendar;
@@ -28,12 +32,15 @@
2832
import java.util.List;
2933
import java.util.Locale;
3034
import java.util.TimeZone;
35+
import java.util.function.BiConsumer;
3136

32-
import org.hamcrest.Matchers;
33-
import org.junit.Test;
34-
35-
import static org.hamcrest.Matchers.*;
36-
import static org.junit.Assert.*;
37+
import static org.hamcrest.Matchers.is;
38+
import static org.junit.Assert.assertEquals;
39+
import static org.junit.Assert.assertFalse;
40+
import static org.junit.Assert.assertNull;
41+
import static org.junit.Assert.assertThat;
42+
import static org.junit.Assert.assertTrue;
43+
import static org.junit.Assert.fail;
3744

3845
/**
3946
* Unit tests for {@link org.springframework.http.HttpHeaders}.
@@ -311,21 +318,70 @@ public void cacheControlAllValues() {
311318
assertThat(headers.getCacheControl(), is("max-age=1000, public, s-maxage=1000"));
312319
}
313320

314-
@SuppressWarnings("deprecation")
315321
@Test
316322
public void contentDisposition() {
317323
headers.setContentDispositionFormData("name", null);
318324
assertEquals("Invalid Content-Disposition header", "form-data; name=\"name\"",
319325
headers.getFirst("Content-Disposition"));
320326

321-
headers.setContentDispositionFormData("name", "filename");
322-
assertEquals("Invalid Content-Disposition header", "form-data; name=\"name\"; filename=\"filename\"",
327+
headers.setContentDispositionFormData("name", "foo.txt");
328+
assertEquals("Invalid Content-Disposition header", "form-data; name=\"name\"; filename=\"foo.txt\"",
323329
headers.getFirst("Content-Disposition"));
330+
}
324331

325-
headers.setContentDispositionFormData("name", "中文.txt", Charset.forName("UTF-8"));
332+
@SuppressWarnings("deprecation")
333+
@Test // SPR-14547
334+
public void contentDispositionWithCharset() {
335+
336+
headers.setContentDispositionFormData("name", "foo.txt", StandardCharsets.US_ASCII);
337+
assertEquals("Invalid Content-Disposition header", "form-data; name=\"name\"; filename=\"foo.txt\"",
338+
headers.getFirst("Content-Disposition"));
339+
340+
headers.setContentDispositionFormData("name", "中文.txt", StandardCharsets.UTF_8);
326341
assertEquals("Invalid Content-Disposition header",
327342
"form-data; name=\"name\"; filename*=UTF-8''%E4%B8%AD%E6%96%87.txt",
328343
headers.getFirst("Content-Disposition"));
344+
345+
try {
346+
headers.setContentDispositionFormData("name", "foo.txt", StandardCharsets.UTF_16);
347+
fail();
348+
}
349+
catch (IllegalArgumentException ex) {
350+
// expected
351+
}
352+
}
353+
354+
@Test // gh-24580
355+
public void contentDispositionWithFilenameWithQuotes() {
356+
BiConsumer<String, String> tester = (filenameIn, filenameOut) -> {
357+
headers.setContentDispositionFormData("name", filenameIn);
358+
assertEquals("form-data; name=\"name\"; filename=\"" + filenameOut + "\"",
359+
headers.getFirst("Content-Disposition"));
360+
};
361+
362+
tester.accept("foo.txt", "foo.txt");
363+
364+
String filename = "\"foo.txt";
365+
tester.accept(filename, "\\" + filename);
366+
367+
filename = "\\\"foo.txt";
368+
tester.accept(filename, filename);
369+
370+
filename = "\\\\\"foo.txt";
371+
tester.accept(filename, "\\" + filename);
372+
373+
filename = "\\\\\\\"foo.txt";
374+
tester.accept(filename, filename);
375+
376+
filename = "\\\\\\\\\"foo.txt";
377+
tester.accept(filename, "\\" + filename);
378+
379+
tester.accept("\"\"foo.txt", "\\\"\\\"foo.txt");
380+
tester.accept("\"\"\"foo.txt", "\\\"\\\"\\\"foo.txt");
381+
382+
tester.accept("foo.txt\\", "foo.txt");
383+
tester.accept("foo.txt\\\\", "foo.txt\\\\");
384+
tester.accept("foo.txt\\\\\\", "foo.txt\\\\");
329385
}
330386

331387
@Test // SPR-11917
@@ -409,19 +465,4 @@ public void accessControlRequestMethod() {
409465
headers.setAccessControlRequestMethod(HttpMethod.POST);
410466
assertEquals(HttpMethod.POST, headers.getAccessControlRequestMethod());
411467
}
412-
413-
@Test // SPR-14547
414-
public void encodeHeaderFieldParam() {
415-
String result = HttpHeaders.encodeHeaderFieldParam("test.txt", Charset.forName("US-ASCII"));
416-
assertEquals("test.txt", result);
417-
418-
result = HttpHeaders.encodeHeaderFieldParam("中文.txt", Charset.forName("UTF-8"));
419-
assertEquals("UTF-8''%E4%B8%AD%E6%96%87.txt", result);
420-
}
421-
422-
@Test(expected = IllegalArgumentException.class)
423-
public void encodeHeaderFieldParamInvalidCharset() {
424-
HttpHeaders.encodeHeaderFieldParam("test", Charset.forName("UTF-16"));
425-
}
426-
427468
}

0 commit comments

Comments
 (0)