From 1aa6282d43479c778a1d7cb82df72c94f6db9224 Mon Sep 17 00:00:00 2001 From: Thomas Dax Date: Thu, 19 Dec 2024 15:58:16 +0100 Subject: [PATCH] Improve SVG validation --- .changeset/wise-eyes-warn.md | 21 +++++++++ .../fileUpload/fileUploadErrorMessages.tsx | 4 +- .../DataGrid/fileUpload/useDamFileUpload.tsx | 2 +- .../src/dam/files/file-validation.service.ts | 6 +-- .../cms-api/src/dam/files/files.utils.spec.ts | 26 +++++++---- .../api/cms-api/src/dam/files/files.utils.ts | 46 +++++++++++++------ 6 files changed, 78 insertions(+), 27 deletions(-) create mode 100644 .changeset/wise-eyes-warn.md diff --git a/.changeset/wise-eyes-warn.md b/.changeset/wise-eyes-warn.md new file mode 100644 index 0000000000..ef3b3439cc --- /dev/null +++ b/.changeset/wise-eyes-warn.md @@ -0,0 +1,21 @@ +--- +"@comet/cms-api": patch +--- + +Improve SVG validation + +Following tags are banned in SVGs: + +- script +- \[new\] foreignObject +- \[new\] use +- \[new\] image +- \[new\] animate +- \[new\] animateMotion +- \[new\] animateTransform +- \[new\] set + +Following attributes are banned: + +- Event handlers (`onload`, `onclick`, ...) +- \[new\] `href` and `xlink:href` (if the value starts with `http://`, `https://` or `javascript:`) diff --git a/packages/admin/cms-admin/src/dam/DataGrid/fileUpload/fileUploadErrorMessages.tsx b/packages/admin/cms-admin/src/dam/DataGrid/fileUpload/fileUploadErrorMessages.tsx index 793f514a3a..39569f9506 100644 --- a/packages/admin/cms-admin/src/dam/DataGrid/fileUpload/fileUploadErrorMessages.tsx +++ b/packages/admin/cms-admin/src/dam/DataGrid/fileUpload/fileUploadErrorMessages.tsx @@ -77,8 +77,8 @@ export const FileExtensionTypeMismatchError = ({ extension, mimetype }: FileExte export const SvgContainsJavaScriptError = () => ( ); } - } else if (typedErr.response?.data.message === "Rejected File Upload: SVG must not contain JavaScript") { + } else if (typedErr.response?.data.message.includes("SVG contains forbidden content")) { addValidationError(file, ); } else if (typedErr.response === undefined && typedErr.request) { addValidationError(file, ); diff --git a/packages/api/cms-api/src/dam/files/file-validation.service.ts b/packages/api/cms-api/src/dam/files/file-validation.service.ts index 70e7ce18b7..d5ef57d016 100644 --- a/packages/api/cms-api/src/dam/files/file-validation.service.ts +++ b/packages/api/cms-api/src/dam/files/file-validation.service.ts @@ -1,7 +1,7 @@ import { readFile } from "fs/promises"; import { FileUploadInput } from "./dto/file-upload.input"; -import { getValidExtensionsForMimetype, svgContainsJavaScript } from "./files.utils"; +import { getValidExtensionsForMimetype, isValidSvg } from "./files.utils"; export class FileValidationService { constructor(public config: { maxFileSize: number; acceptedMimeTypes: string[] }) {} @@ -45,8 +45,8 @@ export class FileValidationService { if (file.mimetype === "image/svg+xml") { const fileContent = await readFile(file.path, { encoding: "utf-8" }); - if (svgContainsJavaScript(fileContent)) { - return "SVG must not contain JavaScript"; + if (!isValidSvg(fileContent)) { + return "SVG contains forbidden content (e.g., JavaScript, security-relevant tags or attributes)"; } } diff --git a/packages/api/cms-api/src/dam/files/files.utils.spec.ts b/packages/api/cms-api/src/dam/files/files.utils.spec.ts index 8a4034e312..cca1e03219 100644 --- a/packages/api/cms-api/src/dam/files/files.utils.spec.ts +++ b/packages/api/cms-api/src/dam/files/files.utils.spec.ts @@ -1,16 +1,16 @@ -import { svgContainsJavaScript } from "./files.utils"; +import { isValidSvg } from "./files.utils"; describe("Files Utils", () => { - describe("svgContainsJavaScript", () => { - it("should return false if the svg doesn't contain a script tag or event handler", async () => { + describe("isValidSvg", () => { + it("should return true if the svg doesn't contain any forbidden content", async () => { const cleanSvg = ` `; - expect(svgContainsJavaScript(cleanSvg)).toBe(false); + expect(isValidSvg(cleanSvg)).toBe(true); }); - it("should return true if the svg contains a script tag", async () => { + it("should return false if the svg contains a script tag", async () => { const svgWithScriptTag = ` `; - expect(svgContainsJavaScript(svgWithScriptTag)).toBe(true); + expect(isValidSvg(svgWithScriptTag)).toBe(false); }); - it("should return true if the svg contains an event handler", async () => { + it("should return false if the svg contains an event handler", async () => { const svgWithOnloadHandler = ` `; - expect(svgContainsJavaScript(svgWithOnloadHandler)).toBe(true); + expect(isValidSvg(svgWithOnloadHandler)).toBe(false); + }); + + it("should return false if the svg contains a href attribute containing javascript", async () => { + const svgWithOnloadHandler = ` + + + + `; + + expect(isValidSvg(svgWithOnloadHandler)).toBe(false); }); }); }); diff --git a/packages/api/cms-api/src/dam/files/files.utils.ts b/packages/api/cms-api/src/dam/files/files.utils.ts index 05574bcaff..ff237835bf 100644 --- a/packages/api/cms-api/src/dam/files/files.utils.ts +++ b/packages/api/cms-api/src/dam/files/files.utils.ts @@ -49,34 +49,54 @@ type SvgNode = [key: string]: SvgNode; }; -const recursivelyFindJSInSvg = (node: SvgNode): boolean => { +const disallowedSvgTags = [ + "script", // can lead to XSS + "foreignObject", // can embed non-SVG content + "use", // can load external resources + "image", // can load external resources + "animate", // can modify attributes; resource exhaustion + "animateMotion", // can modify attributes; resource exhaustion + "animateTransform", // can modify attributes; resource exhaustion + "set", // can modify attributes +]; + +const recursiveIsValidSvgNode = (node: SvgNode): boolean => { if (typeof node === "string") { // is plain text -> can't contain JS - return false; + return true; } - for (const tagOrAttr of Object.keys(node)) { - if (tagOrAttr.toLowerCase() === "script" || tagOrAttr.toLowerCase().startsWith("on")) { - // is script tag or event handler - return true; + for (const [tagOrAttributeName, value] of Object.entries(node)) { + const containsDisallowedTags = disallowedSvgTags.map((tag) => tag.toLowerCase()).includes(tagOrAttributeName.toLowerCase()); + + const containsEventHandler = tagOrAttributeName.toLowerCase().startsWith("on"); // can execute JavaScript + + const containsHref = // can execute JavaScript or link to malicious targets + ["href", "xlink:href"].includes(tagOrAttributeName) && + typeof value === "string" && + (value.startsWith("http://") || value.startsWith("https://") || value.startsWith("javascript:")); + + if (containsDisallowedTags || containsEventHandler || containsHref) { + return false; } // is node -> children can contain JS - const children = node[tagOrAttr]; - const childrenContainJS = recursivelyFindJSInSvg(children); - if (childrenContainJS) { - return true; + const children = node[tagOrAttributeName]; + const childrenAreValid = recursiveIsValidSvgNode(children); + + if (!childrenAreValid) { + return false; } } - return false; + return true; }; -export const svgContainsJavaScript = (svg: string) => { +export const isValidSvg = (svg: string) => { const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "" }); const jsonObj = parser.parse(svg) as SvgNode; - return recursivelyFindJSInSvg(jsonObj); + return recursiveIsValidSvgNode(jsonObj); }; export const removeMulterTempFile = async (file: FileUploadInput) => {