From aba075a36e51ff8afd07d1e8d596a3da64a6f39d Mon Sep 17 00:00:00 2001 From: Matus Kasak Date: Tue, 27 Oct 2026 13:27:11 +0100 Subject: [PATCH 1/4] Prevent stored XSS from inline-served active-content bitstreams Switch the /content bitstream download disposition decision from a denylist (default = inline) to an allowlist (default = attachment). A bitstream is now served inline only when its format is listed in the new webui.content_disposition_inline config and its size is within the inline threshold; everything else is forced to download. An additional guard forces attachment for any MIME type ending in "+xml" (e.g. image/svg+xml, application/xhtml+xml), which can carry executable script, even if a deployment adds such a type to the allowlist. Because the controller now always sets an explicit disposition, the image/* content type no longer bypasses the Content-Disposition header and active-content images download instead of rendering inline. Add webui.content_disposition_inline to dspace.cfg and update the integration tests for the allowlist behavior, including cases asserting that image/svg+xml and application/xhtml+xml are served as attachments. --- .../app/rest/BitstreamRestController.java | 63 +++++------ .../app/rest/BitstreamRestControllerIT.java | 100 ++++++++++++++---- dspace/config/dspace.cfg | 29 ++--- 3 files changed, 125 insertions(+), 67 deletions(-) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamRestController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamRestController.java index 0c4bf35f45f1..079ffdbe1e93 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamRestController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamRestController.java @@ -23,7 +23,6 @@ import javax.ws.rs.core.Response; import org.apache.catalina.connector.ClientAbortException; -import org.apache.commons.collections4.ListUtils; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.Logger; import org.dspace.app.rest.converter.ConverterService; @@ -183,12 +182,16 @@ public ResponseEntity retrieve(@PathVariable UUID uuid, HttpServletResponse resp httpHeadersInitializer.withLastModified(lastModified); } - //Determine if we need to send the file as a download or if the browser can open it inline - //The file will be downloaded if its size is larger than the configured threshold, - //or if its mimetype/extension appears in the "webui.content_disposition_format" config - long dispositionThreshold = configurationService.getLongProperty("webui.content_disposition_threshold"); - if ((dispositionThreshold >= 0 && filesize > dispositionThreshold) - || checkFormatForContentDisposition(format)) { + // Determine if we need to send the file as a download or if the browser can open it inline. + // By default, all files will be downloaded as that is more secure. File formats will only be opened inline + // if they are listed in the "webui.content_disposition_inline" config and size is less than the + // configured "webui.content_disposition_threshold" (default = 8MB) + long dispositionThreshold = configurationService.getLongProperty("webui.content_disposition_threshold", + 8388608); + if (checkFormatForContentDispositionInline(format) && + filesize <= dispositionThreshold) { + httpHeadersInitializer.withDisposition(HttpHeadersInitializer.CONTENT_DISPOSITION_INLINE); + } else { httpHeadersInitializer.withDisposition(HttpHeadersInitializer.CONTENT_DISPOSITION_ATTACHMENT); } @@ -283,48 +286,48 @@ private boolean isNotAnErrorResponse(HttpServletResponse response) { } /** - * Check if a Bitstream of the specified format should always be downloaded (i.e. "content-disposition: attachment") - * or can be opened inline (i.e. "content-disposition: inline"). + * Check if a Bitstream of the specified format should be opened inline (i.e. "content-disposition: inline") + * instead of the default behavior of always downloading (i.e. "content-disposition: attachment"). *

* NOTE that downloading via "attachment" is more secure, as the user's browser will not attempt to process or * display the file. But, downloading via "inline" may be seen as more user-friendly for common formats. * @param format BitstreamFormat - * @return true if always download ("attachment"). false if can be opened inline ("inline") + * @return true if format is configured to be opened inline ("inline"). false if always download ("attachment") */ - private boolean checkFormatForContentDisposition(BitstreamFormat format) { + private boolean checkFormatForContentDispositionInline(BitstreamFormat format) { // Undefined or Unknown formats should ALWAYS be downloaded for additional security. if (format == null || format.getSupportLevel() == BitstreamFormat.UNKNOWN) { - return true; + return false; } - // Load additional formats configured to require download - List configuredFormats = List.of(configurationService. - getArrayProperty("webui.content_disposition_format")); - - // If configuration includes "*", then all formats will always be downloaded. - if (configuredFormats.contains("*")) { - return true; + // Return false for BANNED inline formats. Some formats, especially XML / HTML / Javascript based formats, + // when loaded inline may be susceptible to XSS attacks. Therefore, we will refuse to allow those formats to be + // displayed inline for security purposes. + // NOTE: "+xml" in this list will match any MIME Type that ends in "+xml", as those are XML-based formats. + List bannedInlineFormats = List.of("text/html", "text/javascript", "text/xml", "application/xml", + "+xml"); + for (String bannedInlineFormat : bannedInlineFormats) { + // If our format MIME Type contains one of the banned inline formats, we refuse to display it inline + if (format.getMIMEType().contains(bannedInlineFormat)) { + return false; + } } - // Define a download list of formats which DSpace forces to ALWAYS be downloaded. - // These formats can embed JavaScript which may be run in the user's browser if the file is opened inline. - // Therefore, DSpace blocks opening these formats inline as it could be used for an XSS attack. - List downloadOnlyFormats = List.of("text/html", "text/javascript", "text/xml", "rdf"); - - // Combine our two lists - List formats = ListUtils.union(downloadOnlyFormats, configuredFormats); + // Load formats configured to allow inline display + List formats = List.of(configurationService. + getArrayProperty("webui.content_disposition_inline")); // See if the passed in format's MIME type or file extension is listed. - boolean download = formats.contains(format.getMIMEType()); - if (!download) { + boolean inline = formats.contains(format.getMIMEType()); + if (!inline) { for (String ext : format.getExtensions()) { if (formats.contains(ext)) { - download = true; + inline = true; break; } } } - return download; + return inline; } /** 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 7e07c7072168..04fe71ecce4b 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 @@ -1317,12 +1317,8 @@ public void closeInputStreamsDownloadWithCoverPage() throws Exception { @Test public void checkContentDispositionOfFormats() throws Exception { - configurationService.setProperty("webui.content_disposition_format", new String[] { - "text/richtext", - "text/xml", - "txt" - }); - + // This test verifies that, by default, common text formats will be downloaded instead of being served inline. + // The next two tests will verify behavior of non-default settings of "webui.content_disposition_inline" context.turnOffAuthorisationSystem(); Community community = CommunityBuilder.createCommunity(context).build(); Collection collection = CollectionBuilder.createCollection(context, community).build(); @@ -1344,18 +1340,26 @@ public void checkContentDispositionOfFormats() throws Exception { } context.restoreAuthSystemState(); - // these formats are configured and files should be downloaded + // Based on default configuration all files should be downloaded verifyBitstreamDownload(rtf, "text/richtext;charset=UTF-8", true); verifyBitstreamDownload(xml, "text/xml;charset=UTF-8", true); verifyBitstreamDownload(txt, "text/plain;charset=UTF-8", true); - // this format is not configured and should open inline - verifyBitstreamDownload(csv, "text/csv;charset=UTF-8", false); + verifyBitstreamDownload(csv, "text/csv;charset=UTF-8", true); } @Test - public void checkHardcodedContentDispositionFormats() throws Exception { - // This test is similar to the above test, but it verifies that our *hardcoded settings* for - // webui.content_disposition_format are protecting us from loading specific formats *inline*. + public void checkBannedContentDispositionInlineFormats() throws Exception { + configurationService.setProperty("webui.content_disposition_inline", new String[] { + "text/html", + "text/javascript", + "rdf", + "text/xml", + "image/svg+xml" + }); + + // This test is similar to the above test, but it verifies that if a site specifies + // a banned format (e.g. HTML, XML, etc) in their "webui.content_disposition_inline" setting + // DSpace will still protect them by refusing to load the format *inline*. context.turnOffAuthorisationSystem(); Community community = CommunityBuilder.createCommunity(context).build(); Collection collection = CollectionBuilder.createCollection(context, community).build(); @@ -1385,8 +1389,9 @@ public void checkHardcodedContentDispositionFormats() throws Exception { } context.restoreAuthSystemState(); - // By default, HTML, JS & XML should all download. This protects us from possible XSS attacks, as - // each of these formats can embed JavaScript which may execute when the file is loaded *inline*. + // By default, HTML, JS & XML should all download regardless of inline configuration. + // This protects us from possible XSS attacks, as each of these formats can embed JavaScript + // which may execute when the file is loaded *inline*. verifyBitstreamDownload(html, "text/html;charset=UTF-8", true); verifyBitstreamDownload(js, "text/javascript;charset=UTF-8", true); verifyBitstreamDownload(rdf, "application/rdf+xml;charset=UTF-8", true); @@ -1399,22 +1404,29 @@ public void checkHardcodedContentDispositionFormats() throws Exception { } @Test - public void checkWildcardContentDispositionFormats() throws Exception { - // Setting "*" should result in all formats being downloaded (nothing will be opened inline) - configurationService.setProperty("webui.content_disposition_format", "*"); - + public void checkContentDispositionInlineFormats() throws Exception { + // Set PDF and a few image formats to verify they will display inline. But leave off "text/plain" + configurationService.setProperty("webui.content_disposition_inline", new String[] { + "text/csv", + "application/pdf", + "image/jpeg", + "video/mpeg" + }); context.turnOffAuthorisationSystem(); Community community = CommunityBuilder.createCommunity(context).build(); Collection collection = CollectionBuilder.createCollection(context, community).build(); Item item = ItemBuilder.createItem(context, collection).build(); String content = "Test Content"; Bitstream csv; + Bitstream txt; Bitstream jpg; Bitstream mpg; Bitstream pdf; try (InputStream is = IOUtils.toInputStream(content, CharEncoding.UTF_8)) { csv = BitstreamBuilder.createBitstream(context, item, is) .withMimeType("text/csv").build(); + txt = BitstreamBuilder.createBitstream(context, item, is) + .withMimeType("text/plain").build(); jpg = BitstreamBuilder.createBitstream(context, item, is) .withMimeType("image/jpeg").build(); mpg = BitstreamBuilder.createBitstream(context, item, is) @@ -1424,11 +1436,53 @@ public void checkWildcardContentDispositionFormats() throws Exception { } context.restoreAuthSystemState(); - // All formats should be download only - verifyBitstreamDownload(csv, "text/csv;charset=UTF-8", true); - verifyBitstreamDownload(jpg, "image/jpeg;charset=UTF-8", true); - verifyBitstreamDownload(mpg, "video/mpeg;charset=UTF-8", true); - verifyBitstreamDownload(pdf, "application/pdf;charset=UTF-8", true); + // Only text/plain should download, while other formats should be served inline based on the configuration + verifyBitstreamDownload(csv, "text/csv;charset=UTF-8", false); + verifyBitstreamDownload(jpg, "image/jpeg;charset=UTF-8", false); + verifyBitstreamDownload(mpg, "video/mpeg;charset=UTF-8", false); + verifyBitstreamDownload(pdf, "application/pdf;charset=UTF-8", false); + // This is the only format not listed in the inline configuration, so it will be downloaded + verifyBitstreamDownload(txt, "text/plain;charset=UTF-8", true); + } + + @Test + public void checkActiveContentFormatsNeverServedInline() throws Exception { + // Active-content formats that resolve to a KNOWN format (e.g. SVG and XHTML, which can carry scripts) must + // never be served inline, even when a deployment explicitly adds them to "webui.content_disposition_inline". + // These are the stored XSS vector: inline rendering would execute their embedded JavaScript in the repository + // origin. The "+xml" guard forces them to download regardless of configuration. + configurationService.setProperty("webui.content_disposition_inline", new String[] { + "image/svg+xml", + "application/xhtml+xml" + }); + + context.turnOffAuthorisationSystem(); + Community community = CommunityBuilder.createCommunity(context).build(); + Collection collection = CollectionBuilder.createCollection(context, community).build(); + Item item = ItemBuilder.createItem(context, collection).build(); + String content = "Test Content"; + + // Register SVG and XHTML as KNOWN formats so they are served with their real MIME type instead of octet-stream + createBitstreamFormat(context).withMimeType("image/svg+xml") + .withShortDescription("SVG image") + .withSupportLevel(KNOWN).build(); + createBitstreamFormat(context).withMimeType("application/xhtml+xml") + .withShortDescription("XHTML document") + .withSupportLevel(KNOWN).build(); + + Bitstream svg; + Bitstream xhtml; + try (InputStream is = IOUtils.toInputStream(content, CharEncoding.UTF_8)) { + svg = BitstreamBuilder.createBitstream(context, item, is) + .withMimeType("image/svg+xml").build(); + xhtml = BitstreamBuilder.createBitstream(context, item, is) + .withMimeType("application/xhtml+xml").build(); + } + context.restoreAuthSystemState(); + + // Both must be forced to download (attachment), never served inline. + verifyBitstreamDownload(svg, "image/svg+xml;charset=UTF-8", true); + verifyBitstreamDownload(xhtml, "application/xhtml+xml;charset=UTF-8", true); } diff --git a/dspace/config/dspace.cfg b/dspace/config/dspace.cfg index fe4eced2855d..ca4d91b08570 100644 --- a/dspace/config/dspace.cfg +++ b/dspace/config/dspace.cfg @@ -1415,23 +1415,24 @@ websvc.opensearch.formats = html,atom,rss # maximum number of item per request websvc.opensearch.max_num_of_items_per_request = 100 -#### Content Inline Disposition Threshold #### -# -# Set the max size of a bitstream that can be served inline -# Use -1 to force all bitstream to be served inline -webui.content_disposition_threshold = 8388608 - -#### Content Attachment Disposition Formats #### +#### Content Inline Disposition Formats #### # -# Set which mimetypes or file extensions will NOT be opened inline. -# Files with these mimetypes/extensions will always be downloaded, regardless of the threshold above. +# Set which mimetypes or file extensions are allowed to be opened inline in a user's browser. +# By default, all files will be downloaded, regardless of the threshold below, unless specified in this configuration. # NOTE: For security reasons, some file formats (e.g. HTML, XML, RDF, JS) will always be downloaded regardless -# of the settings here. This blocks these formats from executing embedded JavaScript when opened inline. -# For additional security, you may choose to set this to "*" to force all formats to always be downloaded -# (i.e. disables all formats from opening inline within the user's browser). +# of the settings here. This blocks these formats from executing embedded JavaScript when opened inline, protecting +# the site from potential XSS attacks. # -# By default, RTF is always downloaded because most browsers attempt to display it as plain text. -webui.content_disposition_format = text/richtext +# For example: this setting defaults to enabling PDF and common image / audio / video formats to be opened +# (or potentially streamed) in a user's browser. +webui.content_disposition_inline = application/pdf, image/gif, image/jpeg, image/png, audio/mpeg, video/mpeg, video/mp4 + +#### Content Inline Disposition Threshold #### +# +# Set the max size (in bytes) of a bitstream that can be served inline. This setting only applies to formats +# specified in the "webui.content_disposition_inline" configuration above. +# Default = 8MB (8388608 bytes). +webui.content_disposition_threshold = 8388608 #### Multi-file HTML document/site settings ##### # TODO: UNSUPPORTED in DSpace 7.0. May be re-added in a later release From dbf9d5272d22f6a18923f4b12bbe323b91179b06 Mon Sep 17 00:00:00 2001 From: Matus Kasak Date: Tue, 27 Oct 2026 15:06:28 +0100 Subject: [PATCH 2/4] fix: harden inline MIME checks and preserve disposition settings --- .../app/rest/BitstreamRestController.java | 44 +++++----- .../app/rest/BitstreamRestControllerIT.java | 87 +++++++++++-------- dspace/config/dspace.cfg | 23 ++--- 3 files changed, 78 insertions(+), 76 deletions(-) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamRestController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamRestController.java index 079ffdbe1e93..bf96110c3741 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamRestController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamRestController.java @@ -16,6 +16,7 @@ import java.net.URI; import java.sql.SQLException; import java.util.List; +import java.util.Locale; import java.util.UUID; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -132,7 +133,7 @@ public ResponseEntity retrieve(@PathVariable UUID uuid, HttpServletResponse resp Long lastModified = bitstreamService.getLastModified(bit); BitstreamFormat format = bit.getFormat(context); - String mimetype = format.getMIMEType(); + String mimetype = StringUtils.defaultIfBlank(format.getMIMEType(), "application/octet-stream"); String name = getBitstreamName(bit, format); if (StringUtils.isBlank(request.getHeader("Range"))) { @@ -182,14 +183,11 @@ public ResponseEntity retrieve(@PathVariable UUID uuid, HttpServletResponse resp httpHeadersInitializer.withLastModified(lastModified); } - // Determine if we need to send the file as a download or if the browser can open it inline. - // By default, all files will be downloaded as that is more secure. File formats will only be opened inline - // if they are listed in the "webui.content_disposition_inline" config and size is less than the - // configured "webui.content_disposition_threshold" (default = 8MB) + // Serve only allowlisted formats inline, subject to the size limit. long dispositionThreshold = configurationService.getLongProperty("webui.content_disposition_threshold", 8388608); if (checkFormatForContentDispositionInline(format) && - filesize <= dispositionThreshold) { + (dispositionThreshold == -1 || filesize <= dispositionThreshold)) { httpHeadersInitializer.withDisposition(HttpHeadersInitializer.CONTENT_DISPOSITION_INLINE); } else { httpHeadersInitializer.withDisposition(HttpHeadersInitializer.CONTENT_DISPOSITION_ATTACHMENT); @@ -286,31 +284,29 @@ private boolean isNotAnErrorResponse(HttpServletResponse response) { } /** - * Check if a Bitstream of the specified format should be opened inline (i.e. "content-disposition: inline") - * instead of the default behavior of always downloading (i.e. "content-disposition: attachment"). - *

- * NOTE that downloading via "attachment" is more secure, as the user's browser will not attempt to process or - * display the file. But, downloading via "inline" may be seen as more user-friendly for common formats. + * Check whether a known, non-active format is allowed inline by configuration. * @param format BitstreamFormat * @return true if format is configured to be opened inline ("inline"). false if always download ("attachment") */ private boolean checkFormatForContentDispositionInline(BitstreamFormat format) { - // Undefined or Unknown formats should ALWAYS be downloaded for additional security. - if (format == null || format.getSupportLevel() == BitstreamFormat.UNKNOWN) { + if (format == null || format.getSupportLevel() == BitstreamFormat.UNKNOWN + || StringUtils.isBlank(format.getMIMEType())) { return false; } - // Return false for BANNED inline formats. Some formats, especially XML / HTML / Javascript based formats, - // when loaded inline may be susceptible to XSS attacks. Therefore, we will refuse to allow those formats to be - // displayed inline for security purposes. - // NOTE: "+xml" in this list will match any MIME Type that ends in "+xml", as those are XML-based formats. + String mimeType = StringUtils.substringBefore(format.getMIMEType(), ";").trim().toLowerCase(Locale.ROOT); + if (mimeType.isEmpty()) { + return false; + } + + // Block active content even when explicitly allowlisted. List bannedInlineFormats = List.of("text/html", "text/javascript", "text/xml", "application/xml", - "+xml"); - for (String bannedInlineFormat : bannedInlineFormats) { - // If our format MIME Type contains one of the banned inline formats, we refuse to display it inline - if (format.getMIMEType().contains(bannedInlineFormat)) { - return false; - } + "application/javascript", "application/ecmascript", "text/ecmascript", + "application/x-javascript", "application/x-ecmascript", "text/x-javascript", "text/x-ecmascript", + "text/jscript", "text/livescript"); + if (bannedInlineFormats.contains(mimeType) || mimeType.endsWith("+xml") + || mimeType.startsWith("text/javascript")) { + return false; } // Load formats configured to allow inline display @@ -318,7 +314,7 @@ private boolean checkFormatForContentDispositionInline(BitstreamFormat format) { getArrayProperty("webui.content_disposition_inline")); // See if the passed in format's MIME type or file extension is listed. - boolean inline = formats.contains(format.getMIMEType()); + boolean inline = formats.stream().anyMatch(value -> mimeType.equalsIgnoreCase(value.trim())); if (!inline) { for (String ext : format.getExtensions()) { if (formats.contains(ext)) { 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 04fe71ecce4b..94b3e64288a2 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 @@ -1317,8 +1317,7 @@ public void closeInputStreamsDownloadWithCoverPage() throws Exception { @Test public void checkContentDispositionOfFormats() throws Exception { - // This test verifies that, by default, common text formats will be downloaded instead of being served inline. - // The next two tests will verify behavior of non-default settings of "webui.content_disposition_inline" + // Unlisted text formats should download by default. context.turnOffAuthorisationSystem(); Community community = CommunityBuilder.createCommunity(context).build(); Collection collection = CollectionBuilder.createCollection(context, community).build(); @@ -1357,9 +1356,7 @@ public void checkBannedContentDispositionInlineFormats() throws Exception { "image/svg+xml" }); - // This test is similar to the above test, but it verifies that if a site specifies - // a banned format (e.g. HTML, XML, etc) in their "webui.content_disposition_inline" setting - // DSpace will still protect them by refusing to load the format *inline*. + // Explicit configuration must not bypass the active-content guard. context.turnOffAuthorisationSystem(); Community community = CommunityBuilder.createCommunity(context).build(); Collection collection = CollectionBuilder.createCollection(context, community).build(); @@ -1389,17 +1386,13 @@ public void checkBannedContentDispositionInlineFormats() throws Exception { } context.restoreAuthSystemState(); - // By default, HTML, JS & XML should all download regardless of inline configuration. - // This protects us from possible XSS attacks, as each of these formats can embed JavaScript - // which may execute when the file is loaded *inline*. verifyBitstreamDownload(html, "text/html;charset=UTF-8", true); verifyBitstreamDownload(js, "text/javascript;charset=UTF-8", true); verifyBitstreamDownload(rdf, "application/rdf+xml;charset=UTF-8", true); verifyBitstreamDownload(xml, "text/xml;charset=UTF-8", true); // Unknown file formats should also always download verifyBitstreamDownload(unknown, "application/octet-stream;charset=UTF-8", true); - // SVG does NOT currently exist as a recognized format in DSpace's bitstream-formats.xml, but it's another - // format that allows embedded JavaScript. This test is a reminder that SVGs should NOT be opened inline. + // Unregistered SVG falls back to the unknown format. verifyBitstreamDownload(svg, "application/octet-stream;charset=UTF-8", true); } @@ -1441,50 +1434,70 @@ public void checkContentDispositionInlineFormats() throws Exception { verifyBitstreamDownload(jpg, "image/jpeg;charset=UTF-8", false); verifyBitstreamDownload(mpg, "video/mpeg;charset=UTF-8", false); verifyBitstreamDownload(pdf, "application/pdf;charset=UTF-8", false); - // This is the only format not listed in the inline configuration, so it will be downloaded verifyBitstreamDownload(txt, "text/plain;charset=UTF-8", true); } @Test public void checkActiveContentFormatsNeverServedInline() throws Exception { - // Active-content formats that resolve to a KNOWN format (e.g. SVG and XHTML, which can carry scripts) must - // never be served inline, even when a deployment explicitly adds them to "webui.content_disposition_inline". - // These are the stored XSS vector: inline rendering would execute their embedded JavaScript in the repository - // origin. The "+xml" guard forces them to download regardless of configuration. - configurationService.setProperty("webui.content_disposition_inline", new String[] { - "image/svg+xml", - "application/xhtml+xml" - }); + String[] mimeTypes = { + "image/svg+xml", "application/xhtml+xml", "image/SVG+XML", "APPLICATION/XHTML+XML", + "text/html; charset=utf-8", "application/javascript", "application/ecmascript", + "text/ecmascript", "application/x-javascript", "application/x-ecmascript", + "text/x-javascript", "text/x-ecmascript", "text/jscript", "text/livescript", "text/javascript1.5" + }; + configurationService.setProperty("webui.content_disposition_inline", mimeTypes); context.turnOffAuthorisationSystem(); Community community = CommunityBuilder.createCommunity(context).build(); Collection collection = CollectionBuilder.createCollection(context, community).build(); Item item = ItemBuilder.createItem(context, collection).build(); - String content = "Test Content"; + context.restoreAuthSystemState(); - // Register SVG and XHTML as KNOWN formats so they are served with their real MIME type instead of octet-stream - createBitstreamFormat(context).withMimeType("image/svg+xml") - .withShortDescription("SVG image") - .withSupportLevel(KNOWN).build(); - createBitstreamFormat(context).withMimeType("application/xhtml+xml") - .withShortDescription("XHTML document") - .withSupportLevel(KNOWN).build(); + for (String mimeType : mimeTypes) { + context.turnOffAuthorisationSystem(); + // Known formats exercise the guard instead of the unknown-format fallback. + BitstreamFormat format = createBitstreamFormat(context).withMimeType(mimeType) + .withShortDescription("Active " + mimeType).withSupportLevel(KNOWN).build(); + Bitstream file = BitstreamBuilder.createBitstream(context, item, toInputStream("test", UTF_8)).build(); + file.setFormat(context, format); + bitstreamService.update(context, file); + context.restoreAuthSystemState(); - Bitstream svg; - Bitstream xhtml; - try (InputStream is = IOUtils.toInputStream(content, CharEncoding.UTF_8)) { - svg = BitstreamBuilder.createBitstream(context, item, is) - .withMimeType("image/svg+xml").build(); - xhtml = BitstreamBuilder.createBitstream(context, item, is) - .withMimeType("application/xhtml+xml").build(); + String contentType = mimeType.split(";")[0] + ";charset=UTF-8"; + configurationService.setProperty("webui.content_disposition_threshold", 8388608); + verifyBitstreamDownload(file, contentType, true); + configurationService.setProperty("webui.content_disposition_threshold", -1); + verifyBitstreamDownload(file, contentType, true); } + } + + @Test + public void checkInlineDispositionSizeLimit() throws Exception { + configurationService.setProperty("webui.content_disposition_inline", new String[] {"application/pdf"}); + context.turnOffAuthorisationSystem(); + bitstream.setFormat(context, bitstreamFormatService.findByMIMEType(context, "application/pdf")); + bitstreamService.update(context, bitstream); context.restoreAuthSystemState(); - // Both must be forced to download (attachment), never served inline. - verifyBitstreamDownload(svg, "image/svg+xml;charset=UTF-8", true); - verifyBitstreamDownload(xhtml, "application/xhtml+xml;charset=UTF-8", true); + configurationService.setProperty("webui.content_disposition_threshold", bitstream.getSizeBytes()); + verifyBitstreamDownload(bitstream, "application/pdf;charset=UTF-8", false); + configurationService.setProperty("webui.content_disposition_threshold", bitstream.getSizeBytes() - 1); + verifyBitstreamDownload(bitstream, "application/pdf;charset=UTF-8", true); + configurationService.setProperty("webui.content_disposition_threshold", -1); + verifyBitstreamDownload(bitstream, "application/pdf;charset=UTF-8", false); } + @Test + public void checkMissingMimeTypeDownloads() throws Exception { + configurationService.setProperty("webui.content_disposition_inline", new String[] {"pdf"}); + for (String mimeType : new String[] {null, "", " "}) { + context.turnOffAuthorisationSystem(); + supportedFormat.setMIMEType(mimeType); + bitstreamFormatService.update(context, supportedFormat); + context.restoreAuthSystemState(); + verifyBitstreamDownload(bitstream, "application/octet-stream;charset=UTF-8", true); + } + } private void verifyBitstreamDownload(Bitstream file, String contentType, boolean shouldDownload) throws Exception { String token = getAuthToken(admin.getEmail(), password); diff --git a/dspace/config/dspace.cfg b/dspace/config/dspace.cfg index ca4d91b08570..9590f904e07f 100644 --- a/dspace/config/dspace.cfg +++ b/dspace/config/dspace.cfg @@ -1415,25 +1415,18 @@ websvc.opensearch.formats = html,atom,rss # maximum number of item per request websvc.opensearch.max_num_of_items_per_request = 100 -#### Content Inline Disposition Formats #### -# -# Set which mimetypes or file extensions are allowed to be opened inline in a user's browser. -# By default, all files will be downloaded, regardless of the threshold below, unless specified in this configuration. -# NOTE: For security reasons, some file formats (e.g. HTML, XML, RDF, JS) will always be downloaded regardless -# of the settings here. This blocks these formats from executing embedded JavaScript when opened inline, protecting -# the site from potential XSS attacks. -# -# For example: this setting defaults to enabling PDF and common image / audio / video formats to be opened -# (or potentially streamed) in a user's browser. -webui.content_disposition_inline = application/pdf, image/gif, image/jpeg, image/png, audio/mpeg, video/mpeg, video/mp4 - #### Content Inline Disposition Threshold #### # -# Set the max size (in bytes) of a bitstream that can be served inline. This setting only applies to formats -# specified in the "webui.content_disposition_inline" configuration above. -# Default = 8MB (8388608 bytes). +# Set the max size in bytes of an allowlisted bitstream that can be served inline. +# Use -1 to disable the size limit for allowlisted formats. webui.content_disposition_threshold = 8388608 +#### Content Inline Disposition Formats #### +# +# MIME types or file extensions allowed inline; all other formats are downloaded. +# Active HTML, XML and JavaScript formats are downloaded regardless of this setting. +webui.content_disposition_inline = application/pdf, image/gif, image/jpeg, image/png, audio/mpeg, video/mpeg, video/mp4 + #### Multi-file HTML document/site settings ##### # TODO: UNSUPPORTED in DSpace 7.0. May be re-added in a later release # From 87fc74879d09f05af83b22b4405e2f0018825001 Mon Sep 17 00:00:00 2001 From: Matus Kasak Date: Tue, 27 Oct 2026 15:11:22 +0100 Subject: [PATCH 3/4] test: reload bitstream fixtures between content requests --- .../org/dspace/app/rest/BitstreamRestControllerIT.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 94b3e64288a2..c9f237e157eb 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 @@ -1456,11 +1456,11 @@ public void checkActiveContentFormatsNeverServedInline() throws Exception { for (String mimeType : mimeTypes) { context.turnOffAuthorisationSystem(); // Known formats exercise the guard instead of the unknown-format fallback. - BitstreamFormat format = createBitstreamFormat(context).withMimeType(mimeType) + createBitstreamFormat(context).withMimeType(mimeType) .withShortDescription("Active " + mimeType).withSupportLevel(KNOWN).build(); - Bitstream file = BitstreamBuilder.createBitstream(context, item, toInputStream("test", UTF_8)).build(); - file.setFormat(context, format); - bitstreamService.update(context, file); + item = context.reloadEntity(item); + Bitstream file = BitstreamBuilder.createBitstream(context, item, toInputStream("test", UTF_8)) + .withMimeType(mimeType).build(); context.restoreAuthSystemState(); String contentType = mimeType.split(";")[0] + ";charset=UTF-8"; @@ -1492,6 +1492,7 @@ public void checkMissingMimeTypeDownloads() throws Exception { configurationService.setProperty("webui.content_disposition_inline", new String[] {"pdf"}); for (String mimeType : new String[] {null, "", " "}) { context.turnOffAuthorisationSystem(); + supportedFormat = context.reloadEntity(supportedFormat); supportedFormat.setMIMEType(mimeType); bitstreamFormatService.update(context, supportedFormat); context.restoreAuthSystemState(); From fc8418185cf62ac6a1b23c21809464fc34126e71 Mon Sep 17 00:00:00 2001 From: Matus Kasak Date: Tue, 27 Oct 2026 15:31:30 +0100 Subject: [PATCH 4/4] fix: narrow active-content protection to existing download denylist --- .../app/rest/BitstreamRestController.java | 65 +++++++++++-------- .../app/rest/BitstreamRestControllerIT.java | 64 +++++++++--------- dspace/config/dspace.cfg | 18 +++-- 3 files changed, 79 insertions(+), 68 deletions(-) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamRestController.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamRestController.java index bf96110c3741..bf25b76aa5e8 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamRestController.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamRestController.java @@ -24,6 +24,7 @@ import javax.ws.rs.core.Response; import org.apache.catalina.connector.ClientAbortException; +import org.apache.commons.collections4.ListUtils; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.Logger; import org.dspace.app.rest.converter.ConverterService; @@ -183,13 +184,12 @@ public ResponseEntity retrieve(@PathVariable UUID uuid, HttpServletResponse resp httpHeadersInitializer.withLastModified(lastModified); } - // Serve only allowlisted formats inline, subject to the size limit. - long dispositionThreshold = configurationService.getLongProperty("webui.content_disposition_threshold", - 8388608); - if (checkFormatForContentDispositionInline(format) && - (dispositionThreshold == -1 || filesize <= dispositionThreshold)) { - httpHeadersInitializer.withDisposition(HttpHeadersInitializer.CONTENT_DISPOSITION_INLINE); - } else { + //Determine if we need to send the file as a download or if the browser can open it inline + //The file will be downloaded if its size is larger than the configured threshold, + //or if its mimetype/extension appears in the "webui.content_disposition_format" config + long dispositionThreshold = configurationService.getLongProperty("webui.content_disposition_threshold"); + if ((dispositionThreshold >= 0 && filesize > dispositionThreshold) + || checkFormatForContentDisposition(format)) { httpHeadersInitializer.withDisposition(HttpHeadersInitializer.CONTENT_DISPOSITION_ATTACHMENT); } @@ -284,46 +284,57 @@ private boolean isNotAnErrorResponse(HttpServletResponse response) { } /** - * Check whether a known, non-active format is allowed inline by configuration. + * Check if a Bitstream of the specified format should always be downloaded (i.e. "content-disposition: attachment") + * or can be opened inline (i.e. "content-disposition: inline"). + *

+ * NOTE that downloading via "attachment" is more secure, as the user's browser will not attempt to process or + * display the file. But, downloading via "inline" may be seen as more user-friendly for common formats. * @param format BitstreamFormat - * @return true if format is configured to be opened inline ("inline"). false if always download ("attachment") + * @return true if always download ("attachment"). false if can be opened inline ("inline") */ - private boolean checkFormatForContentDispositionInline(BitstreamFormat format) { + private boolean checkFormatForContentDisposition(BitstreamFormat format) { + // Undefined or Unknown formats should ALWAYS be downloaded for additional security. if (format == null || format.getSupportLevel() == BitstreamFormat.UNKNOWN || StringUtils.isBlank(format.getMIMEType())) { - return false; + return true; } + // Load additional formats configured to require download + List configuredFormats = List.of(configurationService. + getArrayProperty("webui.content_disposition_format")); + + // If configuration includes "*", then all formats will always be downloaded. + if (configuredFormats.contains("*")) { + return true; + } + + // Compare MIME types without parameters or case differences. String mimeType = StringUtils.substringBefore(format.getMIMEType(), ";").trim().toLowerCase(Locale.ROOT); - if (mimeType.isEmpty()) { - return false; + if (mimeType.isEmpty() || mimeType.endsWith("+xml") || mimeType.startsWith("text/javascript")) { + return true; } - // Block active content even when explicitly allowlisted. - List bannedInlineFormats = List.of("text/html", "text/javascript", "text/xml", "application/xml", - "application/javascript", "application/ecmascript", "text/ecmascript", + // Active formats must download regardless of configuration. + List downloadOnlyFormats = List.of("text/html", "text/javascript", "text/xml", "application/xml", + "rdf", "application/javascript", "application/ecmascript", "text/ecmascript", "application/x-javascript", "application/x-ecmascript", "text/x-javascript", "text/x-ecmascript", "text/jscript", "text/livescript"); - if (bannedInlineFormats.contains(mimeType) || mimeType.endsWith("+xml") - || mimeType.startsWith("text/javascript")) { - return false; - } - // Load formats configured to allow inline display - List formats = List.of(configurationService. - getArrayProperty("webui.content_disposition_inline")); + // Combine our two lists + List formats = ListUtils.union(downloadOnlyFormats, configuredFormats); // See if the passed in format's MIME type or file extension is listed. - boolean inline = formats.stream().anyMatch(value -> mimeType.equalsIgnoreCase(value.trim())); - if (!inline) { + boolean download = formats.stream().anyMatch(value -> + mimeType.equalsIgnoreCase(StringUtils.substringBefore(value, ";").trim())); + if (!download) { for (String ext : format.getExtensions()) { if (formats.contains(ext)) { - inline = true; + download = true; break; } } } - return inline; + return download; } /** 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 c9f237e157eb..6b0086a72adc 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 @@ -1317,7 +1317,12 @@ public void closeInputStreamsDownloadWithCoverPage() throws Exception { @Test public void checkContentDispositionOfFormats() throws Exception { - // Unlisted text formats should download by default. + configurationService.setProperty("webui.content_disposition_format", new String[] { + "text/richtext", + "text/xml", + "txt" + }); + context.turnOffAuthorisationSystem(); Community community = CommunityBuilder.createCommunity(context).build(); Collection collection = CollectionBuilder.createCollection(context, community).build(); @@ -1339,24 +1344,18 @@ public void checkContentDispositionOfFormats() throws Exception { } context.restoreAuthSystemState(); - // Based on default configuration all files should be downloaded + // these formats are configured and files should be downloaded verifyBitstreamDownload(rtf, "text/richtext;charset=UTF-8", true); verifyBitstreamDownload(xml, "text/xml;charset=UTF-8", true); verifyBitstreamDownload(txt, "text/plain;charset=UTF-8", true); - verifyBitstreamDownload(csv, "text/csv;charset=UTF-8", true); + // this format is not configured and should open inline + verifyBitstreamDownload(csv, "text/csv;charset=UTF-8", false); } @Test - public void checkBannedContentDispositionInlineFormats() throws Exception { - configurationService.setProperty("webui.content_disposition_inline", new String[] { - "text/html", - "text/javascript", - "rdf", - "text/xml", - "image/svg+xml" - }); - - // Explicit configuration must not bypass the active-content guard. + public void checkHardcodedContentDispositionFormats() throws Exception { + // This test is similar to the above test, but it verifies that our *hardcoded settings* for + // webui.content_disposition_format are protecting us from loading specific formats *inline*. context.turnOffAuthorisationSystem(); Community community = CommunityBuilder.createCommunity(context).build(); Collection collection = CollectionBuilder.createCollection(context, community).build(); @@ -1386,40 +1385,36 @@ public void checkBannedContentDispositionInlineFormats() throws Exception { } context.restoreAuthSystemState(); + // By default, HTML, JS & XML should all download. This protects us from possible XSS attacks, as + // each of these formats can embed JavaScript which may execute when the file is loaded *inline*. verifyBitstreamDownload(html, "text/html;charset=UTF-8", true); verifyBitstreamDownload(js, "text/javascript;charset=UTF-8", true); verifyBitstreamDownload(rdf, "application/rdf+xml;charset=UTF-8", true); verifyBitstreamDownload(xml, "text/xml;charset=UTF-8", true); // Unknown file formats should also always download verifyBitstreamDownload(unknown, "application/octet-stream;charset=UTF-8", true); - // Unregistered SVG falls back to the unknown format. + // SVG does NOT currently exist as a recognized format in DSpace's bitstream-formats.xml, but it's another + // format that allows embedded JavaScript. This test is a reminder that SVGs should NOT be opened inline. verifyBitstreamDownload(svg, "application/octet-stream;charset=UTF-8", true); } @Test - public void checkContentDispositionInlineFormats() throws Exception { - // Set PDF and a few image formats to verify they will display inline. But leave off "text/plain" - configurationService.setProperty("webui.content_disposition_inline", new String[] { - "text/csv", - "application/pdf", - "image/jpeg", - "video/mpeg" - }); + public void checkWildcardContentDispositionFormats() throws Exception { + // Setting "*" should result in all formats being downloaded (nothing will be opened inline) + configurationService.setProperty("webui.content_disposition_format", "*"); + context.turnOffAuthorisationSystem(); Community community = CommunityBuilder.createCommunity(context).build(); Collection collection = CollectionBuilder.createCollection(context, community).build(); Item item = ItemBuilder.createItem(context, collection).build(); String content = "Test Content"; Bitstream csv; - Bitstream txt; Bitstream jpg; Bitstream mpg; Bitstream pdf; try (InputStream is = IOUtils.toInputStream(content, CharEncoding.UTF_8)) { csv = BitstreamBuilder.createBitstream(context, item, is) .withMimeType("text/csv").build(); - txt = BitstreamBuilder.createBitstream(context, item, is) - .withMimeType("text/plain").build(); jpg = BitstreamBuilder.createBitstream(context, item, is) .withMimeType("image/jpeg").build(); mpg = BitstreamBuilder.createBitstream(context, item, is) @@ -1429,14 +1424,14 @@ public void checkContentDispositionInlineFormats() throws Exception { } context.restoreAuthSystemState(); - // Only text/plain should download, while other formats should be served inline based on the configuration - verifyBitstreamDownload(csv, "text/csv;charset=UTF-8", false); - verifyBitstreamDownload(jpg, "image/jpeg;charset=UTF-8", false); - verifyBitstreamDownload(mpg, "video/mpeg;charset=UTF-8", false); - verifyBitstreamDownload(pdf, "application/pdf;charset=UTF-8", false); - verifyBitstreamDownload(txt, "text/plain;charset=UTF-8", true); + // All formats should be download only + verifyBitstreamDownload(csv, "text/csv;charset=UTF-8", true); + verifyBitstreamDownload(jpg, "image/jpeg;charset=UTF-8", true); + verifyBitstreamDownload(mpg, "video/mpeg;charset=UTF-8", true); + verifyBitstreamDownload(pdf, "application/pdf;charset=UTF-8", true); } + @Test public void checkActiveContentFormatsNeverServedInline() throws Exception { String[] mimeTypes = { @@ -1445,7 +1440,7 @@ public void checkActiveContentFormatsNeverServedInline() throws Exception { "text/ecmascript", "application/x-javascript", "application/x-ecmascript", "text/x-javascript", "text/x-ecmascript", "text/jscript", "text/livescript", "text/javascript1.5" }; - configurationService.setProperty("webui.content_disposition_inline", mimeTypes); + configurationService.setProperty("webui.content_disposition_format", new String[0]); context.turnOffAuthorisationSystem(); Community community = CommunityBuilder.createCommunity(context).build(); @@ -1455,7 +1450,7 @@ public void checkActiveContentFormatsNeverServedInline() throws Exception { for (String mimeType : mimeTypes) { context.turnOffAuthorisationSystem(); - // Known formats exercise the guard instead of the unknown-format fallback. + // Exercise registered active formats, not the unknown-format fallback. createBitstreamFormat(context).withMimeType(mimeType) .withShortDescription("Active " + mimeType).withSupportLevel(KNOWN).build(); item = context.reloadEntity(item); @@ -1473,7 +1468,6 @@ public void checkActiveContentFormatsNeverServedInline() throws Exception { @Test public void checkInlineDispositionSizeLimit() throws Exception { - configurationService.setProperty("webui.content_disposition_inline", new String[] {"application/pdf"}); context.turnOffAuthorisationSystem(); bitstream.setFormat(context, bitstreamFormatService.findByMIMEType(context, "application/pdf")); bitstreamService.update(context, bitstream); @@ -1489,7 +1483,7 @@ public void checkInlineDispositionSizeLimit() throws Exception { @Test public void checkMissingMimeTypeDownloads() throws Exception { - configurationService.setProperty("webui.content_disposition_inline", new String[] {"pdf"}); + configurationService.setProperty("webui.content_disposition_format", new String[0]); for (String mimeType : new String[] {null, "", " "}) { context.turnOffAuthorisationSystem(); supportedFormat = context.reloadEntity(supportedFormat); diff --git a/dspace/config/dspace.cfg b/dspace/config/dspace.cfg index 9590f904e07f..fe4eced2855d 100644 --- a/dspace/config/dspace.cfg +++ b/dspace/config/dspace.cfg @@ -1417,15 +1417,21 @@ websvc.opensearch.max_num_of_items_per_request = 100 #### Content Inline Disposition Threshold #### # -# Set the max size in bytes of an allowlisted bitstream that can be served inline. -# Use -1 to disable the size limit for allowlisted formats. +# Set the max size of a bitstream that can be served inline +# Use -1 to force all bitstream to be served inline webui.content_disposition_threshold = 8388608 -#### Content Inline Disposition Formats #### +#### Content Attachment Disposition Formats #### # -# MIME types or file extensions allowed inline; all other formats are downloaded. -# Active HTML, XML and JavaScript formats are downloaded regardless of this setting. -webui.content_disposition_inline = application/pdf, image/gif, image/jpeg, image/png, audio/mpeg, video/mpeg, video/mp4 +# Set which mimetypes or file extensions will NOT be opened inline. +# Files with these mimetypes/extensions will always be downloaded, regardless of the threshold above. +# NOTE: For security reasons, some file formats (e.g. HTML, XML, RDF, JS) will always be downloaded regardless +# of the settings here. This blocks these formats from executing embedded JavaScript when opened inline. +# For additional security, you may choose to set this to "*" to force all formats to always be downloaded +# (i.e. disables all formats from opening inline within the user's browser). +# +# By default, RTF is always downloaded because most browsers attempt to display it as plain text. +webui.content_disposition_format = text/richtext #### Multi-file HTML document/site settings ##### # TODO: UNSUPPORTED in DSpace 7.0. May be re-added in a later release