Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,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 javax.servlet.http.HttpServletRequest;
Expand Down Expand Up @@ -279,25 +280,50 @@ private void redirectToS3DownloadUrl(String bitName, String bitInternalId,
}

/**
* Build a Content-Disposition header value using RFC 5987 encoding.
* Includes both {@code filename} (ASCII fallback) and {@code filename*}
* (UTF-8 percent-encoded) so that curl -J and browsers can save files
* with non-ASCII characters in the name correctly.
*
* @param name the original filename
* @return the Content-Disposition header value
* 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.
* curl -J on Windows cannot create files with non-ASCII characters from a raw UTF-8 header,
* which is why this endpoint needs it too.
*/
private String buildContentDisposition(String name) {
// RFC 5987 percent-encoding for filename*
String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8)
.replace("+", "%20");
// ASCII fallback: replace non-ASCII chars with underscore, escape quotes.
// Modern clients use filename* (RFC 5987 / RFC 6266) with real UTF-8 name.
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 a 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);
}
Comment on lines +302 to +313

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

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

# Map the relevant structure first, then inspect the target range.
ast-grep outline "$file" --view expanded || true
echo
sed -n '260,340p' "$file" | cat -n

Repository: dataquest-dev/DSpace

Length of output: 5175


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect surrounding code that uses the fallback name and header setting.
rg -n -A4 -B4 "createFallbackAsciiName|setHeader|Content-Disposition|filename\*" \
  dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamByHandleRestController.java

Repository: dataquest-dev/DSpace

Length of output: 3119


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read only the relevant method and the call site context.
sed -n '290,340p' dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamByHandleRestController.java | cat -n

Repository: dataquest-dev/DSpace

Length of output: 2836


Strip ASCII control characters from the fallback filename

