Skip to content

Commit a3627a6

Browse files
milanmajchrakJohnnyMendesCclaude
authored
UFAL/fix: RFC 5987 Content-Disposition for single-file + allzip download (#1368)
* fix(DSpace#11191): Align Content-Disposition with RFC 5987/6266 (cherry picked from commit fe4077a) * fix: use RFC 5987 Content-Disposition for allzip and download-by-handle Ports the allzip fix from customer/zcu-data (#1267) to dtq-dev and aligns the fork's own endpoints with the encoding vanilla now uses. The allzip endpoint still built its header with a bare `attachment;filename="<name>"`, so item names with diacritics reached the browser mangled and names containing a double quote closed the quoted-string early (ERR_RESPONSE_HEADERS_MULTIPLE_CONTENT_DISPOSITION). MetadataBitstreamController and BitstreamByHandleRestController have no counterpart upstream, so each carries its own private copy of vanilla's createFallbackAsciiName / createEncodedUtf8Name rather than a shared fork utility. Copying keeps every endpoint tracking upstream behaviour and adds no fork-invented API to maintain. HttpHeadersInitializer stays byte-identical to vanilla and keeps its own copy for the same reason. One deliberate deviation from vanilla, marked in both copies: the ASCII fallback escapes \ and ". Vanilla omits this, so a name containing a quote closes the quoted-string early — exactly the bug #1267 was raised for. Because the fallback now transliterates instead of blanking out, BitstreamByHandleRestControllerIT expects "Media (3).jfif" where it used to expect "M_di_ (3).jfif". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: align by-handle Content-Disposition expectations with the vanilla ASCII fallback The fallback transliterates (NFD + strip combining marks + drop non-ASCII), it does not substitute underscores. Two expectations in this file still assumed the old underscore output. * fix: escape backslash and double quote in the ASCII Content-Disposition fallback A bitstream name containing a double quote closed the quoted-string early and produced an invalid Content-Disposition, which browsers reject. The allzip and by-handle paths in this branch already escape; this brings the single-file download path in line and adds an IT for it. This is a deliberate deviation from vanilla HttpHeadersInitializer, which still has the bug. Also fixes the continuation indent of an expected value in the same IT. * chore: drop the tracker reference from the Content-Disposition deviation comments --------- Co-authored-by: JohnnyMendesC <177888064+JohnnyMendesC@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f27d932 commit a3627a6

6 files changed

Lines changed: 255 additions & 28 deletions

File tree

dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamByHandleRestController.java

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import java.net.URLEncoder;
1616
import java.nio.charset.StandardCharsets;
1717
import java.sql.SQLException;
18+
import java.text.Normalizer;
1819
import java.util.List;
1920
import java.util.Objects;
2021
import javax.servlet.http.HttpServletRequest;
@@ -279,25 +280,50 @@ private void redirectToS3DownloadUrl(String bitName, String bitInternalId,
279280
}
280281

281282
/**
282-
* Build a Content-Disposition header value using RFC 5987 encoding.
283-
* Includes both {@code filename} (ASCII fallback) and {@code filename*}
284-
* (UTF-8 percent-encoded) so that curl -J and browsers can save files
285-
* with non-ASCII characters in the name correctly.
286-
*
287-
* @param name the original filename
288-
* @return the Content-Disposition header value
283+
* Build the Content-Disposition value the way vanilla's HttpHeadersInitializer does: an ASCII
284+
* fallback in {@code filename} for clients that predate RFC 5987, plus the real UTF-8 name in
285+
* {@code filename*} for everyone else. This endpoint has no upstream counterpart, so the logic
286+
* is copied from vanilla rather than shared, to keep it tracking upstream's behaviour.
287+
* curl -J on Windows cannot create files with non-ASCII characters from a raw UTF-8 header,
288+
* which is why this endpoint needs it too.
289289
*/
290290
private String buildContentDisposition(String name) {
291-
// RFC 5987 percent-encoding for filename*
292-
String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8)
293-
.replace("+", "%20");
294-
// ASCII fallback: replace non-ASCII chars with underscore, escape quotes.
295-
// Modern clients use filename* (RFC 5987 / RFC 6266) with real UTF-8 name.
296-
String asciiFallback = name.replaceAll("[^\\x20-\\x7E]", "_")
291+
return String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s",
292+
createFallbackAsciiName(name), createEncodedUtf8Name(name));
293+
}
294+
295+
/**
296+
* Creates a safe ASCII-only fallback filename by removing diacritics (accents)
297+
* and replacing any remaining non-ASCII characters.
298+
* E.g., "ä-ö-é.pdf" becomes "a-o-e.pdf".
299+
* @param originalFilename The original filename.
300+
* @return A string containing only ASCII characters.
301+
*/
302+
private String createFallbackAsciiName(String originalFilename) {
303+
if (originalFilename == null) {
304+
return "";
305+
}
306+
String normalized = Normalizer.normalize(originalFilename, Normalizer.Form.NFD);
307+
String withoutAccents = normalized.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
308+
// Deviates from vanilla by escaping \ and ": the value is a quoted-string, and a name
309+
// containing a quote closes it early. Vanilla still has that bug.
310+
return withoutAccents.replaceAll("[^\\x00-\\x7F]", "")
297311
.replace("\\", "\\\\")
298312
.replace("\"", "\\\"");
299-
return String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s",
300-
asciiFallback, encoded);
313+
}
314+
315+
/**
316+
* Creates a percent-encoded UTF-8 filename according to RFC 5987.
317+
* This is for the `filename*` parameter.
318+
* E.g., "ä ö é.pdf" becomes "%C3%A4%20%C3%B6%20%C3%A9.pdf".
319+
* @param originalFilename The original filename.
320+
* @return A percent-encoded string.
321+
*/
322+
private String createEncodedUtf8Name(String originalFilename) {
323+
if (originalFilename == null) {
324+
return "";
325+
}
326+
return URLEncoder.encode(originalFilename, StandardCharsets.UTF_8).replace("+", "%20");
301327
}
302328

