Skip to content

Commit f6ede42

Browse files
KoblerSschiwekMSirSimon04
authored
Implement mime type validation for attachments and enhance error hand… (#316)
…ling --------- Co-authored-by: Marten Schiwek <marten.schiwek@sap.com> Co-authored-by: Simon Engel <simon.engel01@sap.com>
1 parent f265c31 commit f6ede42

18 files changed

+305
-101
lines changed

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file.
44
This project adheres to [Semantic Versioning](http://semver.org/).
55
The format is based on [Keep a Changelog](http://keepachangelog.com/).
66

7+
## Version 3.4.0
8+
9+
### Added
10+
11+
- Introduced support for the `@Core.AcceptableMediaTypes` annotation, allowing specification of permitted MIME types for attachment uploads:
12+
```cds
13+
annotate my.Books.attachments with {
14+
content @Core.AcceptableMediaTypes: ['image/jpeg'];
15+
}
16+
```
17+
- Added support for the `@Validation.Maximum` annotation to define the maximum allowed file size for attachments:
18+
```cds
19+
annotate my.Books.attachments with {
20+
content @Validation.Maximum: '2MB';
21+
}
22+
```
23+
24+
### Fixed
25+
26+
- Removed the previous hard limit of `400 MB` for file uploads. Files exceeding this size may still fail during malware scanning and will be marked with a `Failed` status.
27+
- Resolved issues with generic handler registration, enabling services to intercept the attachments plugin using middleware.
728
829
## Version 3.3.0
930

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,39 @@ annotate Incidents.attachments with {
262262

263263
The default is 400MB
264264

265+
### Restrict allowed MIME types
266+
267+
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.
268+
269+
```cds
270+
entity Incidents {
271+
...
272+
attachments: Composition of many Attachments;
273+
}
274+
275+
annotate Incidents.attachments with {
276+
content @Core.AcceptableMediaTypes : ['image/jpeg', 'image/png', 'application/pdf'];
277+
}
278+
```
279+
280+
Wildcard patterns are supported:
281+
282+
```cds
283+
annotate Incidents.attachments with {
284+
content @Core.AcceptableMediaTypes : ['image/*', 'application/pdf'];
285+
}
286+
```
287+
288+
To allow all MIME types (default behavior), either omit the annotation or use:
289+
290+
```cds
291+
annotate Incidents.attachments with {
292+
content @Core.AcceptableMediaTypes : ['*/*'];
293+
}
294+
```
295+
296+
When a file with a disallowed MIME type is uploaded, the request will be rejected with a `400` error.
297+
265298
## Releases
266299

267300
- The plugin is released to [NPM Registry](https://www.npmjs.com/package/@cap-js/attachments).

_i18n/messages.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
UnableToDownloadAttachmentScanStatusNotClean=Unable to download the attachment as scan status is not clean.
22
AttachmentSizeExceeded=File size limit exceeded beyond {0}.
33
MultiUpdateNotSupported=Multi update is not supported.
4+
AttachmentMimeTypeDisallowed=The attachment file type '{mimeType}' is not allowed.

db/data/sap.attachments-ScanStates.csv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ Unscanned;Unscanned;The file is not yet scanned for malware.;2
33
Scanning;Scanning;The file is currently being scanned for malware.;2
44
Infected;Infected;The file contains malware! Do not download!;1
55
Clean;Clean;The file does not contain any malware.;3
6-
Failed;Failed;The file could not be scanned for malware.;1
6+
Failed;Failed;The file could not be scanned for malware.;1

db/data/sap.attachments-ScanStates_texts.csv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ en;Unscanned;Unscanned;The file is not yet scanned for malware.
33
en;Scanning;Scanning;The file is currently being scanned for malware.
44
en;Infected;Infected;The file contains malware! Do not download!
55
en;Clean;Clean;The file does not contain any malware.
6-
en;Failed;Failed;The file could not be scanned for malware.
6+
en;Failed;Failed;The file could not be scanned for malware.

lib/generic-handlers.js

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
const cds = require('@sap/cds')
22
const LOG = cds.log('attachments')
33
const { extname } = require("path")
4-
const { MAX_FILE_SIZE, sizeInBytes } = require('./helper')
4+
const { MAX_FILE_SIZE, sizeInBytes, checkMimeTypeMatch } = require('./helper')
55

66
const isMultitenacyEnabled = !!cds.env.requires.multitenancy
77
const objectStoreKind = cds.env.requires?.attachments?.objectStore?.kind
@@ -20,6 +20,7 @@ function onPrepareAttachment(req) {
2020

2121
let ext = req.data.filename ? extname(req.data.filename).toLowerCase().slice(1) : null
2222
req.data.mimeType = Ext2MimeTypes[ext]
23+
2324
if (!req.data.mimeType) {
2425
LOG.warn(`An attachment ${req.data.ID} is uploaded whose extension "${ext}" is not known! Falling back to "application/octet-stream"`)
2526
req.data.mimeType = "application/octet-stream"
@@ -73,7 +74,7 @@ async function readAttachment([attachment], req) {
7374
* Checks the attachments size against the maximum defined by the annotation `@Validation.Maximum`. Default 400mb.
7475
* If the limit is reached by the reported size of the content-length header or if the stream length exceeds
7576
* the limits the error is thrown.
76-
* @param {import('@sap/cds').Request} req
77+
* @param {import('@sap/cds').Request} req - The request object
7778
* @throws AttachmentSizeExceeded
7879
*/
7980
function validateAttachmentSize(req) {
@@ -87,16 +88,30 @@ function validateAttachmentSize(req) {
8788
}
8889
}
8990

91+
/**
92+
* Validates the attachment mime type against acceptable media types
93+
* @param {import('@sap/cds').Request} req - The request object
94+
*/
95+
function validateAttachmentMimeType(req) {
96+
if (!req.target?._attachments.isAttachmentsEntity || !req.data.content) return;
97+
98+
const mimeType = req.data.mimeType
9099

100+
const acceptableMediaTypes = req.target.elements.content['@Core.AcceptableMediaTypes'] || '*/*'
101+
if (!checkMimeTypeMatch(acceptableMediaTypes, mimeType)) {
102+
return req.reject(400, "AttachmentMimeTypeDisallowed", { mimeType: mimeType })
103+
}
104+
}
91105

92106
module.exports = {
93107
validateAttachmentSize,
94108
onPrepareAttachment,
95109
readAttachment,
96-
validateAttachment
110+
validateAttachment,
111+
validateAttachmentMimeType
97112
}
98113

99-
// Supported mime types
114+
// Mapping table from file extensions to mime types
100115
const Ext2MimeTypes = {
101116
aac: "audio/aac",
102117
abw: "application/x-abiword",

lib/helper.js

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ async function fetchObjectStoreBinding(tenantID, token) {
100100

101101
/**
102102
* Retrieves object store credentials for a given tenant
103-
* @param {*} tenantID - Tenant ID
103+
* @param {string} tenantID - Tenant ID
104104
* @returns {Promise<Object|null>} - Promise resolving to object store credentials or null
105105
*/
106106
async function getObjectStoreCredentials(tenantID) {
@@ -295,6 +295,38 @@ async function fetchTokenWithMTLS(certURL, clientid, certificate, key) {
295295
}
296296
}
297297

298+
/**
299+
* Checks if the given mimeType matches any of the allowedTypes patterns
300+
* @param {Array<string>} allowedTypes - Array of allowed mime types (can include wildcards)
301+
* @param {string} mimeType - Mime type to check
302+
* @returns {boolean} - True if mimeType matches any allowedTypes, false otherwise
303+
*/
304+
function checkMimeTypeMatch(allowedTypes, mimeType) {
305+
if (!allowedTypes || allowedTypes.length === 0) {
306+
return true
307+
}
308+
309+
if (typeof allowedTypes === 'string') {
310+
allowedTypes = [allowedTypes]
311+
}
312+
313+
if (allowedTypes.includes('*/*')) {
314+
return true
315+
}
316+
317+
// Remove any parameters (e.g., "; charset=utf-8", "; boundary=...")
318+
const baseMimeType = mimeType.split(';')[0].trim()
319+
320+
return allowedTypes.some(allowedType => {
321+
if (allowedType.endsWith('/*')) {
322+
const prefix = allowedType.slice(0, -2)
323+
return baseMimeType.startsWith(prefix + '/')
324+
} else {
325+
return baseMimeType === allowedType
326+
}
327+
})
328+
}
329+
298330
async function computeHash(input) {
299331
const hash = crypto.createHash('sha256')
300332

@@ -359,5 +391,6 @@ module.exports = {
359391
sizeInBytes,
360392
fetchObjectStoreBinding,
361393
validateServiceManagerCredentials,
394+
checkMimeTypeMatch,
362395
MAX_FILE_SIZE
363-
}
396+
}

lib/mtx/server.js

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,9 @@ const _getOfferingID = async (sm_url, token) => {
144144

145145
/**
146146
* Registers attachment handlers for the given service and entity
147-
* @param {*} sm_url - Service Manager URL
148-
* @param {*} token - OAuth token
149-
* @param {*} offeringID - Service Offering ID
147+
* @param {string} sm_url - Service Manager URL
148+
* @param {string} token - OAuth token
149+
* @param {string} offeringID - Service Offering ID
150150
* @returns
151151
*/
152152
const _getPlanID = async (sm_url, token, offeringID) => {
@@ -183,10 +183,10 @@ const _getPlanID = async (sm_url, token, offeringID) => {
183183

184184
/**
185185
* Creates an object store instance for the given tenant
186-
* @param {*} sm_url - Service Manager URL
187-
* @param {*} tenant - Tenant ID
188-
* @param {*} planID - Service Plan ID
189-
* @param {*} token - OAuth token
186+
* @param {string} sm_url - Service Manager URL
187+
* @param {string} tenant - Tenant ID
188+
* @param {string} planID - Service Plan ID
189+
* @param {string} token - OAuth token
190190
* @returns
191191
*/
192192
const _createObjectStoreInstance = async (sm_url, tenant, planID, token) => {
@@ -215,9 +215,9 @@ const _createObjectStoreInstance = async (sm_url, tenant, planID, token) => {
215215

216216
/**
217217
* Polls the service manager until the instance is in a terminal state
218-
* @param {*} sm_url - Service Manager URL
219-
* @param {*} instancePath - Path to the service instance
220-
* @param {*} token - OAuth token
218+
* @param {string} sm_url - Service Manager URL
219+
* @param {string} instancePath - Path to the service instance
220+
* @param {string} token - OAuth token
221221
* @returns
222222
*/
223223
const _pollUntilDone = async (sm_url, instancePath, token) => {
@@ -255,10 +255,10 @@ const _pollUntilDone = async (sm_url, instancePath, token) => {
255255

256256
/**
257257
* Registers attachment handlers for the given service and entity
258-
* @param {*} sm_url - Service Manager URL
259-
* @param {*} tenant - Tenant ID
260-
* @param {*} instanceID - Service Instance ID
261-
* @param {*} token - OAuth token
258+
* @param {string} sm_url - Service Manager URL
259+
* @param {string} tenant - Tenant ID
260+
* @param {string} instanceID - Service Instance ID
261+
* @param {string} token - OAuth token
262262
* @returns
263263
*/
264264
const _bindObjectStoreInstance = async (sm_url, tenant, instanceID, token) => {

lib/plugin.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
const cds = require("@sap/cds")
2-
const { validateAttachment, readAttachment, validateAttachmentSize, onPrepareAttachment } = require("./generic-handlers")
2+
const { validateAttachment, readAttachment, validateAttachmentSize, onPrepareAttachment, validateAttachmentMimeType } = require("./generic-handlers")
33
require("./csn-runtime-extension")
44
const LOG = cds.log('attachments')
55

@@ -50,6 +50,7 @@ cds.ApplicationService.handle_attachments = cds.service.impl(async function () {
5050
this.before("READ", validateAttachment)
5151
this.after("READ", readAttachment)
5252
this.before("PUT", validateAttachmentSize)
53+
this.before("PUT", validateAttachmentMimeType)
5354
this.before("NEW", onPrepareAttachment)
5455
this.before("CREATE", (req) => {
5556
return onPrepareAttachment(req)
@@ -119,8 +120,6 @@ cds.ApplicationService.handle_attachments = cds.service.impl(async function () {
119120
)
120121
)
121122

122-
123-
124123
const AttachmentsSrv = await cds.connect.to("attachments")
125124
AttachmentsSrv.registerHandlers(this)
126-
})
125+
})

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@cap-js/attachments",
33
"description": "CAP cds-plugin providing image and attachment storing out-of-the-box.",
4-
"version": "3.3.0",
4+
"version": "3.4.0",
55
"repository": "cap-js/attachments",
66
"author": "SAP SE (https://www.sap.com)",
77
"homepage": "https://cap.cloud.sap/",
@@ -122,4 +122,4 @@
122122
"workspaces": [
123123
"tests/incidents-app/"
124124
]
125-
}
125+
}

0 commit comments

Comments
 (0)