From 495b767013072254b4ea8fb2e42b745fe0372f92 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Thu, 12 Mar 2026 15:53:08 +0100 Subject: [PATCH 1/2] fix: escape double quotes in Content-Disposition header for allzip endpoint The allzip endpoint was generating an invalid Content-Disposition header when the item name contained double quotes (e.g. manuscript titles with quoted terms). This caused ERR_RESPONSE_HEADERS_MULTIPLE_CONTENT_DISPOSITION in browsers. Added buildContentDisposition() method using RFC 5987 encoding: - filename param: ASCII fallback with escaped quotes and backslashes - filename*: UTF-8 percent-encoded for modern clients Added integration tests for: - Item names with embedded double quotes - Item names with non-ASCII characters (diacritics) --- .../app/rest/MetadataBitstreamController.java | 19 +++++- .../rest/MetadataBitstreamControllerIT.java | 63 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) 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 5e6700306959..87adfbaa2273 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 @@ -11,6 +11,8 @@ import java.io.IOException; import java.io.InputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.sql.SQLException; import java.util.List; import java.util.Objects; @@ -110,7 +112,7 @@ public void downloadFileZip(@PathVariable UUID uuid, @RequestParam("handleId") S Item item = (Item) dso; 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 bundles = item.getBundles("ORIGINAL"); @@ -134,4 +136,19 @@ public void downloadFileZip(@PathVariable UUID uuid, @RequestParam("handleId") S zip.close(); response.getOutputStream().flush(); } + + /** + * 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. + */ + private String buildContentDisposition(String name) { + String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8) + .replace("+", "%20"); + String asciiFallback = name.replaceAll("[^\\x20-\\x7E]", "_") + .replace("\\", "\\\\") + .replace("\"", "\\\""); + return String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s", + asciiFallback, encoded); + } } 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 63f7e62206f8..d05d3acdab43 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 @@ -9,6 +9,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.io.ByteArrayOutputStream; @@ -97,4 +98,66 @@ public void downloadAllZip() throws Exception { .andExpect(content().bytes(byteArrayOutputStream.toByteArray())); } + + @Test + public void downloadAllZipWithDoubleQuotesInItemName() throws Exception { + context.turnOffAuthorisationSystem(); + + // Create an item with double quotes in the name — reproduces the + // ERR_RESPONSE_HEADERS_MULTIPLE_CONTENT_DISPOSITION browser error. + Item itemWithQuotes = ItemBuilder.createItem(context, col) + .withTitle("Supported data for manuscript \"Thermally-induced evolution\"") + .withAuthor(AUTHOR) + .build(); + + String bitstreamContent = "QuotedItemContent"; + Bitstream quotedBts; + try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { + quotedBts = 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()) + // The filename must have escaped quotes so the header is valid + .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(); + + String bitstreamContent = "DiacriticsContent"; + try (InputStream is = IOUtils.toInputStream(bitstreamContent, 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()) + // Non-ASCII chars replaced with _ in filename, full UTF-8 in filename* + .andExpect(header().string("Content-Disposition", + "attachment; filename=\"P__li_ _lu_ou_k_ k__.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 cc44412eeaf54ddceb54535544a3461ff97a8bb9 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Thu, 12 Mar 2026 15:53:08 +0100 Subject: [PATCH 2/2] fix: escape double quotes in Content-Disposition header for allzip endpoint The allzip endpoint was generating an invalid Content-Disposition header when the item name contained double quotes (e.g. manuscript titles with quoted terms). This caused ERR_RESPONSE_HEADERS_MULTIPLE_CONTENT_DISPOSITION in browsers. Added buildContentDisposition() method using RFC 5987 encoding: - filename param: ASCII fallback with escaped quotes and backslashes - filename*: UTF-8 percent-encoded for modern clients Added integration tests for: - Item names with embedded double quotes - Item names with non-ASCII characters (diacritics) --- .../app/rest/MetadataBitstreamController.java | 19 +++++- .../rest/MetadataBitstreamControllerIT.java | 63 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) 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 5e6700306959..87adfbaa2273 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 @@ -11,6 +11,8 @@ import java.io.IOException; import java.io.InputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.sql.SQLException; import java.util.List; import java.util.Objects; @@ -110,7 +112,7 @@ public void downloadFileZip(@PathVariable UUID uuid, @RequestParam("handleId") S Item item = (Item) dso; 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 bundles = item.getBundles("ORIGINAL"); @@ -134,4 +136,19 @@ public void downloadFileZip(@PathVariable UUID uuid, @RequestParam("handleId") S zip.close(); response.getOutputStream().flush(); } + + /** + * 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. + */ + private String buildContentDisposition(String name) { + String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8) + .replace("+", "%20"); + String asciiFallback = name.replaceAll("[^\\x20-\\x7E]", "_") + .replace("\\", "\\\\") + .replace("\"", "\\\""); + return String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s", + asciiFallback, encoded); + } } 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 63f7e62206f8..54be368ffc02 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 @@ -9,6 +9,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.io.ByteArrayOutputStream; @@ -97,4 +98,66 @@ public void downloadAllZip() throws Exception { .andExpect(content().bytes(byteArrayOutputStream.toByteArray())); } + + @Test + public void downloadAllZipWithDoubleQuotesInItemName() throws Exception { + context.turnOffAuthorisationSystem(); + + // Create an item with double quotes in the name — reproduces the + // ERR_RESPONSE_HEADERS_MULTIPLE_CONTENT_DISPOSITION browser error. + Item itemWithQuotes = ItemBuilder.createItem(context, col) + .withTitle("Supported data for manuscript \"Thermally-induced evolution\"") + .withAuthor(AUTHOR) + .build(); + + String bitstreamContent = "QuotedItemContent"; + try (InputStream is = IOUtils.toInputStream(bitstreamContent, 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()) + // The filename must have escaped quotes so the header is valid + .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(); + + String bitstreamContent = "DiacriticsContent"; + try (InputStream is = IOUtils.toInputStream(bitstreamContent, 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()) + // Non-ASCII chars replaced with _ in filename, full UTF-8 in filename* + .andExpect(header().string("Content-Disposition", + "attachment; filename=\"P__li_ _lu_ou_k_ k__.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")); + } }