303329
/**

dspace-server-webapp/src/main/java/org/dspace/app/rest/MetadataBitstreamController.java

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111

1212
import java.io.IOException;
1313
import java.io.InputStream;
14+
import java.net.URLEncoder;
15+
import java.nio.charset.StandardCharsets;
1416
import java.sql.SQLException;
17+
import java.text.Normalizer;
1518
import java.util.List;
1619
import java.util.Objects;
1720
import java.util.UUID;
@@ -115,7 +118,7 @@ public void downloadFileZip(@PathVariable UUID uuid, @RequestParam("handleId") S
115118
// This bitstream is used to get it's item in the statistics tracker
116119
Bitstream bitstreamForStatistics = null;
117120
name = item.getName() + ".zip";
118-
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, String.format("attachment;filename=\"%s\"", name));
121+
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, buildContentDisposition(name));
119122
response.setContentType("application/zip");
120123
List<Bundle> bundles = item.getBundles("ORIGINAL");
121124

@@ -143,4 +146,49 @@ public void downloadFileZip(@PathVariable UUID uuid, @RequestParam("handleId") S
143146
matomoBitstreamTracker.trackBitstreamDownload(context, request, bitstreamForStatistics, true);
144147
response.getOutputStream().flush();
145148
}
149+
150+
/**
151+
* Build the Content-Disposition value the way vanilla's HttpHeadersInitializer does: an ASCII
152+
* fallback in {@code filename} for clients that predate RFC 5987, plus the real UTF-8 name in
153+
* {@code filename*} for everyone else. This endpoint has no upstream counterpart, so the logic
154+
* is copied from vanilla rather than shared, to keep it tracking upstream's behaviour.
155+
*/
156+
private String buildContentDisposition(String name) {
157+
return String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s",
158+
createFallbackAsciiName(name), createEncodedUtf8Name(name));
159+
}
160+
161+
/**
162+
* Creates a safe ASCII-only fallback filename by removing diacritics (accents)
163+
* and replacing any remaining non-ASCII characters.
164+
* E.g., "ä-ö-é.pdf" becomes "a-o-e.pdf".
165+
* @param originalFilename The original filename.
166+
* @return A string containing only ASCII characters.
167+
*/
168+
private String createFallbackAsciiName(String originalFilename) {
169+
if (originalFilename == null) {
170+
return "";
171+
}
172+
String normalized = Normalizer.normalize(originalFilename, Normalizer.Form.NFD);
173+
String withoutAccents = normalized.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
174+
// Deviates from vanilla by escaping \ and ": the value is a quoted-string, and an item name
175+
// containing a quote closes it early. Vanilla still has that bug.
176+
return withoutAccents.replaceAll("[^\\x00-\\x7F]", "")
177+
.replace("\\", "\\\\")
178+
.replace("\"", "\\\"");
179+
}
180+
181+
/**
182+
* Creates a percent-encoded UTF-8 filename according to RFC 5987.
183+
* This is for the `filename*` parameter.
184+
* E.g., "ä ö é.pdf" becomes "%C3%A4%20%C3%B6%20%C3%A9.pdf".
185+
* @param originalFilename The original filename.
186+
* @return A percent-encoded string.
187+
*/
188+
private String createEncodedUtf8Name(String originalFilename) {
189+
if (originalFilename == null) {
190+
return "";
191+
}
192+
return URLEncoder.encode(originalFilename, StandardCharsets.UTF_8).replace("+", "%20");
193+
}
146194
}

dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99

1010
import static java.util.Objects.isNull;
1111
import static java.util.Objects.nonNull;
12-
import static javax.mail.internet.MimeUtility.encodeText;
1312

1413
import java.io.IOException;
14+
import java.net.URLEncoder;
15+
import java.nio.charset.StandardCharsets;
16+
import java.text.Normalizer;
1517
import java.util.Arrays;
1618
import java.util.Collections;
1719
import java.util.Objects;
@@ -171,9 +173,16 @@ public HttpHeaders initialiseHeaders() throws IOException {
171173

172174
// distposition may be null here if contentType is null
173175
if (!isNullOrEmpty(disposition)) {
174-
httpHeaders.put(CONTENT_DISPOSITION, Collections.singletonList(String.format(CONTENT_DISPOSITION_FORMAT,
175-
disposition,
176-
encodeText(fileName))));
176+
String fallbackAsciiName = createFallbackAsciiName(this.fileName);
177+
String encodedUtf8Name = createEncodedUtf8Name(this.fileName);
178+
179+
String headerValue = String.format(
180+
"%s; filename=\"%s\"; filename*=UTF-8''%s",
181+
disposition,
182+
fallbackAsciiName,
183+
encodedUtf8Name
184+
);
185+
httpHeaders.put(CONTENT_DISPOSITION, Collections.singletonList(headerValue));
177186
}
178187
log.debug("Content-Disposition : {}", disposition);
179188

@@ -261,4 +270,43 @@ private static boolean matches(String matchHeader, String toMatch) {
261270
return Arrays.binarySearch(matchValues, toMatch) > -1 || Arrays.binarySearch(matchValues, "*") > -1;
262271
}
263272

273+
/**
274+
* Creates a safe ASCII-only fallback filename by removing diacritics (accents)
275+
* and replacing any remaining non-ASCII characters.
276+
* E.g., "ä-ö-é.pdf" becomes "a-o-e.pdf".
277+
* @param originalFilename The original filename.
278+
* @return A string containing only ASCII characters.
279+
*/
280+
private String createFallbackAsciiName(String originalFilename) {
281+
if (originalFilename == null) {
282+
return "";
283+
}
284+
String normalized = Normalizer.normalize(originalFilename, Normalizer.Form.NFD);
285+
String withoutAccents = normalized.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
286+
return withoutAccents.replaceAll("[^\\x00-\\x7F]", "")
287+
.replace("\\", "\\\\")
288+
.replace("\"", "\\\"");
289+
}
290+
291+
/**
292+
* Creates a percent-encoded UTF-8 filename according to RFC 5987.
293+
* This is for the `filename*` parameter.
294+
* E.g., "ä ö é.pdf" becomes "%C3%A4%20%C3%B6%20%C3%A9.pdf".
295+
* @param originalFilename The original filename.
296+
* @return A percent-encoded string.
297+
*/
298+
private String createEncodedUtf8Name(String originalFilename) {
299+
if (originalFilename == null) {
300+
return "";
301+
}
302+
try {
303+
String encoded = URLEncoder.encode(originalFilename, StandardCharsets.UTF_8.toString());
304+
return encoded.replace("+", "%20");
305+
} catch (java.io.UnsupportedEncodingException e) {
306+
// Fallback to a simple ASCII name if encoding fails.
307+
log.error("UTF-8 encoding not supported, which should not happen.", e);
308+
return createFallbackAsciiName(originalFilename);
309+
}
310+
}
311+
264312
}

dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamByHandleRestControllerIT.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -260,8 +260,8 @@ public void downloadBitstreamByHandleUtf8Filename() throws Exception {
260260
+ "/M%C3%A9di%C3%A1%20(3).jfif")))
261261
.andExpect(status().isOk())
262262
.andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
263-
// ASCII fallback replaces non-ASCII with underscore; filename* has UTF-8 encoding
264-
equalTo("attachment; filename=\"M_di_ (3).jfif\"; "
263+
// ASCII fallback transliterates the diacritics away; filename* keeps the real name
264+
equalTo("attachment; filename=\"Media (3).jfif\"; "
265265
+ "filename*=UTF-8''M%C3%A9di%C3%A1%20%283%29.jfif")))
266266
.andExpect(content().string(bitstreamContent));
267267
}
@@ -512,8 +512,8 @@ public void downloadBitstreamByHandleCjkFilename() throws Exception {
512512
+ "/%E6%97%A5%E6%9C%AC%E8%AA%9E.txt")))
513513
.andExpect(status().isOk())
514514
.andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
515-
// CJK chars replaced with _ in ASCII fallback; filename* has UTF-8 encoding
516-
equalTo("attachment; filename=\"___.txt\"; "
515+
// CJK has no ASCII decomposition, so it drops out of the fallback entirely
516+
equalTo("attachment; filename=\".txt\"; "
517517
+ "filename*=UTF-8''%E6%97%A5%E6%9C%AC%E8%AA%9E.txt")))
518518
.andExpect(content().string(bitstreamContent));
519519
}
@@ -592,7 +592,7 @@ public void downloadBitstreamByHandleComplexFilename() throws Exception {
592592
+ "/M%C3%A9di%C3%A1%20(%2B)%239)%20ano")))
593593
.andExpect(status().isOk())
594594
.andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
595-
equalTo("attachment; filename=\"M_di_ (+)#9) ano\"; "
595+
equalTo("attachment; filename=\"Media (+)#9) ano\"; "
596596
+ "filename*=UTF-8''M%C3%A9di%C3%A1%20%28%2B%29%239%29%20ano")))
597597
.andExpect(content().string(bitstreamContent));
598598
}

dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestControllerIT.java

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
package org.dspace.app.rest;
99

1010
import static java.util.UUID.randomUUID;
11-
import static javax.mail.internet.MimeUtility.encodeText;
1211
import static org.apache.commons.codec.CharEncoding.UTF_8;
1312
import static org.apache.commons.collections.CollectionUtils.isEmpty;
1413
import static org.apache.commons.io.IOUtils.toInputStream;
@@ -364,7 +363,10 @@ public void testBitstreamName() throws Exception {
364363
//2. A public item with a bitstream
365364

366365
String bitstreamContent = "0123456789";
367-
String bitstreamName = "ภาษาไทย";
366+
String bitstreamName = "ภาษาไทย-com-acentuação.pdf";
367+
String expectedAscii = "-com-acentuacao.pdf";
368+
String expectedUtf8Encoded = "%E0%B8%A0%E0%B8%B2%E0%B8%A9%E0%B8%B2%E0%B9%84%E0%B8%97%E0%B8%A2-"
369+
+ "com-acentua%C3%A7%C3%A3o.pdf";
368370

369371
try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) {
370372

@@ -388,7 +390,51 @@ public void testBitstreamName() throws Exception {
388390
//We expect the content disposition to have the encoded bitstream name
389391
.andExpect(header().string(
390392
"Content-Disposition",
391-
"attachment;filename=\"" + encodeText(bitstreamName) + "\""
393+
String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s",
394+
expectedAscii,
395+
expectedUtf8Encoded)
396+
));
397+
}
398+
399+
@Test
400+
public void testBitstreamNameWithQuote() throws Exception {
401+
402+
context.turnOffAuthorisationSystem();
403+
404+
parentCommunity = CommunityBuilder
405+
.createCommunity(context)
406+
.build();
407+
408+
Collection collection = CollectionBuilder
409+
.createCollection(context, parentCommunity)
410+
.build();
411+
412+
String bitstreamContent = "0123456789";
413+
String bitstreamName = "file \"quoted\".txt";
414+
String expectedAscii = "file \\\"quoted\\\".txt";
415+
String expectedUtf8Encoded = "file%20%22quoted%22.txt";
416+
417+
try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) {
418+
419+
Item item = ItemBuilder
420+
.createItem(context, collection)
421+
.build();
422+
423+
bitstream = BitstreamBuilder
424+
.createBitstream(context, item, is)
425+
.withName(bitstreamName)
426+
.build();
427+
}
428+
429+
context.restoreAuthSystemState();
430+
431+
getClient().perform(get("/api/core/bitstreams/" + bitstream.getID() + "/content"))
432+
.andExpect(status().isOk())
433+
.andExpect(header().string(
434+
"Content-Disposition",
435+
String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s",
436+
expectedAscii,
437+
expectedUtf8Encoded)
392438
));
393439
}
394440

