From 8b16b386e2d10f5dab19c2dc7e5a8a236a2997a8 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 23 Jul 2026 11:35:56 +0200 Subject: [PATCH 1/5] remove inline annotation --- .../cds-feature-attachments/attachments-annotations.cds | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds index 85ed597a..d1bcd424 100644 --- a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds +++ b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds @@ -37,8 +37,7 @@ annotate sap.attachments.MediaData with @UI.MediaResource: {Stream: content} { annotate sap.attachments.Attachments { content @( Core.ContentDisposition: { - Filename: fileName, - Type : 'inline', + Filename: fileName }, Core.MediaType : mimeType ); From 4dc6a5ef3a8ff7a7ee2eeb68ebd58ed9b87b783a Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 23 Jul 2026 11:36:01 +0200 Subject: [PATCH 2/5] add test --- ...stHandlerAndWithoutMalwareScannerTest.java | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithoutTestHandlerAndWithoutMalwareScannerTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithoutTestHandlerAndWithoutMalwareScannerTest.java index 6c43d8e3..71454784 100644 --- a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithoutTestHandlerAndWithoutMalwareScannerTest.java +++ b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithoutTestHandlerAndWithoutMalwareScannerTest.java @@ -1,5 +1,5 @@ /* - * © 2024 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + * © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. */ package com.sap.cds.feature.attachments.integrationtests.nondraftservice; @@ -24,6 +24,41 @@ void serviceHandlerIsNull() { assertThat(serviceHandler).isNull(); } + /** + * Regression test for the stored XSS mitigation (see cds-calesi #1263). Content served for + * attachments must default to {@code Content-Disposition: attachment} so that browsers download + * the file rather than render it inline in the application origin. Applications that require + * inline previews can opt in by annotating {@code content} in their own CDS model. + */ + @Test + void contentDownloadUsesAttachmentDisposition() throws Exception { + var serviceRoot = buildServiceRootWithDeepData(); + postServiceRoot(serviceRoot); + + var selectedRoot = selectStoredRootWithDeepData(); + var item = getItemWithAttachment(selectedRoot); + var itemAttachment = getRandomItemAttachment(item); + putContentForAttachmentWithNavigation(selectedRoot, itemAttachment); + clearServiceHandlerContext(); + + var contentUrl = + buildNavigationAttachmentUrl(selectedRoot.getId(), item.getId(), itemAttachment.getId()) + + "/content"; + + // Wait for the (async) malware scan to complete and content to become readable. + Awaitility.await() + .atMost(10, TimeUnit.SECONDS) + .until(() -> requestHelper.executeGet(contentUrl).getResponse().getStatus() == 200); + + var response = requestHelper.executeGet(contentUrl); + var contentDisposition = response.getResponse().getHeader("Content-Disposition"); + assertThat(contentDisposition) + .as("Content-Disposition must default to 'attachment' (see cds-calesi #1263)") + .isNotNull() + .startsWithIgnoringCase("attachment"); + assertThat(contentDisposition).doesNotStartWithIgnoringCase("inline"); + } + @Override protected void executeContentRequestAndValidateContent(String url, String content) throws Exception { From 3d2a5fcc1a9ed314f3c4e789862f3ecc1f0bb417 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 23 Jul 2026 11:36:05 +0200 Subject: [PATCH 3/5] add readme --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 7d5b38f4..3828748b 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ It supports the [AWS, Azure, and Google object stores](storage-targets/cds-featu * [Malware Scanner](#malware-scanner) * [Specify the maximum file size](#specify-the-maximum-file-size) * [Restrict allowed MIME types](#restrict-allowed-mime-types) + * [Content Disposition](#content-disposition) * [Outbox](#outbox) * [Restore Endpoint](#restore-endpoint) * [Motivation](#motivation) @@ -249,6 +250,21 @@ annotate Books.attachments with { } ``` +### Content Disposition + +Attachment content is served with `Content-Disposition: attachment`. Browsers therefore download the file instead of rendering it inline in the application origin. This is the safe default that mitigates stored XSS attacks via user-uploaded HTML or SVG payloads, because the content type of a stored attachment is derived from the uploaded file. + +If your application intentionally wants to preview certain attachments inline (e.g. images restricted via `@Core.AcceptableMediaTypes`), you can opt in explicitly in your own CDS model: + +```cds +using { sap.attachments.Attachments } from '@cap-js/cds-feature-attachments'; + +annotate Books.attachments with { + content @Core.ContentDisposition.Type: 'inline'; +} +``` + +> **Security warning:** Only enable inline disposition for MIME types that cannot execute script (for example, restrict to `image/jpeg`, `image/png`, `application/pdf` via `@Core.AcceptableMediaTypes`). Never enable inline disposition for `image/svg+xml`, `text/html`, `application/xhtml+xml`, or `application/xml` when the uploader is not fully trusted. ### Outbox From 9a6ce7b3973368402f66ac67937e75391e1e4da7 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 23 Jul 2026 11:36:18 +0200 Subject: [PATCH 4/5] add to changelog --- doc/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index 300f2a34..ac01138f 100644 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -6,6 +6,12 @@ This project adheres to [Semantic Versioning](http://semver.org/). The format is based on [Keep a Changelog](http://keepachangelog.com/). +## Unreleased + +### Security + +- Attachment content is now served with `Content-Disposition: attachment` by default (previously `inline`) to mitigate stored XSS (CWE-79) via user-uploaded SVG/HTML payloads (cds-calesi #1263). Applications that require inline previews can opt in by annotating `content` in their own CDS model — see the "Content Disposition" section in the README. It is recommended to combine this with `@Core.AcceptableMediaTypes` restricting inline content to non-scriptable types (e.g. `image/jpeg`, `image/png`, `application/pdf`). + ## Version 1.6.0 - 2026-07-17 ### Added From 391e9673c9a9baf6be88c7afe192704356a71aba Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 23 Jul 2026 12:07:10 +0200 Subject: [PATCH 5/5] adress review comments --- README.md | 2 +- ...stHandlerAndWithoutMalwareScannerTest.java | 22 ++++++++++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3828748b..f129d998 100644 --- a/README.md +++ b/README.md @@ -264,7 +264,7 @@ annotate Books.attachments with { } ``` -> **Security warning:** Only enable inline disposition for MIME types that cannot execute script (for example, restrict to `image/jpeg`, `image/png`, `application/pdf` via `@Core.AcceptableMediaTypes`). Never enable inline disposition for `image/svg+xml`, `text/html`, `application/xhtml+xml`, or `application/xml` when the uploader is not fully trusted. +> **Security warning:** Only enable inline disposition for MIME types that cannot execute script — for example, plain images such as `image/jpeg` or `image/png` — restricted via `@Core.AcceptableMediaTypes`. Never enable inline disposition for `image/svg+xml`, `text/html`, `application/xhtml+xml`, or `application/xml` when the uploader is not fully trusted. Note that `application/pdf` may execute JavaScript in some PDF viewers (notably Adobe Reader); verify the viewer used by your target audience before allowing PDFs inline. ### Outbox diff --git a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithoutTestHandlerAndWithoutMalwareScannerTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithoutTestHandlerAndWithoutMalwareScannerTest.java index 71454784..2f93b73d 100644 --- a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithoutTestHandlerAndWithoutMalwareScannerTest.java +++ b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithoutTestHandlerAndWithoutMalwareScannerTest.java @@ -11,9 +11,11 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import org.awaitility.Awaitility; import org.junit.jupiter.api.Test; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MvcResult; @ActiveProfiles(Profiles.TEST_HANDLER_DISABLED) class OdataRequestValidationWithoutTestHandlerAndWithoutMalwareScannerTest @@ -45,18 +47,26 @@ void contentDownloadUsesAttachmentDisposition() throws Exception { buildNavigationAttachmentUrl(selectedRoot.getId(), item.getId(), itemAttachment.getId()) + "/content"; - // Wait for the (async) malware scan to complete and content to become readable. + // Wait for the (async) malware scan to complete and content to become readable, + // capturing the successful response for assertion (avoids a redundant second GET). + var responseRef = new AtomicReference(); Awaitility.await() .atMost(10, TimeUnit.SECONDS) - .until(() -> requestHelper.executeGet(contentUrl).getResponse().getStatus() == 200); + .until( + () -> { + var r = requestHelper.executeGet(contentUrl); + if (r.getResponse().getStatus() == 200) { + responseRef.set(r); + return true; + } + return false; + }); - var response = requestHelper.executeGet(contentUrl); - var contentDisposition = response.getResponse().getHeader("Content-Disposition"); + var contentDisposition = responseRef.get().getResponse().getHeader("Content-Disposition"); assertThat(contentDisposition) .as("Content-Disposition must default to 'attachment' (see cds-calesi #1263)") .isNotNull() - .startsWithIgnoringCase("attachment"); - assertThat(contentDisposition).doesNotStartWithIgnoringCase("inline"); + .startsWith("attachment;"); } @Override