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 @@ -10,6 +10,7 @@
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.text.Normalizer;
import java.time.Instant;
import java.util.Date;

Expand Down Expand Up @@ -79,20 +80,60 @@ public String generatePresignedUrl(String bucket, String key, int expirationSeco
.withMethod(HttpMethod.GET)
.withExpiration(expiration);
// Add custom response header for filename - to download the file with the desired name
// Remove CRLF and quotes to prevent header injection
String safeName = desiredFilename.replaceAll("[\\r\\n\"]", "_");
// RFC-5987: percent-encode UTF-8, e.g. filename*=UTF-8''%E2%82%ACrates.txt
String encoded = URLEncoder.encode(desiredFilename, StandardCharsets.UTF_8);
String contentDisposition = String.format(
"attachment; filename=\"%s\"; filename*=UTF-8''%s",
safeName, encoded);

request.addRequestParameter("response-content-disposition", contentDisposition);
request.addRequestParameter("response-content-disposition",
buildContentDisposition(desiredFilename));
try {
return s3Client.generatePresignedUrl(request).toString();
} catch (Exception e) {
log.error("Failed to generate presigned URL for bucket: {}, key: {}", bucket, key, e);
throw new RuntimeException("Failed to generate presigned URL", e);
}
}

/**
* 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. S3 direct download has no upstream counterpart, so the
* logic is copied from vanilla rather than shared.
*/
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}+", "");
// Two deviations from vanilla, both of them behaviour this class already had: CR/LF are
// dropped so a crafted name cannot inject a header, and \ and " are escaped so a name
// containing a quote cannot close the quoted-string early.
return withoutAccents.replaceAll("[^\\x00-\\x7F]", "")
.replaceAll("[\\r\\n]", "")
.replace("\\", "\\\\")
.replace("\"", "\\\"");
}

/**
* 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 @@ -139,6 +139,19 @@ public void weirdFilename() throws Exception {
assertTrue(cd.contains("UTF-8"));
}

// Spaces must be %20 in filename*, not '+' — URLEncoder is form-encoding and differs from RFC 5987 here
@Test
public void spacesAndDiacriticsInFilename() throws Exception {
URL fake = new URL("https://spaces");
when(amazonS3.generatePresignedUrl(any(GeneratePresignedUrlRequest.class))).thenReturn(fake);

s3DirectDownloadService.generatePresignedUrl("b", "k", 60, "Příliš žluťoučký kůň.txt");
String cd = captureRequest().getRequestParameters().get("response-content-disposition");

assertEquals("attachment; filename=\"Prilis zlutoucky kun.txt\"; "
+ "filename*=UTF-8''P%C5%99%C3%ADli%C5%A1%20%C5%BElu%C5%A5ou%C4%8Dk%C3%BD%20k%C5%AF%C5%88.txt", cd);
}

// Underlying AmazonS3 throws → IllegalArgumentException
@Test(expected = IllegalArgumentException.class)
public void nullFilename() throws Exception {
Expand Down
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);
}

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

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("\"", "\\\"");
}

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

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

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
Loading
Loading