diff --git a/package-lock.json b/package-lock.json index 886292d53..0581e1e62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5284,6 +5284,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/media-typer": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@types/media-typer/-/media-typer-0.3.4.tgz", + "integrity": "sha512-JnDKAviTtc+aSVRYjPQwesJkGHzg/LSjAZJcuPlltp4gOXVq2VqrJqGsxTGHCxfH+tSH8vk9deIQEj1NA9ozqg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -15454,6 +15461,7 @@ "json-stringify-safe": "^5.0.1", "lodash": "^4.18.1", "luxon": "^3.7.2", + "media-typer": "^1.1.1", "reflect-metadata": "^0.2.2", "ts-simple-nameof": "^1.3.3" }, @@ -15468,6 +15476,7 @@ "@types/json-stringify-safe": "5.0.3", "@types/lodash": "4.17.25", "@types/luxon": "3.7.5", + "@types/media-typer": "0.3.4", "jwt-decode": "4.0.0", "openid-client": "6.8.8", "ts-json-schema-generator": "2.9.0", diff --git a/packages/app-runtime/test/runtime/AppStringProcessor.test.ts b/packages/app-runtime/test/runtime/AppStringProcessor.test.ts index f221f4ae2..b3107f078 100644 --- a/packages/app-runtime/test/runtime/AppStringProcessor.test.ts +++ b/packages/app-runtime/test/runtime/AppStringProcessor.test.ts @@ -367,7 +367,7 @@ describe("AppStringProcessor", function () { const fileResult = await runtime1Session.transportServices.files.uploadOwnFile({ filename: "aFileName", content: new TextEncoder().encode("aFileContent"), - mimetype: "aMimetype", + mimetype: "text/plain", expiresAt: CoreDate.utc().add({ minutes: 10 }).toISOString() }); const file = fileResult.value; @@ -502,7 +502,7 @@ describe("AppStringProcessor", function () { const fileResult = await runtime1Session.transportServices.files.uploadOwnFile({ filename: "aFileName", content: new TextEncoder().encode("aFileContent"), - mimetype: "aMimetype", + mimetype: "text/plain", expiresAt: CoreDate.utc().add({ minutes: 10 }).toISOString() }); const file = fileResult.value; diff --git a/packages/consumption/test/core/TestUtil.ts b/packages/consumption/test/core/TestUtil.ts index cbfc57734..219e3dad3 100644 --- a/packages/consumption/test/core/TestUtil.ts +++ b/packages/consumption/test/core/TestUtil.ts @@ -460,7 +460,7 @@ export class TestUtil { description: "aDescription", filename: "aFilename", filemodified: CoreDate.from("2019-09-30T00:00:00.000Z"), - mimetype: "aMimetype", + mimetype: "text/plain", expiresAt: parameters?.expiresAt ?? CoreDate.utc().add({ minutes: 5 }), tags: parameters?.tags }; diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 19ab0d7f6..6f9ff758e 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -91,6 +91,7 @@ "json-stringify-safe": "^5.0.1", "lodash": "^4.18.1", "luxon": "^3.7.2", + "media-typer": "^1.1.1", "reflect-metadata": "^0.2.2", "ts-simple-nameof": "^1.3.3" }, @@ -105,6 +106,7 @@ "@types/json-stringify-safe": "5.0.3", "@types/lodash": "4.17.25", "@types/luxon": "3.7.5", + "@types/media-typer": "0.3.4", "jwt-decode": "4.0.0", "openid-client": "6.8.8", "ts-json-schema-generator": "2.9.0", diff --git a/packages/runtime/src/useCases/transport/files/UploadOwnFile.ts b/packages/runtime/src/useCases/transport/files/UploadOwnFile.ts index ed0e46b3d..158d75fef 100644 --- a/packages/runtime/src/useCases/transport/files/UploadOwnFile.ts +++ b/packages/runtime/src/useCases/transport/files/UploadOwnFile.ts @@ -5,6 +5,7 @@ import { CoreBuffer } from "@nmshd/crypto"; import { FileDTO } from "@nmshd/runtime-types"; import { AccountController, FileController } from "@nmshd/transport"; import { Inject } from "@nmshd/typescript-ioc"; +import { parse as parseMediaType } from "media-typer"; import { nameof } from "ts-simple-nameof"; import { ISO8601DateTimeString, RuntimeErrors, SchemaRepository, SchemaValidator, UseCase, ValidationFailure, ValidationResult } from "../../common"; import { FileMapper } from "./FileMapper"; @@ -40,6 +41,26 @@ class Validator extends SchemaValidator { const validationResult = super.validate(input); if (!validationResult.isValid()) return validationResult; + const filenameValidationError = this.validateFilename(input.filename); + if (filenameValidationError) { + validationResult.addFailure( + new ValidationFailure( + RuntimeErrors.general.invalidPropertyValue(filenameValidationError), + nameof((r) => r.filename) + ) + ); + } + + const mimetypeValidationError = this.validateMimetype(input.mimetype); + if (mimetypeValidationError) { + validationResult.addFailure( + new ValidationFailure( + RuntimeErrors.general.invalidPropertyValue(mimetypeValidationError), + nameof((r) => r.mimetype) + ) + ); + } + if (input.content.byteLength > this._maxFileSize) { validationResult.addFailure( new ValidationFailure( @@ -60,6 +81,32 @@ class Validator extends SchemaValidator { return validationResult; } + + private validateFilename(filename: string): string | undefined { + const propertyName = nameof((r) => r.filename); + + if (filename.trim().length === 0) return `'${propertyName}' must not be empty or consist only of whitespace`; + if (filename === "." || filename === "..") return `'${propertyName}' must not be '.' or '..'`; + if (/[\\/]/.test(filename)) return `'${propertyName}' must not contain path separators`; + if (/\p{Cc}/u.test(filename)) return `'${propertyName}' must not contain Unicode control characters`; + if (new TextEncoder().encode(filename).byteLength > 255) return `'${propertyName}' must not exceed 255 UTF-8 bytes`; + + return undefined; + } + + private validateMimetype(mimetype: string): string | undefined { + const propertyName = nameof((r) => r.mimetype); + + if (mimetype.trim().length === 0) return `'${propertyName}' must not be empty or consist only of whitespace`; + if (mimetype.trim() !== mimetype) return `'${propertyName}' must not contain leading or trailing whitespace`; + + try { + parseMediaType(mimetype); + return undefined; + } catch { + return `'${propertyName}' must be a concrete media type in the form 'type/subtype' without parameters or wildcards`; + } + } } export class UploadOwnFileUseCase extends UseCase { diff --git a/packages/runtime/test/lib/testUtils.ts b/packages/runtime/test/lib/testUtils.ts index edcf65032..d8d6d91bf 100644 --- a/packages/runtime/test/lib/testUtils.ts +++ b/packages/runtime/test/lib/testUtils.ts @@ -206,7 +206,7 @@ export async function makeUploadRequest(values: object = {}): Promise { expect(response).toBeSuccessful(); }); + test.each(["test.txt", "a file.txt", "ÄÖÜ.txt"])("can upload a file named '%s'", async (filename) => { + const response = await transportServices1.files.uploadOwnFile(await makeUploadRequest({ filename })); + + expect(response).toBeSuccessful(); + expect(response.value.filename).toBe(filename); + }); + + test.each([ + ["an empty filename", ""], + ["a filename consisting only of whitespace", " \t "], + ["'.' as filename", "."], + ["'..' as filename", ".."], + ["a forward slash in the filename", "directory/file.txt"], + ["a backslash in the filename", "directory\\file.txt"], + ["a NUL character in the filename", "file\0.txt"], + ["another control character in the filename", "file\u0085.txt"], + ["a filename longer than 255 UTF-8 bytes", "ä".repeat(128)] + ])("cannot upload a file with %s", async (_description, filename) => { + const response = await transportServices1.files.uploadOwnFile(await makeUploadRequest({ filename })); + + expect(response).toBeAnError(/filename/, "error.runtime.validation.invalidPropertyValue"); + }); + + test.each(["text/plain", "application/pdf", "image/svg+xml", "application/vnd.api+json", "IMAGE/PNG"])("can upload a file with MIME type '%s'", async (mimetype) => { + const response = await transportServices1.files.uploadOwnFile(await makeUploadRequest({ mimetype })); + + expect(response).toBeSuccessful(); + expect(response.value.mimetype).toBe(mimetype); + }); + + test.each(["", " \t ", "text", "text/", "/plain", "text/pla in", "text/plain; charset=utf-8", "text/*", "*/*", "text/plain\r\n"])( + "cannot upload a file with invalid MIME type '%s'", + async (mimetype) => { + const response = await transportServices1.files.uploadOwnFile(await makeUploadRequest({ mimetype })); + + expect(response).toBeAnError(/mimetype/, "error.runtime.validation.invalidPropertyValue"); + } + ); + test("uploaded files can be accessed under /Files", async () => { const uploadResponse = await transportServices1.files.uploadOwnFile(await makeUploadRequest()); expect(uploadResponse).toBeSuccessful(); diff --git a/packages/transport/test/modules/sync/SyncController.files.test.ts b/packages/transport/test/modules/sync/SyncController.files.test.ts index 76a252bf2..bebb1157d 100644 --- a/packages/transport/test/modules/sync/SyncController.files.test.ts +++ b/packages/transport/test/modules/sync/SyncController.files.test.ts @@ -23,7 +23,7 @@ describe("FileSync", function () { description: "aDescription", expiresAt: CoreDate.utc().add({ minutes: 2 }), filename: "aFilename.txt", - mimetype: "aMimetype", + mimetype: "text/plain", title: "aTitle", buffer: CoreBuffer.fromUtf8("test") });