From c949e6a66970746252a338cae7104eebeea25c45 Mon Sep 17 00:00:00 2001 From: JohnnyMendesC <177888064+JohnnyMendesC@users.noreply.github.com> Date: Thu, 4 Sep 2025 11:10:46 -0300 Subject: [PATCH 1/3] fix(#11191): Align Content-Disposition with RFC 5987/6266 (cherry picked from commit fe4077acee30fac8fb5607821a7d6e1ee3bd9d88) (cherry picked from commit f3179117d46a466c30289c2679b408389bd78e1f) (cherry picked from commit 9b6318114bb77374e0e5deef53dfc6c9222fb418) --- .../rest/utils/HttpHeadersInitializer.java | 54 +++++++++++++++++-- .../app/rest/BitstreamRestControllerIT.java | 11 ++-- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java index a69da4c5e86..0ed36e954a3 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java @@ -9,9 +9,11 @@ import static java.util.Objects.isNull; import static java.util.Objects.nonNull; -import static javax.mail.internet.MimeUtility.encodeText; import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.text.Normalizer; import java.util.Arrays; import java.util.Collections; import javax.servlet.http.HttpServletRequest; @@ -165,9 +167,16 @@ public HttpHeaders initialiseHeaders() throws IOException { httpHeaders.put(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, Collections.singletonList(HttpHeaders.ACCEPT_RANGES)); - httpHeaders.put(CONTENT_DISPOSITION, Collections.singletonList(String.format(CONTENT_DISPOSITION_FORMAT, - disposition, - encodeText(fileName)))); + String fallbackAsciiName = createFallbackAsciiName(this.fileName); + String encodedUtf8Name = createEncodedUtf8Name(this.fileName); + + String headerValue = String.format( + "%s; filename=\"%s\"; filename*=UTF-8''%s", + disposition, + fallbackAsciiName, + encodedUtf8Name + ); + httpHeaders.put(CONTENT_DISPOSITION, Collections.singletonList(headerValue)); log.debug("Content-Disposition : {}", disposition); // Content phase @@ -260,4 +269,41 @@ private static boolean matches(String matchHeader, String toMatch) { return Arrays.binarySearch(matchValues, toMatch) > -1 || Arrays.binarySearch(matchValues, "*") > -1; } + /** + * Creates a safe ASCII-only fallback filename by removing diacritics (accents) + * and replacing any remaining non-ASCII characters. + * E.g., "ä-ö-é.pdf" becomes "a-o-e.pdf". + * @param originalFilename The original filename. + * @return A string containing only ASCII characters. + */ + private String createFallbackAsciiName(String originalFilename) { + if (originalFilename == null) { + return ""; + } + String normalized = Normalizer.normalize(originalFilename, Normalizer.Form.NFD); + String withoutAccents = normalized.replaceAll("\\p{InCombiningDiacriticalMarks}+", ""); + return withoutAccents.replaceAll("[^\\x00-\\x7F]", ""); + } + + /** + * Creates a percent-encoded UTF-8 filename according to RFC 5987. + * This is for the `filename*` parameter. + * E.g., "ä ö é.pdf" becomes "%C3%A4%20%C3%B6%20%C3%A9.pdf". + * @param originalFilename The original filename. + * @return A percent-encoded string. + */ + private String createEncodedUtf8Name(String originalFilename) { + if (originalFilename == null) { + return ""; + } + try { + String encoded = URLEncoder.encode(originalFilename, StandardCharsets.UTF_8.toString()); + return encoded.replace("+", "%20"); + } catch (java.io.UnsupportedEncodingException e) { + // Fallback to a simple ASCII name if encoding fails. + log.error("UTF-8 encoding not supported, which should not happen.", e); + return createFallbackAsciiName(originalFilename); + } + } + } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestControllerIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestControllerIT.java index 53ce060f31d..60fbfec6ba2 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestControllerIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestControllerIT.java @@ -8,7 +8,6 @@ package org.dspace.app.rest; import static java.util.UUID.randomUUID; -import static javax.mail.internet.MimeUtility.encodeText; import static org.apache.commons.codec.CharEncoding.UTF_8; import static org.apache.commons.collections.CollectionUtils.isEmpty; import static org.apache.commons.io.IOUtils.toInputStream; @@ -332,7 +331,11 @@ public void testBitstreamName() throws Exception { //2. A public item with a bitstream String bitstreamContent = "0123456789"; - String bitstreamName = "ภาษาไทย"; + String bitstreamName = "ภาษาไทย-com-acentuação.pdf"; + String expectedAscii = "-com-acentuacao.pdf"; + String expectedUtf8Encoded = + "%E0%B8%A0%E0%B8%B2%E0%B8%A9%E0%B8%B2%E0%B9%84%E0%B8%97%E0%B8%A2-" + + "com-acentua%C3%A7%C3%A3o.pdf"; try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { @@ -356,7 +359,9 @@ public void testBitstreamName() throws Exception { //We expect the content disposition to have the encoded bitstream name .andExpect(header().string( "Content-Disposition", - "attachment;filename=\"" + encodeText(bitstreamName) + "\"" + String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s", + expectedAscii, + expectedUtf8Encoded) )); } From 7a7c1e299ebe517e35bdcd9289378a8ef4c6a426 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Thu, 16 Jul 2026 11:34:55 +0200 Subject: [PATCH 2/3] ZCU-DATA/fix: use RFC 5987 Content-Disposition for single-file download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of the single-file half of #1368 (dtq-dev) to customer/zcu-data. allzip was already fixed here by #1267, but single-file download was not: HttpHeadersInitializer encoded the name with MimeUtility.encodeText, i.e. RFC 2047 encoded-words, which RFC 6266 Appendix C.1 forbids in HTTP. Safari shows the raw =?UTF-8?Q?...?= string; Chrome and Firefox decode it anyway, which is why this looked intermittent. Also aligns the private buildContentDisposition added by #1267 with vanilla's createFallbackAsciiName / createEncodedUtf8Name, so allzip and single-file render the same way and both track upstream. The fallback now transliterates rather than blanking out, so it reads "Prilis zlutoucky kun.zip" instead of "P__li_ _lu_ou_k_ k__.zip"; the IT from #1267 is updated accordingly. Only clients that ignore filename* ever see that value. The escaping of \ and " that #1267 added is kept, and marked in the code as a deliberate deviation — vanilla omits it and still has that bug. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/rest/MetadataBitstreamController.java | 47 +++++++++++++++---- .../rest/MetadataBitstreamControllerIT.java | 4 +- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/MetadataBitstreamController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/MetadataBitstreamController.java index 87adfbaa227..a5fa455ab1a 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/MetadataBitstreamController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/MetadataBitstreamController.java @@ -14,6 +14,7 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.sql.SQLException; +import java.text.Normalizer; import java.util.List; import java.util.Objects; import java.util.UUID; @@ -138,17 +139,47 @@ public void downloadFileZip(@PathVariable UUID uuid, @RequestParam("handleId") S } /** - * Build a Content-Disposition header value using RFC 5987 encoding. - * Includes both {@code filename} (ASCII fallback with escaped quotes) and {@code filename*} - * (UTF-8 percent-encoded) so that browsers can save files with special characters correctly. + * Build the Content-Disposition value the way vanilla's HttpHeadersInitializer does: an ASCII + * fallback in {@code filename} for clients that predate RFC 5987, plus the real UTF-8 name in + * {@code filename*} for everyone else. This endpoint has no upstream counterpart, so the logic + * is copied from vanilla rather than shared, to keep it tracking upstream's behaviour. */ private String buildContentDisposition(String name) { - String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8) - .replace("+", "%20"); - String asciiFallback = name.replaceAll("[^\\x20-\\x7E]", "_") + return String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s", + createFallbackAsciiName(name), createEncodedUtf8Name(name)); + } + + /** + * Creates a safe ASCII-only fallback filename by removing diacritics (accents) + * and replacing any remaining non-ASCII characters. + * E.g., "ä-ö-é.pdf" becomes "a-o-e.pdf". + * @param originalFilename The original filename. + * @return A string containing only ASCII characters. + */ + private String createFallbackAsciiName(String originalFilename) { + if (originalFilename == null) { + return ""; + } + String normalized = Normalizer.normalize(originalFilename, Normalizer.Form.NFD); + String withoutAccents = normalized.replaceAll("\\p{InCombiningDiacriticalMarks}+", ""); + // Deviates from vanilla by escaping \ and ": the value is a quoted-string, and an item name + // containing a quote closes it early. That is the bug #1267 fixed; vanilla still has it. + return withoutAccents.replaceAll("[^\\x00-\\x7F]", "") .replace("\\", "\\\\") .replace("\"", "\\\""); - return String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s", - asciiFallback, encoded); + } + + /** + * Creates a percent-encoded UTF-8 filename according to RFC 5987. + * This is for the `filename*` parameter. + * E.g., "ä ö é.pdf" becomes "%C3%A4%20%C3%B6%20%C3%A9.pdf". + * @param originalFilename The original filename. + * @return A percent-encoded string. + */ + private String createEncodedUtf8Name(String originalFilename) { + if (originalFilename == null) { + return ""; + } + return URLEncoder.encode(originalFilename, StandardCharsets.UTF_8).replace("+", "%20"); } } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/MetadataBitstreamControllerIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/MetadataBitstreamControllerIT.java index 54be368ffc0..0f4baa306b5 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/MetadataBitstreamControllerIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/MetadataBitstreamControllerIT.java @@ -154,9 +154,9 @@ public void downloadAllZipWithNonAsciiItemName() throws Exception { getClient(token).perform(get(METADATABITSTREAM_ENDPOINT + "/" + itemWithDiacritics.getID() + "/" + ALL_ZIP_PATH).param(HANDLE_PARAM, itemWithDiacritics.getHandle())) .andExpect(status().isOk()) - // Non-ASCII chars replaced with _ in filename, full UTF-8 in filename* + // fallback transliterates the diacritics away; filename* carries the real name .andExpect(header().string("Content-Disposition", - "attachment; filename=\"P__li_ _lu_ou_k_ k__.zip\";" + "attachment; filename=\"Prilis zlutoucky kun.zip\";" + " filename*=UTF-8''P%C5%99%C3%ADli%C5%A1%20%C5%BElu%C5%A5ou%C4%8Dk%C3%BD" + "%20k%C5%AF%C5%88.zip")); } From ade6a96f0d78d5ad3c3f7a874f1f51238725932e Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Tue, 21 Jul 2026 13:19:16 +0200 Subject: [PATCH 3/3] ZCU-DATA/fix: harden ASCII Content-Disposition fallback (Copilot review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restrict createFallbackAsciiName to printable ASCII ([\x20-\x7E]) in both HttpHeadersInitializer (single-file) and MetadataBitstreamController (allzip), so control chars — notably CR/LF — can no longer reach the quoted-string filename= value and inject a header. HttpHeadersInitializer additionally now escapes \ and ", matching MetadataBitstreamController; previously the two paths were inconsistent. MetadataBitstreamController had regressed from [\x20-\x7E] to [\x00-\x7F] while aligning with vanilla; this restores the printable-only filter while keeping the NFD transliteration. Existing IT assertions are unaffected (all use printable names). Addresses Copilot review comments on #1370. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../org/dspace/app/rest/MetadataBitstreamController.java | 7 ++++--- .../org/dspace/app/rest/utils/HttpHeadersInitializer.java | 7 ++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/MetadataBitstreamController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/MetadataBitstreamController.java index a5fa455ab1a..3b7e2e15e38 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/MetadataBitstreamController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/MetadataBitstreamController.java @@ -162,9 +162,10 @@ private String createFallbackAsciiName(String originalFilename) { } String normalized = Normalizer.normalize(originalFilename, Normalizer.Form.NFD); String withoutAccents = normalized.replaceAll("\\p{InCombiningDiacriticalMarks}+", ""); - // Deviates from vanilla by escaping \ and ": the value is a quoted-string, and an item name - // containing a quote closes it early. That is the bug #1267 fixed; vanilla still has it. - return withoutAccents.replaceAll("[^\\x00-\\x7F]", "") + // Deviates from vanilla: restrict to printable ASCII and escape \ and ". The value is a + // quoted-string, so control chars could inject a header and a quote would close it early. + // That is the bug #1267 fixed; vanilla still has it. + return withoutAccents.replaceAll("[^\\x20-\\x7E]", "") .replace("\\", "\\\\") .replace("\"", "\\\""); } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java index 0ed36e954a3..a2700f342bc 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java @@ -282,7 +282,12 @@ private String createFallbackAsciiName(String originalFilename) { } String normalized = Normalizer.normalize(originalFilename, Normalizer.Form.NFD); String withoutAccents = normalized.replaceAll("\\p{InCombiningDiacriticalMarks}+", ""); - return withoutAccents.replaceAll("[^\\x00-\\x7F]", ""); + // Deviates from vanilla: restrict to printable ASCII and escape \ and ". The value is a + // quoted-string, so control chars could inject a header and a quote would close it early. + // Kept consistent with MetadataBitstreamController.createFallbackAsciiName. + return withoutAccents.replaceAll("[^\\x20-\\x7E]", "") + .replace("\\", "\\\\") + .replace("\"", "\\\""); } /**