From e7475f1bfb219f01ca262551aeedeacb3cbe6989 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Tue, 21 Jul 2026 12:25:36 +0200 Subject: [PATCH 1/2] fix: validate header-derived file name and MIME type on create CreateAttachmentEvent falls back to the Content-Disposition/slug and Content-Type headers for the file name and MIME type. This fallback ran AFTER acceptable-media-type validation, so a direct media upload that supplied the file name / MIME type only via headers bypassed @Core.AcceptableMediaTypes while storage still derived and persisted those header values. The header fallback logic is extracted into HeaderMediaMetadataResolver and now also applied to the request data before validation, so the allowlist is enforced against the exact values that will be persisted and served. CreateAttachmentEvent reuses the same resolver. --- .../CreateAttachmentsHandler.java | 5 + .../helper/HeaderMediaMetadataResolver.java | 117 ++++++++++++++++++ .../modifyevents/CreateAttachmentEvent.java | 55 +------- 3 files changed, 125 insertions(+), 52 deletions(-) create mode 100644 cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/HeaderMediaMetadataResolver.java diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java index c5c2c863a..9a3e5b1d7 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java @@ -5,6 +5,7 @@ import com.sap.cds.CdsData; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ExtendedErrorStatuses; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.HeaderMediaMetadataResolver; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ReadonlyDataContextEnhancer; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader; @@ -69,6 +70,10 @@ void processBeforeForDraft(CdsCreateEventContext context, List data) { @HandlerOrder(HandlerOrder.BEFORE) void processBeforeForMetadata(EventContext context, List data) { CdsEntity target = context.getTarget(); + // Normalize file name / MIME type derived from request headers (Content-Disposition, slug, + // Content-Type) into the data first, so acceptable-media-type validation runs over the exact + // values that storage will persist and the read model will serve. + HeaderMediaMetadataResolver.applyHeaderFallback(target, data, context); AttachmentValidationHelper.validateMediaAttachments(target, data, cdsRuntime); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/HeaderMediaMetadataResolver.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/HeaderMediaMetadataResolver.java new file mode 100644 index 000000000..c6c41a257 --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/HeaderMediaMetadataResolver.java @@ -0,0 +1,117 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper; + +import com.sap.cds.CdsData; +import com.sap.cds.CdsDataProcessor; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; +import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.services.EventContext; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Resolves attachment metadata (file name, MIME type) from HTTP headers and applies it as a + * fallback to the request data. + * + *

For direct media uploads the file name is typically provided via the {@code + * Content-Disposition} or {@code slug} header and the MIME type via the {@code Content-Type} header + * rather than in the request payload. This resolver normalizes those header values into the + * attachment data before acceptable-media-type validation runs, so that validation and + * storage operate on the same file name / MIME type that is ultimately persisted and served. + */ +public final class HeaderMediaMetadataResolver { + + private static final Pattern RFC5987_FILENAME_PATTERN = + Pattern.compile("filename\\*=UTF-8''([^;]+)", Pattern.CASE_INSENSITIVE); + private static final Pattern PLAIN_FILENAME_PATTERN = + Pattern.compile("(? data, EventContext eventContext) { + if (entity == null + || data == null + || data.isEmpty() + || eventContext.getParameterInfo() == null) { + return; + } + + CdsDataProcessor.create() + .addValidator( + ApplicationHandlerHelper.MEDIA_CONTENT_FILTER, + (path, element, value) -> { + Map values = path.target().values(); + if (values.get(MediaData.FILE_NAME) == null) { + extractFileNameFromHeader(eventContext) + .ifPresent(fn -> values.put(MediaData.FILE_NAME, fn)); + } + if (values.get(MediaData.MIME_TYPE) == null) { + extractMimeTypeFromHeader(eventContext) + .ifPresent(mt -> values.put(MediaData.MIME_TYPE, mt)); + } + }) + .process(data, entity); + } + + /** + * Extracts the file name from the {@code Content-Disposition} header or falls back to the {@code + * slug} header. Supports RFC 5987 encoded file names ({@code filename*=UTF-8''...}) and plain + * file names. + */ + public static Optional extractFileNameFromHeader(EventContext eventContext) { + String header = eventContext.getParameterInfo().getHeader("Content-Disposition"); + if (header != null) { + // Try RFC 5987 encoded filename first (filename*=UTF-8''...) + Matcher utf8Matcher = RFC5987_FILENAME_PATTERN.matcher(header); + if (utf8Matcher.find()) { + return Optional.of(URLDecoder.decode(utf8Matcher.group(1), StandardCharsets.UTF_8)); + } + // Fall back to plain filename= + Matcher plainMatcher = PLAIN_FILENAME_PATTERN.matcher(header); + if (plainMatcher.find()) { + return Optional.of(plainMatcher.group(1).trim()); + } + } + // Fiori Elements may use the slug header instead + String slug = eventContext.getParameterInfo().getHeader("slug"); + if (slug != null) { + return Optional.of(URLDecoder.decode(slug, StandardCharsets.UTF_8)); + } + return Optional.empty(); + } + + /** + * Extracts the MIME type from the {@code Content-Type} header, stripping charset and other + * parameters. Returns empty if the {@code Content-Type} is null or empty. + */ + public static Optional extractMimeTypeFromHeader(EventContext eventContext) { + String contentType = eventContext.getParameterInfo().getHeader("Content-Type"); + if (contentType == null) { + return Optional.empty(); + } + String mimeType = contentType.split(";")[0].trim(); + if (mimeType.isEmpty()) { + return Optional.empty(); + } + return Optional.of(mimeType); + } + + private HeaderMediaMetadataResolver() { + // to prevent instantiation + } +} diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java index 530dddbe1..cf0082ef5 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java @@ -7,6 +7,7 @@ import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.HeaderMediaMetadataResolver; import com.sap.cds.feature.attachments.handler.applicationservice.transaction.ListenerProvider; import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; import com.sap.cds.feature.attachments.service.AttachmentService; @@ -16,12 +17,8 @@ import com.sap.cds.services.EventContext; import com.sap.cds.services.changeset.ChangeSetListener; import java.io.InputStream; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,10 +30,6 @@ public class CreateAttachmentEvent implements ModifyAttachmentEvent { private static final Logger logger = LoggerFactory.getLogger(CreateAttachmentEvent.class); - private static final Pattern RFC5987_FILENAME_PATTERN = - Pattern.compile("filename\\*=UTF-8''([^;]+)", Pattern.CASE_INSENSITIVE); - private static final Pattern PLAIN_FILENAME_PATTERN = - Pattern.compile("(? values.put(MediaData.FILE_NAME, fn)); } if (mimeTypeOptional.isEmpty()) { - mimeTypeOptional = extractMimeTypeFromHeader(eventContext); + mimeTypeOptional = HeaderMediaMetadataResolver.extractMimeTypeFromHeader(eventContext); mimeTypeOptional.ifPresent(mt -> values.put(MediaData.MIME_TYPE, mt)); } } @@ -96,46 +89,4 @@ private static Optional getFieldValue( Object value = nonNull(annotationValue) ? annotationValue : attachment.get(fieldName); return Optional.ofNullable((String) value); } - - /** - * Extracts the filename from the Content-Disposition header or falls back to the slug header. - * Supports RFC 5987 encoded filenames (filename*=UTF-8''...) and plain filenames. - */ - private static Optional extractFileNameFromHeader(EventContext eventContext) { - String header = eventContext.getParameterInfo().getHeader("Content-Disposition"); - if (header != null) { - // Try RFC 5987 encoded filename first (filename*=UTF-8''...) - Matcher utf8Matcher = RFC5987_FILENAME_PATTERN.matcher(header); - if (utf8Matcher.find()) { - return Optional.of(URLDecoder.decode(utf8Matcher.group(1), StandardCharsets.UTF_8)); - } - // Fall back to plain filename= - Matcher plainMatcher = PLAIN_FILENAME_PATTERN.matcher(header); - if (plainMatcher.find()) { - return Optional.of(plainMatcher.group(1).trim()); - } - } - // Fiori Elements may use the slug header instead - String slug = eventContext.getParameterInfo().getHeader("slug"); - if (slug != null) { - return Optional.of(URLDecoder.decode(slug, StandardCharsets.UTF_8)); - } - return Optional.empty(); - } - - /** - * Extracts the MIME type from the Content-Type header, stripping charset and other parameters. - * Returns empty if the Content-Type is null or empty. - */ - private static Optional extractMimeTypeFromHeader(EventContext eventContext) { - String contentType = eventContext.getParameterInfo().getHeader("Content-Type"); - if (contentType == null) { - return Optional.empty(); - } - String mimeType = contentType.split(";")[0].trim(); - if (mimeType.isEmpty()) { - return Optional.empty(); - } - return Optional.of(mimeType); - } } From 2017422a1e142cc2ba9c30a0c9b9d03a87103a68 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Tue, 21 Jul 2026 12:33:15 +0200 Subject: [PATCH 2/2] test: cover header-derived media metadata normalization Adds HeaderMediaMetadataResolverTest verifying that file name and MIME type are resolved from the Content-Disposition/slug and Content-Type headers and filled into the attachment data (without overriding payload values), and a CreateAttachmentsHandler test asserting the header fallback is applied before acceptable-media-type validation. --- .../CreateAttachmentsHandlerTest.java | 23 ++++ .../HeaderMediaMetadataResolverTest.java | 119 ++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/HeaderMediaMetadataResolverTest.java diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java index 37ed28c8a..bc7d183ad 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java @@ -26,6 +26,7 @@ import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ExtendedErrorStatuses; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.HeaderMediaMetadataResolver; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader; import com.sap.cds.feature.attachments.handler.applicationservice.helper.mimeTypeValidation.AttachmentValidationHelper; @@ -371,6 +372,28 @@ void processBeforeForMetadata_executesValidation() { } } + @Test + void processBeforeForMetadata_appliesHeaderFallbackBeforeValidation() { + EventContext context = mock(EventContext.class); + CdsEntity entity = mock(CdsEntity.class); + List data = List.of(mock(CdsData.class)); + when(context.getTarget()).thenReturn(entity); + + try (MockedStatic resolver = + mockStatic(HeaderMediaMetadataResolver.class); + MockedStatic helper = + mockStatic(AttachmentValidationHelper.class)) { + // when + new CreateAttachmentsHandler(eventFactory, storageReader, "400MB", runtime) + .processBeforeForMetadata(context, data); + + // then header-derived metadata is normalized into the data and validation runs over it + resolver.verify(() -> HeaderMediaMetadataResolver.applyHeaderFallback(entity, data, context)); + helper.verify( + () -> AttachmentValidationHelper.validateMediaAttachments(entity, data, runtime)); + } + } + private void getEntityAndMockContext(String cdsName) { var serviceEntity = runtime.getCdsModel().findEntity(cdsName); mockTargetInCreateContext(serviceEntity.orElseThrow()); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/HeaderMediaMetadataResolverTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/HeaderMediaMetadataResolverTest.java new file mode 100644 index 000000000..3248516ba --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/HeaderMediaMetadataResolverTest.java @@ -0,0 +1,119 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.services.EventContext; +import com.sap.cds.services.request.ParameterInfo; +import com.sap.cds.services.runtime.CdsRuntime; +import java.io.InputStream; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class HeaderMediaMetadataResolverTest { + + private static final String MEDIA_ENTITY = "unit.test.Attachment"; + + private static CdsRuntime runtime; + + private EventContext eventContext; + private ParameterInfo parameterInfo; + + @BeforeAll + static void classSetup() { + runtime = RuntimeHelper.runtime; + } + + @BeforeEach + void setup() { + eventContext = mock(EventContext.class); + parameterInfo = mock(ParameterInfo.class); + when(eventContext.getParameterInfo()).thenReturn(parameterInfo); + } + + @Test + void extractsFileNameFromContentDispositionHeader() { + when(parameterInfo.getHeader("Content-Disposition")) + .thenReturn("attachment; filename=\"report.pdf\""); + + assertThat(HeaderMediaMetadataResolver.extractFileNameFromHeader(eventContext)) + .contains("report.pdf"); + } + + @Test + void extractsFileNameFromSlugHeader() { + when(parameterInfo.getHeader("Content-Disposition")).thenReturn(null); + when(parameterInfo.getHeader("slug")).thenReturn("document.docx"); + + assertThat(HeaderMediaMetadataResolver.extractFileNameFromHeader(eventContext)) + .contains("document.docx"); + } + + @Test + void extractsMimeTypeFromContentTypeHeaderStrippingParameters() { + when(parameterInfo.getHeader("Content-Type")).thenReturn("text/html; charset=utf-8"); + + assertThat(HeaderMediaMetadataResolver.extractMimeTypeFromHeader(eventContext)) + .contains("text/html"); + } + + @Test + void returnsEmptyWhenNoHeadersPresent() { + assertThat(HeaderMediaMetadataResolver.extractFileNameFromHeader(eventContext)).isEmpty(); + assertThat(HeaderMediaMetadataResolver.extractMimeTypeFromHeader(eventContext)).isEmpty(); + } + + @Test + void fillsFileNameAndMimeTypeFromHeadersWhenAbsentInData() { + CdsEntity entity = runtime.getCdsModel().getEntity(MEDIA_ENTITY); + Attachments data = Attachments.create(); + data.setContent(mock(InputStream.class)); + when(parameterInfo.getHeader("Content-Disposition")) + .thenReturn("attachment; filename=\"notes.txt\""); + when(parameterInfo.getHeader("Content-Type")).thenReturn("text/html"); + + HeaderMediaMetadataResolver.applyHeaderFallback(entity, List.of(data), eventContext); + + assertThat(data.getFileName()).isEqualTo("notes.txt"); + assertThat(data.getMimeType()).isEqualTo("text/html"); + } + + @Test + void doesNotOverridePayloadValuesWithHeaderValues() { + CdsEntity entity = runtime.getCdsModel().getEntity(MEDIA_ENTITY); + Attachments data = Attachments.create(); + data.setContent(mock(InputStream.class)); + data.setFileName("payload.png"); + data.setMimeType("image/png"); + when(parameterInfo.getHeader("Content-Disposition")) + .thenReturn("attachment; filename=\"notes.txt\""); + when(parameterInfo.getHeader("Content-Type")).thenReturn("text/html"); + + HeaderMediaMetadataResolver.applyHeaderFallback(entity, List.of(data), eventContext); + + assertThat(data.getFileName()).isEqualTo("payload.png"); + assertThat(data.getMimeType()).isEqualTo("image/png"); + } + + @Test + void doesNothingWhenDataHasNoContentElement() { + CdsEntity entity = runtime.getCdsModel().getEntity(MEDIA_ENTITY); + Attachments data = Attachments.create(); + when(parameterInfo.getHeader("Content-Disposition")) + .thenReturn("attachment; filename=\"notes.txt\""); + + HeaderMediaMetadataResolver.applyHeaderFallback(entity, List.of(data), eventContext); + + assertThat(data.getFileName()).isNull(); + assertThat(data.getMimeType()).isNull(); + } +}