Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ It supports the [AWS, Azure, and Google object stores](storage-targets/cds-featu
* [Storage Targets](#storage-targets)
* [Malware Scanner](#malware-scanner)
* [Specify the maximum file size](#specify-the-maximum-file-size)
* [Restrict allowed MIME types](#restrict-allowed-mime-types)
* [Outbox](#outbox)
* [Restore Endpoint](#restore-endpoint)
* [Motivation](#motivation)
Expand Down Expand Up @@ -206,6 +207,37 @@ annotate Books.attachments with {

The default is 400MB

### Restrict allowed MIME types

You can restrict which MIME types are allowed for attachments by annotating the content property with @Core.AcceptableMediaTypes. This validation is performed during file upload.

```cds
entity Books {
...
attachments: Composition of many Attachments;
}

annotate Books.attachments with {
content @Core.AcceptableMediaTypes : ['image/jpeg', 'image/png', 'application/pdf'];
}
```

Wildcard patterns are supported:

```cds
annotate Books.attachments with {
content @Core.AcceptableMediaTypes : ['image/*', 'application/pdf'];
}
```

To allow all MIME types (default behavior), either omit the annotation or use:

```cds
annotate Books.attachments with {
content @Core.AcceptableMediaTypes : ['*/*'];
}
```

### Outbox

In this plugin the [persistent outbox](https://cap.cloud.sap/docs/java/outbox#persistent) is used to mark attachments as
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/*
* © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
*/
package com.sap.cds.feature.attachments.handler.applicationservice.helper;

import java.net.URLConnection;
import java.util.Collection;
import java.util.List;
import java.util.Map;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.sap.cds.services.ServiceException;
import com.sap.cds.services.ErrorStatuses;

public class AttachmentValidationHelper {

public static final String DEFAULT_MEDIA_TYPE = "application/octet-stream";
public static final Map<String, String> EXT_TO_MEDIA_TYPE = Map.ofEntries(
Map.entry("aac", "audio/aac"),
Map.entry("abw", "application/x-abiword"),
Map.entry("arc", "application/octet-stream"),
Map.entry("avi", "video/x-msvideo"),
Map.entry("azw", "application/vnd.amazon.ebook"),
Map.entry("bin", "application/octet-stream"),
Map.entry("png", "image/png"),
Map.entry("gif", "image/gif"),
Map.entry("bmp", "image/bmp"),
Map.entry("bz", "application/x-bzip"),
Map.entry("bz2", "application/x-bzip2"),
Map.entry("csh", "application/x-csh"),
Map.entry("css", "text/css"),
Map.entry("csv", "text/csv"),
Map.entry("doc", "application/msword"),
Map.entry("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
Map.entry("odp", "application/vnd.oasis.opendocument.presentation"),
Map.entry("ods", "application/vnd.oasis.opendocument.spreadsheet"),
Map.entry("odt", "application/vnd.oasis.opendocument.text"),
Map.entry("epub", "application/epub+zip"),
Map.entry("gz", "application/gzip"),
Map.entry("htm", "text/html"),
Map.entry("html", "text/html"),
Map.entry("ico", "image/x-icon"),
Map.entry("ics", "text/calendar"),
Map.entry("jar", "application/java-archive"),
Map.entry("jpg", "image/jpeg"),
Map.entry("jpeg", "image/jpeg"),
Map.entry("js", "text/javascript"),
Map.entry("json", "application/json"),
Map.entry("mid", "audio/midi"),
Map.entry("midi", "audio/midi"),
Map.entry("mjs", "text/javascript"),
Map.entry("mov", "video/quicktime"),
Map.entry("mp3", "audio/mpeg"),
Map.entry("mp4", "video/mp4"),
Map.entry("mpeg", "video/mpeg"),
Map.entry("mpkg", "application/vnd.apple.installer+xml"),
Map.entry("otf", "font/otf"),
Map.entry("pdf", "application/pdf"),
Map.entry("ppt", "application/vnd.ms-powerpoint"),
Map.entry("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"),
Map.entry("rar", "application/x-rar-compressed"),
Map.entry("rtf", "application/rtf"),
Map.entry("svg", "image/svg+xml"),
Map.entry("tar", "application/x-tar"),
Map.entry("tif", "image/tiff"),
Map.entry("tiff", "image/tiff"),
Map.entry("ttf", "font/ttf"),
Map.entry("vsd", "application/vnd.visio"),
Map.entry("wav", "audio/wav"),
Map.entry("woff", "font/woff"),
Map.entry("woff2", "font/woff2"),
Map.entry("xhtml", "application/xhtml+xml"),
Map.entry("xls", "application/vnd.ms-excel"),
Map.entry("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
Map.entry("xml", "application/xml"),
Map.entry("zip", "application/zip"),
Map.entry("txt", "application/txt"),
Map.entry("lst", "application/txt"),
Map.entry("webp", "image/webp"));

private static final Logger logger = LoggerFactory.getLogger(AttachmentValidationHelper.class);

/**
* Validates the file name and resolves its media type. Ensures that the
* detected media type is part of the list of acceptable media types.
*
* @param fileName the name of the attachment file
* @param acceptableMediaTypes list of allowed media types (e.g. "image/*",
* "application/pdf")
* @return the detected media type
* @throws ServiceException if the file name is invalid or the media type is not
* allowed
*/
public static String validateMediaTypeForAttachment(String fileName, List<String> acceptableMediaTypes) {
validateFileName(fileName);
String detectedMediaType = resolveMimeType(fileName);
validateAcceptableMediaType(acceptableMediaTypes, detectedMediaType);
return detectedMediaType;
}

private static void validateFileName(String fileName) {
String clean = fileName.trim();
int lastDotIndex = clean.lastIndexOf('.');
if (lastDotIndex <= 0 || lastDotIndex == clean.length() - 1) {
throw new ServiceException(
ErrorStatuses.UNSUPPORTED_MEDIA_TYPE,
"Invalid filename format: " + fileName);
}
}

private static void validateAcceptableMediaType(List<String> acceptableMediaTypes, String actualMimeType) {
if (!checkMimeTypeMatch(acceptableMediaTypes, actualMimeType)) {
throw new ServiceException(
ErrorStatuses.UNSUPPORTED_MEDIA_TYPE,
"The attachment file type '{}' is not allowed. Allowed types are: {}", actualMimeType,
String.join(", ", acceptableMediaTypes));
}
}

private static String resolveMimeType(String fileName) {
String actualMimeType = URLConnection.guessContentTypeFromName(fileName);

if (actualMimeType == null) {
String fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
actualMimeType = EXT_TO_MEDIA_TYPE.get(fileExtension);

if (actualMimeType == null) {
logger.warn("Could not determine mime type for file: {}. Setting mime type to default: {}",
fileName, DEFAULT_MEDIA_TYPE);
actualMimeType = DEFAULT_MEDIA_TYPE;
}
}
return actualMimeType;
}

private static boolean checkMimeTypeMatch(Collection<String> acceptableMediaTypes, String mimeType) {
if (acceptableMediaTypes == null || acceptableMediaTypes.isEmpty() || acceptableMediaTypes.contains("*/*"))
return true;

String baseMimeType = mimeType.trim().toLowerCase();

return acceptableMediaTypes.stream().anyMatch(type -> {
String normalizedType = type.trim().toLowerCase();
return normalizedType.endsWith("/*")
? baseMimeType.startsWith(normalizedType.substring(0, normalizedType.length() - 2) + "/")
: baseMimeType.equals(normalizedType);
});
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
*/
package com.sap.cds.feature.attachments.handler.applicationservice.helper;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sap.cds.CdsData;
import com.sap.cds.CdsDataProcessor;
import com.sap.cds.CdsDataProcessor.Converter;
Expand All @@ -20,23 +22,29 @@
import java.io.InputStream;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public final class ModifyApplicationHandlerHelper {

private static final Filter VALMAX_FILTER =
(path, element, type) ->
element.getName().contentEquals("content")
&& element.findAnnotation("Validation.Maximum").isPresent();
private static final Filter VALMAX_FILTER = hasAnnotationOnContent("Validation.Maximum");
private static final Filter ACCEPTABLE_MEDIA_TYPES_FILTER = hasAnnotationOnContent("Core.AcceptableMediaTypes");
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final Logger logger = LoggerFactory.getLogger(ModifyApplicationHandlerHelper.class);

/**
* Handles attachments for entities.
*
* @param entity the {@link CdsEntity entity} to handle attachments for
* @param data the given list of {@link CdsData data}
* @param entity the {@link CdsEntity entity} to handle attachments
* for
* @param data the given list of {@link CdsData data}
* @param existingAttachments the given list of existing {@link CdsData data}
* @param eventFactory the {@link ModifyAttachmentEventFactory} to create the corresponding event
* @param eventContext the current {@link EventContext}
* @param eventFactory the {@link ModifyAttachmentEventFactory} to create
* the corresponding event
* @param eventContext the current {@link EventContext}
*/
public static void handleAttachmentForEntities(
CdsEntity entity,
Expand All @@ -45,17 +53,15 @@ public static void handleAttachmentForEntities(
ModifyAttachmentEventFactory eventFactory,
EventContext eventContext) {
// Condense existing attachments to get a flat list for matching
List<Attachments> condensedExistingAttachments =
ApplicationHandlerHelper.condenseAttachments(existingAttachments, entity);

Converter converter =
(path, element, value) ->
handleAttachmentForEntity(
condensedExistingAttachments,
eventFactory,
eventContext,
path,
(InputStream) value);
List<Attachments> condensedExistingAttachments = ApplicationHandlerHelper.condenseAttachments(existingAttachments,
entity);

Converter converter = (path, element, value) -> handleAttachmentForEntity(
condensedExistingAttachments,
eventFactory,
eventContext,
path,
(InputStream) value);

CdsDataProcessor.create()
.addConverter(ApplicationHandlerHelper.MEDIA_CONTENT_FILTER, converter)
Expand All @@ -65,11 +71,13 @@ public static void handleAttachmentForEntities(
/**
* Handles attachments for a single entity.
*
* @param existingAttachments the list of existing {@link Attachments} to check against
* @param eventFactory the {@link ModifyAttachmentEventFactory} to create the corresponding event
* @param eventContext the current {@link EventContext}
* @param path the {@link Path} of the attachment
* @param content the content of the attachment
* @param existingAttachments the list of existing {@link Attachments} to check
* against
* @param eventFactory the {@link ModifyAttachmentEventFactory} to create
* the corresponding event
* @param eventContext the current {@link EventContext}
* @param path the {@link Path} of the attachment
* @param content the content of the attachment
* @return the processed content as an {@link InputStream}
*/
public static InputStream handleAttachmentForEntity(
Expand All @@ -87,9 +95,8 @@ public static InputStream handleAttachmentForEntity(
eventContext.put(
"attachment.MaxSize",
maxSizeStr); // make max size available in context for error handling later
ServiceException TOO_LARGE_EXCEPTION =
new ServiceException(
ExtendedErrorStatuses.CONTENT_TOO_LARGE, "AttachmentSizeExceeded", maxSizeStr);
ServiceException TOO_LARGE_EXCEPTION = new ServiceException(
ExtendedErrorStatuses.CONTENT_TOO_LARGE, "AttachmentSizeExceeded", maxSizeStr);

if (contentLength != null) {
try {
Expand All @@ -100,10 +107,15 @@ public static InputStream handleAttachmentForEntity(
throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Invalid Content-Length header");
}
}
CountingInputStream wrappedContent =
content != null ? new CountingInputStream(content, maxSizeStr) : null;
ModifyAttachmentEvent eventToProcess =
eventFactory.getEvent(wrappedContent, contentId, attachment);
CountingInputStream wrappedContent = content != null ? new CountingInputStream(content, maxSizeStr) : null;

// Acceptable media types should be restricted
String fileName = getFileName(path, attachment);
List<String> allowedTypes = getEntityAcceptableMediaTypes(path.target().entity(),
existingAttachments);
AttachmentValidationHelper.validateMediaTypeForAttachment(fileName, allowedTypes);

ModifyAttachmentEvent eventToProcess = eventFactory.getEvent(wrappedContent, contentId, attachment);
try {
return eventToProcess.processEvent(path, wrappedContent, attachment, eventContext);
} catch (Exception e) {
Expand Down Expand Up @@ -133,6 +145,43 @@ private static String getValMaxValue(CdsEntity entity, List<? extends CdsData> d
return annotationValue.get() == null ? "400MB" : annotationValue.get();
}

public static String getFileName(Path path, Attachments attachment) throws ServiceException {
String fileName = Optional.ofNullable(attachment.getFileName())
.orElseGet(() -> (String) path.target().values().get(Attachments.FILE_NAME));

if (fileName == null || fileName.isBlank()) {
logger.warn("Filename could not be determined from existing attachment or path values.");
throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename is missing");
}
return fileName;
}

static List<String> getEntityAcceptableMediaTypes(CdsEntity entity, List<? extends CdsData> data) {
AtomicReference<List<String>> annotationValue = new AtomicReference<>();
CdsDataProcessor.create()
.addValidator(
ACCEPTABLE_MEDIA_TYPES_FILTER,
(path, element, value) -> {
element
.findAnnotation("Core.AcceptableMediaTypes")
.ifPresent(annotation -> {
List<String> types = OBJECT_MAPPER.convertValue(annotation.getValue(),
new TypeReference<List<String>>() {
});
annotationValue.set(types);
});
})
.process(data, entity);

return Optional.ofNullable(annotationValue.get())
.orElse(List.of("*/*"));
}

private static Filter hasAnnotationOnContent(String annotationName) {
return (path, element, type) -> "content".contentEquals(element.getName()) &&
element.findAnnotation(annotationName).isPresent();
}

private static Attachments getExistingAttachment(
Map<String, Object> keys, List<Attachments> existingAttachments) {
return existingAttachments.stream()
Expand All @@ -144,4 +193,4 @@ private static Attachments getExistingAttachment(
private ModifyApplicationHandlerHelper() {
// avoid instantiation
}
}
}
Loading