[^\x00-\x7F] still lets CR, LF, NUL, and the other C0 controls through, so a bitstream name can reach response.setHeader(...) inside a quoted filename. Strip [\x00-\x1F\x7F] here too.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamByHandleRestController.java`
around lines 302 - 313, Update createFallbackAsciiName so the fallback filename
removes ASCII control characters, including C0 controls and DEL, before escaping
backslashes and quotes. Replace the current non-ASCII-only filtering in the
withoutAccents transformation while preserving accent removal and quoted-string
escaping.


/**
* 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");
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@

import java.io.IOException;
import java.io.InputStream;
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;
Expand Down Expand Up @@ -115,7 +118,7 @@ public void downloadFileZip(@PathVariable UUID uuid, @RequestParam("handleId") S
// This bitstream is used to get it's item in the statistics tracker
Bitstream bitstreamForStatistics = null;
name = item.getName() + ".zip";
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, String.format("attachment;filename=\"%s\"", name));
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, buildContentDisposition(name));
response.setContentType("application/zip");
List<Bundle> bundles = item.getBundles("ORIGINAL");

Comment on lines 120 to 124
Expand Down Expand Up @@ -143,4 +146,49 @@ public void downloadFileZip(@PathVariable UUID uuid, @RequestParam("handleId") S
matomoBitstreamTracker.trackBitstreamDownload(context, request, bitstreamForStatistics, true);
response.getOutputStream().flush();
}

/**
* 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) {
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("\"", "\\\"");
}
Comment on lines +168 to +179

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the controller implementation and the related controller mentioned in the comment.
git ls-files 'dspace-server-webapp/src/main/java/org/dspace/app/rest/*.java' | sort
printf '\n--- MetadataBitstreamController excerpt ---\n'
sed -n '140,210p' dspace-server-webapp/src/main/java/org/dspace/app/rest/MetadataBitstreamController.java
printf '\n--- BitstreamByHandleRestController excerpt ---\n'
sed -n '1,240p' dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamByHandleRestController.java

Repository: dataquest-dev/DSpace

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for the fallback name helper and any filename/header construction around it.
rg -n "createFallbackAsciiName|filename=|Content-Disposition|attachment|inline|diacritical|CombiningDiacriticalMarks" dspace-server-webapp/src/main/java/org/dspace/app/rest

Repository: dataquest-dev/DSpace

Length of output: 7197


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the controller implementation and the related controller mentioned in the comment.
printf '\n--- MetadataBitstreamController excerpt ---\n'
sed -n '140,210p' dspace-server-webapp/src/main/java/org/dspace/app/rest/MetadataBitstreamController.java
printf '\n--- BitstreamByHandleRestController excerpt ---\n'
sed -n '1,240p' dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamByHandleRestController.java

Repository: dataquest-dev/DSpace

Length of output: 13170


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '150,220p' dspace-server-webapp/src/main/java/org/dspace/app/rest/MetadataBitstreamController.java

Repository: dataquest-dev/DSpace

Length of output: 2316


🌐 Web query:

Spring Framework ContentDisposition filename escaping control characters CR LF quoted-string sanitize header value

💡 Result:

In the Spring Framework, the ContentDisposition class is designed to help construct secure Content-Disposition header values, specifically addressing risks like Reflected File Download (RFD) and header injection [1][2][3][4]. Regarding the handling of special characters, CR (carriage return), and LF (line feed) in filenames: 1. Automatic Escaping: The ContentDisposition.Builder handles the formatting of filenames as quoted-strings according to RFC 2616 [5][6]. It automatically escapes quote characters within the filename by prefixing them with a backslash (e.g., "foo"bar.txt" becomes "foo\"bar.txt") [6][7][8]. 2. Control Character Handling: Spring Framework versions have received multiple updates (including CVE-2020-5398 and CVE-2025-41234) to mitigate security vulnerabilities related to header injection and RFD [1][2][3][4]. Applications should not rely solely on the library to handle malicious user-supplied input; if a filename is derived from user input, it must be validated and sanitized by the application before being passed to the ContentDisposition.Builder [3]. 3. Security Best Practices: Recent vulnerabilities, such as CVE-2025-41234, highlight that if an application allows user-supplied input to influence the filename, it must sanitize that input [3]. The framework's ability to protect against injection is limited if the application passes unsanitized, malicious control characters or entire header structures directly into the builder [3]. 4. Recommended Usage: Use the provided ContentDisposition.builder() API rather than manually concatenating strings into headers [5][3]. When setting filenames, prefer the builder methods, and ensure that any input originating from an untrusted source is strictly validated for forbidden characters (such as CRLF) before it reaches the ContentDisposition object [3]. For developers, the critical defense against HTTP Response Splitting and RFD is to ensure that user input is never blindly trusted. If you are using an older version of the Spring Framework, you should upgrade to a version where these security issues have been patched [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the matching helper in BitstreamByHandleRestController and the shared utility version.
sed -n '280,320p' dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamByHandleRestController.java
printf '\n--- HttpHeadersInitializer excerpt ---\n'
sed -n '270,320p' dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java

Repository: dataquest-dev/DSpace

Length of output: 3966


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Look for any reusable header-sanitization helpers and other Content-Disposition builders.
rg -n "Content-Disposition|createFallbackAsciiName|sanitize.*header|header value|CRLF|\\r|\\n" \
  dspace-server-webapp/src/main/java/org/dspace/app/rest dspace-server-webapp/src/main/java/org/dspace/app/rest/utils

Repository: dataquest-dev/DSpace

Length of output: 350


🌐 Web query:

ServletResponse setHeader CR LF illegal characters header value control characters Content-Disposition injection

💡 Result:

The use of HttpServletResponse.setHeader (and similar methods like addHeader) with unsanitized user input poses a critical security risk known as HTTP Response Splitting, or CRLF injection (CWE-113) [1][2][3]. CRLF Injection Risks Because HTTP headers are delimited by Carriage Return (CR, \r or %0d) and Line Feed (LF, \n or %0a) sequences, injecting these characters allows an attacker to terminate a legitimate header prematurely and inject arbitrary new headers or even a completely new response body [2][4][5]. When applied to the Content-Disposition header, attackers can exploit this to perform Reflected File Download (RFD) attacks, cross-site scripting (XSS), or cache poisoning [2][6][7][8]. Standard Prevention and Mitigation 1. Mandatory Sanitization: Before passing any user-controlled data to setHeader, you must strip CR and LF characters [1][2][4]. A common pattern is to use a regular expression to remove them [1][6][8]: value = value.replaceAll("[\r\n]", ""); 2. Comprehensive Control Character Filtering: Modern best practices suggest filtering all control characters (U+0000 to U+001F and U+007F), not just CR and LF, to prevent other injection or parsing anomalies [6][8]. value = value.replaceAll("[\r\n\u0000-\u001F\u007F]", ""); 3. Proper Content-Disposition Encoding: To specifically defend Content-Disposition injection, merely sanitizing CR/LF is often insufficient. You must also properly quote the filename and, where possible, use the RFC 6266 filename* parameter to support non-ASCII characters and avoid parsing ambiguity [9][8]. Ensure that double quotes within filenames are escaped or removed to prevent an attacker from closing the filename attribute prematurely [7][8]. 4. Leverage Framework Protections: While modern servlet containers (e.g., Tomcat 9+, Jetty 10+) have improved by rejecting raw CRLF characters in header values by default, you should never rely on the container alone [1][4]. URL-encoded variants (%0d%0a) may be decoded by intermediate proxies or browsers before reaching the container, making application-layer sanitization essential [1]. Always prioritize using built-in framework utilities for header creation (e.g., Spring's ContentDisposition builder) over manual string concatenation [7][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the shared header-building utility and any explicit sanitization logic nearby.
sed -n '150,220p' dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java
printf '\n--- nearby helpers ---\n'
sed -n '220,330p' dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java

Repository: dataquest-dev/DSpace

Length of output: 7409


Strip control characters from the fallback filename
Same gap as BitstreamByHandleRestController: createFallbackAsciiName() only escapes \ and ", so \r, \n, and other control chars can still reach filename="...". Strip them before building the header.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@dspace-server-webapp/src/main/java/org/dspace/app/rest/MetadataBitstreamController.java`
around lines 168 - 179, Update createFallbackAsciiName() to remove all control
characters from the normalized, accent-free filename before escaping backslashes
and quotes. Preserve the existing null handling, ASCII filtering, and
quoted-string escaping while ensuring characters such as carriage returns and
newlines cannot reach the filename header.


