diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentDataExtractor.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentDataExtractor.java index 1250c034a..6c73e0180 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentDataExtractor.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentDataExtractor.java @@ -23,8 +23,11 @@ public final class AttachmentDataExtractor { private static final String FILE_NAME_FIELD = "fileName"; + private static final String MIME_TYPE_FIELD = "mimeType"; public static final Filter FILE_NAME_FILTER = (path, element, type) -> element.getName().contentEquals(FILE_NAME_FIELD); + public static final Filter MIME_TYPE_FILTER = + (path, element, type) -> element.getName().contentEquals(MIME_TYPE_FIELD); /** * Extracts and validates file names of attachments from the given entity data. @@ -42,6 +45,34 @@ public static Map> extractAndValidateFileNamesByElement( return fileNamesByElementName; } + /** + * Extracts the explicitly supplied {@code mimeType} values of attachments from the given entity + * data, keyed by the qualified name of the declaring media entity. + * + *

Unlike file names, these values are not derived from the file extension but taken verbatim + * from the request payload (or, later, HTTP headers). They must therefore be validated against + * the acceptable media types as well, otherwise an allowed file name could be paired with a + * disallowed MIME type that is subsequently persisted and served inline. + * + * @param entity the CDS entity definition + * @param data the incoming data containing attachment values + * @return a map of declaring media entity names to sets of supplied MIME types + */ + public static Map> extractMimeTypesByElement( + CdsEntity entity, List data) { + Map> mimeTypesByElementName = new HashMap<>(); + CdsDataProcessor processor = CdsDataProcessor.create(); + Validator mimeTypeValidator = + (path, element, value) -> { + if (value instanceof String mimeType && !mimeType.isBlank()) { + String key = element.getDeclaringType().getQualifiedName(); + mimeTypesByElementName.computeIfAbsent(key, k -> new HashSet<>()).add(mimeType.trim()); + } + }; + processor.addValidator(MIME_TYPE_FILTER, mimeTypeValidator).process(data, entity); + return mimeTypesByElementName; + } + private static Map> collectFileNamesByElementName( CdsEntity entity, List data) { // Use CdsProcessor to traverse the data and collect file names for elements diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelper.java index 72df1920b..a998dbdce 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelper.java @@ -60,6 +60,54 @@ public static void validateMediaAttachments( Map> fileNamesByElementName = AttachmentDataExtractor.extractAndValidateFileNamesByElement(entity, data); validateAttachmentMediaTypes(fileNamesByElementName, allowedTypesByElementName); + + // validate the explicitly supplied mime types as well. The file name check alone can be + // bypassed by pairing an allowed file name (e.g. "report.pdf") with a disallowed MIME type + // (e.g. "text/html") that is persisted and served inline. + Map> mimeTypesByElementName = + AttachmentDataExtractor.extractMimeTypesByElement(entity, data); + validateAttachmentMimeTypes(mimeTypesByElementName, allowedTypesByElementName); + } + + private static void validateAttachmentMimeTypes( + Map> mimeTypesByElementName, + Map> acceptableMediaTypesByElementName) { + + Map> invalidMimeTypes = + findInvalidMimeTypesByElementName( + mimeTypesByElementName, acceptableMediaTypesByElementName); + + if (!invalidMimeTypes.isEmpty()) { + throw buildUnsupportedFileTypeMessage(acceptableMediaTypesByElementName, invalidMimeTypes); + } + } + + private static Map> findInvalidMimeTypesByElementName( + Map> mimeTypesByElementName, + Map> acceptableMediaTypesByElementName) { + if (mimeTypesByElementName == null || mimeTypesByElementName.isEmpty()) { + return Map.of(); + } + Map> invalidMimeTypes = new HashMap<>(); + mimeTypesByElementName.forEach( + (elementName, mimeTypes) -> { + List acceptableTypes = acceptableMediaTypesByElementName.get(elementName); + if (acceptableTypes == null) { + return; + } + + List invalid = + mimeTypes.stream() + .filter( + mimeType -> !MediaTypeService.isMimeTypeAllowed(acceptableTypes, mimeType)) + .toList(); + + if (!invalid.isEmpty()) { + invalidMimeTypes.put(elementName, invalid); + } + }); + + return invalidMimeTypes; } private static void validateAttachmentMediaTypes( diff --git a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/MediaValidatedAttachmentsNonDraftTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/MediaValidatedAttachmentsNonDraftTest.java index cc8c2e099..db5f8f714 100644 --- a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/MediaValidatedAttachmentsNonDraftTest.java +++ b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/MediaValidatedAttachmentsNonDraftTest.java @@ -82,6 +82,28 @@ void shouldRejectAttachment_whenFileNameIsEmpty() throws Exception { status().isBadRequest()); } + @Test + void shouldRejectAttachment_whenAllowedFileNameCarriesDisallowedMimeType() throws Exception { + String rootId = createRootAndReturnId(); + String attachmentMetadata = + objectMapper.writeValueAsString(Map.of("fileName", "image.jpg", "mimeType", "text/html")); + + requestHelper.assertPostStatus( + createUrl(rootId, MEDIA_VALIDATED_ATTACHMENTS), + attachmentMetadata, + status().isUnsupportedMediaType()); + } + + @Test + void shouldAcceptAttachment_whenAllowedFileNameAndAllowedMimeType() throws Exception { + String rootId = createRootAndReturnId(); + String attachmentMetadata = + objectMapper.writeValueAsString(Map.of("fileName", "image.jpg", "mimeType", "image/jpeg")); + + requestHelper.assertPostStatus( + createUrl(rootId, MEDIA_VALIDATED_ATTACHMENTS), attachmentMetadata, status().isCreated()); + } + @Test void shouldAcceptUppercaseExtension_whenMimeTypeIsAllowed() throws Exception { String rootId = createRootAndReturnId();