-
Notifications
You must be signed in to change notification settings - Fork 1
UFAL/fix: RFC 5987 Content-Disposition for single-file + allzip download (backport vanilla #11260, port #1267) #1368
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dtq-dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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
|
||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.javaRepository: 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/restRepository: 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.javaRepository: 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.javaRepository: dataquest-dev/DSpace Length of output: 2316 🌐 Web query:
💡 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 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.javaRepository: 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/utilsRepository: dataquest-dev/DSpace Length of output: 350 🌐 Web query:
💡 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.javaRepository: dataquest-dev/DSpace Length of output: 7409 Strip control characters from the fallback filename 🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( If a filename contains a double quote, the To avoid duplicating security-sensitive encoding logic and retaining this bug, consider extracting a 🐛 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
Suggested change
🤖 Prompt for AI Agents
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); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
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:
Repository: dataquest-dev/DSpace
Length of output: 5175
🏁 Script executed:
Repository: dataquest-dev/DSpace
Length of output: 3119
🏁 Script executed:
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 reachresponse.setHeader(...)inside a quotedfilename. Strip[\x00-\x1F\x7F]here too.🤖 Prompt for AI Agents