dspace-server-webapp/src/test/java/org/dspace/app/rest/MetadataBitstreamControllerIT.java

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import static org.junit.Assert.assertEquals;
1111
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
12+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
1213
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
1314

1415
import java.io.ByteArrayInputStream;
@@ -104,4 +105,62 @@ public void downloadAllZip() throws Exception {
104105
assertEquals(Set.of(bts.getName()), entries.keySet());
105106
assertEquals(BITSTREAM_CONTENT, entries.get(bts.getName()));
106107
}
108+
109+
@Test
110+
public void downloadAllZipWithDoubleQuotesInItemName() throws Exception {
111+
context.turnOffAuthorisationSystem();
112+
113+
// Double quotes in the name used to close the header's quoted-string early, which browsers
114+
// reported as ERR_RESPONSE_HEADERS_MULTIPLE_CONTENT_DISPOSITION.
115+
Item itemWithQuotes = ItemBuilder.createItem(context, col)
116+
.withTitle("Supported data for manuscript \"Thermally-induced evolution\"")
117+
.withAuthor(AUTHOR)
118+
.build();
119+
120+
try (InputStream is = IOUtils.toInputStream("QuotedItemContent", CharEncoding.UTF_8)) {
121+
BitstreamBuilder.createBitstream(context, itemWithQuotes, is)
122+
.withName("data.csv")
123+
.withMimeType("text/csv")
124+
.build();
125+
}
126+
context.restoreAuthSystemState();
127+
128+
String token = getAuthToken(admin.getEmail(), password);
129+
getClient(token).perform(get(METADATABITSTREAM_ENDPOINT + "/" + itemWithQuotes.getID() +
130+
"/" + ALL_ZIP_PATH).param(HANDLE_PARAM, itemWithQuotes.getHandle()))
131+
.andExpect(status().isOk())
132+
.andExpect(header().string("Content-Disposition",
133+
"attachment; filename=\"Supported data for manuscript"
134+
+ " \\\"Thermally-induced evolution\\\".zip\";"
135+
+ " filename*=UTF-8''Supported%20data%20for%20manuscript"
136+
+ "%20%22Thermally-induced%20evolution%22.zip"));
137+
}
138+
139+
@Test
140+
public void downloadAllZipWithNonAsciiItemName() throws Exception {
141+
context.turnOffAuthorisationSystem();
142+
143+
Item itemWithDiacritics = ItemBuilder.createItem(context, col)
144+
.withTitle("Příliš žluťoučký kůň")
145+
.withAuthor(AUTHOR)
146+
.build();
147+
148+
try (InputStream is = IOUtils.toInputStream("DiacriticsContent", CharEncoding.UTF_8)) {
149+
BitstreamBuilder.createBitstream(context, itemWithDiacritics, is)
150+
.withName("file.txt")
151+
.withMimeType("text/plain")
152+
.build();
153+
}
154+
context.restoreAuthSystemState();
155+
156+
String token = getAuthToken(admin.getEmail(), password);
157+
getClient(token).perform(get(METADATABITSTREAM_ENDPOINT + "/" + itemWithDiacritics.getID() +
158+
"/" + ALL_ZIP_PATH).param(HANDLE_PARAM, itemWithDiacritics.getHandle()))
159+
.andExpect(status().isOk())
160+
// fallback transliterates the diacritics away; filename* carries the real name
161+
.andExpect(header().string("Content-Disposition",
162+
"attachment; filename=\"Prilis zlutoucky kun.zip\";"
163+
+ " filename*=UTF-8''P%C5%99%C3%ADli%C5%A1%20%C5%BElu%C5%A5ou%C4%8Dk%C3%BD"
164+
+ "%20k%C5%AF%C5%88.zip"));
165+
}
107166
}

0 commit comments

Comments
 (0)