/**
* 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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 java.util.Objects;
Expand Down Expand Up @@ -171,9 +173,16 @@ public HttpHeaders initialiseHeaders() throws IOException {

// distposition may be null here if contentType is null
if (!isNullOrEmpty(disposition)) {
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);

Expand Down Expand Up @@ -261,4 +270,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]", "");
}
Comment on lines +273 to +287

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Escape double quotes and strip control characters in the ASCII fallback.

While this file was intentionally left unchanged relative to vanilla DSpace to minimize merge conflicts, the duplicated fallback logic contains a critical bug: it fails to escape double quotes (") and backslashes (\), and does not strip ASCII control characters (like \r and \n).

If a filename contains a double quote, the filename="%s" format in initialiseHeaders will produce a prematurely closed quoted-string (e.g., filename="file"name.txt"). This results in an invalid Content-Disposition header, causing browsers to reject the download with ERR_RESPONSE_HEADERS_MULTIPLE_CONTENT_DISPOSITION. This is the exact scenario successfully fixed and tested by ContentDispositionUtils elsewhere in this PR. Furthermore, unstripped control characters can theoretically lead to HTTP response splitting.

To avoid duplicating security-sensitive encoding logic and retaining this bug, consider extracting a ContentDispositionUtils.format(disposition, filename) method in dspace-api and reusing it here. If you must keep the implementation entirely localized to this file, apply the necessary escaping and strip control characters:

🐛 Proposed fix for local escaping and control-character removal
     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]", "");
+        // Only keep printable ASCII characters (strips control chars like \r, \n)
+        String asciiOnly = withoutAccents.replaceAll("[^\\x20-\\x7E]", "");
+        // Escape backslashes and double quotes for the quoted-string
+        return asciiOnly.replace("\\", "\\\\").replace("\"", "\\\"");
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* 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 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}+", "");
// Only keep printable ASCII characters (strips control chars like \r, \n)
String asciiOnly = withoutAccents.replaceAll("[^\\x20-\\x7E]", "");
// Escape backslashes and double quotes for the quoted-string
return asciiOnly.replace("\\", "\\\\").replace("\"", "\\\"");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/HttpHeadersInitializer.java`
around lines 273 - 287, Update createFallbackAsciiName and its use in
initialiseHeaders to reuse ContentDispositionUtils.format for
Content-Disposition filename encoding, or implement equivalent local handling
that escapes double quotes and backslashes and removes ASCII control characters
while preserving diacritic removal and non-ASCII filtering.

Comment on lines +280 to +287

/**
* 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);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,8 @@ public void downloadBitstreamByHandleUtf8Filename() throws Exception {
+ "/M%C3%A9di%C3%A1%20(3).jfif")))
.andExpect(status().isOk())
.andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
// ASCII fallback replaces non-ASCII with underscore; filename* has UTF-8 encoding
equalTo("attachment; filename=\"M_di_ (3).jfif\"; "
// ASCII fallback transliterates the diacritics away; filename* keeps the real name
equalTo("attachment; filename=\"Media (3).jfif\"; "
+ "filename*=UTF-8''M%C3%A9di%C3%A1%20%283%29.jfif")))
.andExpect(content().string(bitstreamContent));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -364,7 +363,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";
Comment on lines +368 to +370

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

Expand All @@ -388,7 +391,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)
));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import static org.junit.Assert.assertEquals;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import java.io.ByteArrayInputStream;
Expand Down Expand Up @@ -104,4 +105,62 @@ public void downloadAllZip() throws Exception {
assertEquals(Set.of(bts.getName()), entries.keySet());
assertEquals(BITSTREAM_CONTENT, entries.get(bts.getName()));
}

@Test
public void downloadAllZipWithDoubleQuotesInItemName() throws Exception {
context.turnOffAuthorisationSystem();

// Double quotes in the name used to close the header's quoted-string early, which browsers
// reported as ERR_RESPONSE_HEADERS_MULTIPLE_CONTENT_DISPOSITION.
Item itemWithQuotes = ItemBuilder.createItem(context, col)
.withTitle("Supported data for manuscript \"Thermally-induced evolution\"")
.withAuthor(AUTHOR)
.build();

try (InputStream is = IOUtils.toInputStream("QuotedItemContent", CharEncoding.UTF_8)) {
BitstreamBuilder.createBitstream(context, itemWithQuotes, is)
.withName("data.csv")
.withMimeType("text/csv")
.build();
}
context.restoreAuthSystemState();

String token = getAuthToken(admin.getEmail(), password);
getClient(token).perform(get(METADATABITSTREAM_ENDPOINT + "/" + itemWithQuotes.getID() +
"/" + ALL_ZIP_PATH).param(HANDLE_PARAM, itemWithQuotes.getHandle()))
.andExpect(status().isOk())
.andExpect(header().string("Content-Disposition",
"attachment; filename=\"Supported data for manuscript"
+ " \\\"Thermally-induced evolution\\\".zip\";"
+ " filename*=UTF-8''Supported%20data%20for%20manuscript"
+ "%20%22Thermally-induced%20evolution%22.zip"));
}

@Test
public void downloadAllZipWithNonAsciiItemName() throws Exception {
context.turnOffAuthorisationSystem();

Item itemWithDiacritics = ItemBuilder.createItem(context, col)
.withTitle("Příliš žluťoučký kůň")
.withAuthor(AUTHOR)
.build();

try (InputStream is = IOUtils.toInputStream("DiacriticsContent", CharEncoding.UTF_8)) {
BitstreamBuilder.createBitstream(context, itemWithDiacritics, is)
.withName("file.txt")
.withMimeType("text/plain")
.build();
}
context.restoreAuthSystemState();

String token = getAuthToken(admin.getEmail(), password);
getClient(token).perform(get(METADATABITSTREAM_ENDPOINT + "/" + itemWithDiacritics.getID() +
"/" + ALL_ZIP_PATH).param(HANDLE_PARAM, itemWithDiacritics.getHandle()))
.andExpect(status().isOk())
// fallback transliterates the diacritics away; filename* carries the real name
.andExpect(header().string("Content-Disposition",
"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"));
}
}
Loading