diff --git a/packages/nimbus/package.json b/packages/nimbus/package.json index 4be777f8c..a71abc9bd 100644 --- a/packages/nimbus/package.json +++ b/packages/nimbus/package.json @@ -23,7 +23,10 @@ } } }, - "files": ["dist", "package.json"], + "files": [ + "dist", + "package.json" + ], "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" @@ -32,10 +35,14 @@ "type": "git", "url": "https://github.com/commercetools/nimbus.git" }, - "sideEffects": ["*.css"], + "sideEffects": [ + "*.css" + ], "typesVersions": { "*": { - "*": ["./dist/index.d.ts"] + "*": [ + "./dist/index.d.ts" + ] } }, "dependencies": { @@ -77,6 +84,7 @@ "@vueless/storybook-dark-mode": "catalog:tooling", "apca-w3": "^0.1.9", "axe-core": "^4.10.2", + "dompurify": "catalog:", "glob": "catalog:tooling", "playwright": "catalog:tooling", "react": "catalog:react", diff --git a/packages/nimbus/src/components/inline-svg/constants/sanitization.constants.ts b/packages/nimbus/src/components/inline-svg/constants/sanitization.constants.ts index b4f74feea..03f2dbba8 100644 --- a/packages/nimbus/src/components/inline-svg/constants/sanitization.constants.ts +++ b/packages/nimbus/src/components/inline-svg/constants/sanitization.constants.ts @@ -11,9 +11,9 @@ export const DEFAULT_FORBIDDEN_TAGS = [ "link", "base", "meta", -] as const; +]; /** * Protocols allowed in URL attributes */ -export const ALLOWED_PROTOCOLS = ["http:", "https:", "#", "//"] as const; +export const ALLOWED_PROTOCOLS = ["http:", "https:", "#", "//"]; diff --git a/packages/nimbus/src/components/inline-svg/hooks/use-inline-svg.ts b/packages/nimbus/src/components/inline-svg/hooks/use-inline-svg.ts index 58e6c0d75..220ce7cc6 100644 --- a/packages/nimbus/src/components/inline-svg/hooks/use-inline-svg.ts +++ b/packages/nimbus/src/components/inline-svg/hooks/use-inline-svg.ts @@ -37,6 +37,7 @@ export function useInlineSvg(data: string) { const svgEl = doc.querySelector("svg"); if (!svgEl) { + console.warn("InlineSvg: No SVG element found in markup"); return { isValid: false, svgAttributes: {}, diff --git a/packages/nimbus/src/components/inline-svg/inline-svg.stories.tsx b/packages/nimbus/src/components/inline-svg/inline-svg.stories.tsx index 387e30b4c..819df9228 100644 --- a/packages/nimbus/src/components/inline-svg/inline-svg.stories.tsx +++ b/packages/nimbus/src/components/inline-svg/inline-svg.stories.tsx @@ -58,13 +58,14 @@ const multiColorSvg = ` - - +const maliciousSvg = ` + + + `; @@ -237,6 +238,16 @@ export const SecurityTest: Story = { allElements.forEach((element) => { Array.from(element.attributes).forEach((attr) => { expect(attr.name.toLowerCase().startsWith("on")).toBe(false); + // The xlink on the `a` tag is legit, so it should be kept + if ( + element.tagName === "a" && + attr.name.toLowerCase().startsWith("xlink") + ) { + expect(attr.value).toBe("https://www.google.com/"); + } else { + // The xlink on the `path` and `polyline` tags are malicious, so they should be removed + expect(attr.name.toLowerCase().startsWith("xlink")).toBe(false); + } }); }); }); diff --git a/packages/nimbus/src/components/inline-svg/utils/sanitize-svg.ts b/packages/nimbus/src/components/inline-svg/utils/sanitize-svg.ts index d654e8ec1..9ab866202 100644 --- a/packages/nimbus/src/components/inline-svg/utils/sanitize-svg.ts +++ b/packages/nimbus/src/components/inline-svg/utils/sanitize-svg.ts @@ -1,4 +1,5 @@ -import { DEFAULT_FORBIDDEN_TAGS, ALLOWED_PROTOCOLS } from "../constants"; +import DOMPurify from "dompurify"; +import { DEFAULT_FORBIDDEN_TAGS } from "../constants"; /** * Configuration options for SVG sanitization @@ -21,120 +22,6 @@ interface SanitizationOptions { forbiddenTags?: string[]; } -/** - * Sanitizes a URL to prevent XSS attacks - */ -function sanitizeUrl(url: string): string { - if (!url) return ""; - - const trimmed = url.trim(); - - // Allow fragment identifiers - if (trimmed.startsWith("#")) return trimmed; - - // Parse and validate URL - try { - const parsed = new URL(trimmed); - if ((ALLOWED_PROTOCOLS as readonly string[]).includes(parsed.protocol)) { - return trimmed; - } - } catch { - // If it's not a valid URL, treat it as a relative path - if (!trimmed.includes(":")) { - return trimmed; - } - } - - // Block any other protocols (javascript:, data:, etc.) - return ""; -} - -/** - * Recursively sanitizes an SVG element and its children - */ -function sanitizeElement( - element: Element, - options: SanitizationOptions = {} -): Element | null { - const { - allowStyles = false, - forbiddenAttributes = [], - forbiddenTags = [], - } = options; - - const tagName = element.tagName.toLowerCase(); - - // Check if tag is forbidden - const allForbiddenTags = [...DEFAULT_FORBIDDEN_TAGS, ...forbiddenTags]; - if (allForbiddenTags.includes(tagName)) { - return null; - } - - // Create a new element instead of cloning to ensure no attributes are copied - // Use the SVG namespace for SVG elements - const namespace = element.namespaceURI || "http://www.w3.org/2000/svg"; - const cloned = document.createElementNS(namespace, element.tagName); - - // Process attributes - const allForbiddenAttrs = [...forbiddenAttributes]; - if (!allowStyles) { - allForbiddenAttrs.push("style"); - } - - for (const attr of Array.from(element.attributes)) { - const attrName = attr.name.toLowerCase(); - - // Remove forbidden attributes - let isForbidden = false; - - // Check for event handlers (anything starting with "on") - if (attrName.startsWith("on")) { - isForbidden = true; - } - - // Check for explicitly forbidden attributes - if (!isForbidden) { - for (const forbidden of allForbiddenAttrs) { - if (attrName === forbidden) { - isForbidden = true; - break; - } - } - } - - // Skip forbidden attributes - don't add them to the cloned element - if (isForbidden) { - continue; - } - - // Sanitize URLs in href and xlink:href attributes - if (attrName === "href" || attrName === "xlink:href") { - const sanitizedUrl = sanitizeUrl(attr.value); - if (sanitizedUrl) { - cloned.setAttribute(attr.name, sanitizedUrl); - } - continue; - } - - // Copy safe attributes preserving original case for SVG compatibility - cloned.setAttribute(attr.name, attr.value); - } - - // Recursively process children - for (const child of Array.from(element.childNodes)) { - if (child.nodeType === Node.TEXT_NODE) { - cloned.appendChild(child.cloneNode(true)); - } else if (child.nodeType === Node.ELEMENT_NODE) { - const sanitizedChild = sanitizeElement(child as Element, options); - if (sanitizedChild) { - cloned.appendChild(sanitizedChild); - } - } - } - - return cloned; -} - /** * Sanitizes SVG markup string to prevent XSS attacks * @param svgString - The SVG markup as a string @@ -145,37 +32,31 @@ export function sanitizeSvg( svgString: string, options: SanitizationOptions = {} ): string | null { - if (!svgString || typeof svgString !== "string") { - return null; - } - - // Use DOMParser to parse the SVG - const parser = new DOMParser(); - const doc = parser.parseFromString(svgString.trim(), "image/svg+xml"); - - // Check for parsing errors - const parserError = doc.querySelector("parsererror"); - if (parserError) { - console.warn("InlineSvg: Invalid SVG markup provided"); - return null; - } + const { + allowStyles = false, + forbiddenAttributes = [], + forbiddenTags = [], + } = options; - // Find the SVG element - const svgElement = doc.querySelector("svg"); - if (!svgElement) { - console.warn("InlineSvg: No SVG element found in markup"); + if (!svgString || typeof svgString !== "string") { return null; } - // Sanitize the SVG element - const sanitized = sanitizeElement(svgElement, options); - if (!sanitized) { - return null; + if (!canUseDOM()) { + return svgString; } - // Convert back to string - const serializer = new XMLSerializer(); - return serializer.serializeToString(sanitized); + const allForbiddenTags = [...DEFAULT_FORBIDDEN_TAGS, ...forbiddenTags]; + const allForbiddenAttributes = [ + ...(allowStyles ? [] : ["style"]), + ...forbiddenAttributes, + ]; + + return DOMPurify.sanitize(svgString, { + USE_PROFILES: { svg: true, svgFilters: true }, + FORBID_TAGS: allForbiddenTags, + FORBID_ATTR: allForbiddenAttributes, + }); } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f99cc290..388206103 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,9 @@ settings: catalogs: default: + dompurify: + specifier: ^3.2.6 + version: 3.2.6 express-rate-limit: specifier: ^8.0.1 version: 8.0.1 @@ -574,6 +577,9 @@ importers: axe-core: specifier: ^4.10.2 version: 4.10.3 + dompurify: + specifier: 'catalog:' + version: 3.2.6 glob: specifier: catalog:tooling version: 11.0.3 @@ -2556,6 +2562,9 @@ packages: '@types/slug@5.0.9': resolution: {integrity: sha512-6Yp8BSplP35Esa/wOG1wLNKiqXevpQTEF/RcL/NV6BBQaMmZh4YlDwCgrrFSoUE4xAGvnKd5c+lkQJmPrBAzfQ==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -3523,6 +3532,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.2.6: + resolution: {integrity: sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==} + domutils@2.8.0: resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} @@ -8821,6 +8833,9 @@ snapshots: '@types/slug@5.0.9': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -10112,6 +10127,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.2.6: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@2.8.0: dependencies: dom-serializer: 1.4.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 97291aa7b..0e94231f5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: catalog: express-rate-limit: ^8.0.1 + dompurify: ^3.2.6 catalogMode: strict @@ -14,56 +15,56 @@ catalogs: typescript: ~5.8.3 typescript-eslint: ^8.38.0 tsx: ^4.20.3 - "@babel/core": ^7.28.0 - "@babel/runtime": ^7.28.3 - "@babel/preset-typescript": ^7.27.1 - "@eslint/js": ^9.33.0 + '@babel/core': ^7.28.0 + '@babel/runtime': ^7.28.3 + '@babel/preset-typescript': ^7.27.1 + '@eslint/js': ^9.33.0 eslint: ^9.34.0 eslint-config-prettier: ^10.1.8 eslint-plugin-prettier: ^5.5.4 eslint-plugin-react-hooks: ^5.2.0 eslint-plugin-react-refresh: ^0.4.20 prettier: ^3.6.2 - "@preconstruct/cli": ^2.8.12 - "@vitejs/plugin-react": ^4.4.1 - "@vitejs/plugin-react-swc": ^3.10.2 + '@preconstruct/cli': ^2.8.12 + '@vitejs/plugin-react': ^4.4.1 + '@vitejs/plugin-react-swc': ^3.10.2 vite: ^7.0.5 vite-bundle-analyzer: ^1.0.0 vite-plugin-dts: ^4.5.4 vite-tsconfig-paths: ^5.1.4 rollup: ^4.50.0 rollup-plugin-tree-shakeable: ^1.0.3 - "@vitest/browser": ^3.2.4 - "@vitest/coverage-v8": ^3.2.4 - "@testing-library/dom": 10.4.0 - "@testing-library/user-event": ^14.6.1 - "@testing-library/react": ^16.3.0 + '@vitest/browser': ^3.2.4 + '@vitest/coverage-v8': ^3.2.4 + '@testing-library/dom': 10.4.0 + '@testing-library/user-event': ^14.6.1 + '@testing-library/react': ^16.3.0 playwright: ^1.55.0 vitest: ^3.2.4 storybook: ^9.1.3 eslint-plugin-storybook: ^9.1.3 - "@storybook/addon-a11y": ^9.1.3 - "@storybook/addon-docs": ^9.1.3 - "@storybook/addon-vitest": ^9.1.3 - "@storybook/react-vite": ^9.1.3 - "@vueless/storybook-dark-mode": ^9.0.7 + '@storybook/addon-a11y': ^9.1.3 + '@storybook/addon-docs': ^9.1.3 + '@storybook/addon-vitest': ^9.1.3 + '@storybook/react-vite': ^9.1.3 + '@vueless/storybook-dark-mode': ^9.0.7 react: - "@types/react": ^19.1.8 - "@types/react-dom": ^19.1.6 + '@types/react': ^19.1.8 + '@types/react-dom': ^19.1.6 react: ^19.0.0 react-dom: ^19.0.0 - "@emotion/react": ^11.14.0 - "@emotion/is-prop-valid": ^1.3.1 - "@chakra-ui/cli": ^3.26.0 - "@chakra-ui/react": ^3.26.0 + '@emotion/react': ^11.14.0 + '@emotion/is-prop-valid': ^1.3.1 + '@chakra-ui/cli': ^3.26.0 + '@chakra-ui/react': ^3.26.0 next-themes: ^0.4.6 react-aria: 3.43.1 react-aria-components: 1.12.1 react-stately: 3.41.0 - "@react-aria/interactions": 3.25.5 - "@react-aria/optimize-locales-plugin": 1.1.5 - "@internationalized/date": ^3.9.0 + '@react-aria/interactions': 3.25.5 + '@react-aria/optimize-locales-plugin': 1.1.5 + '@internationalized/date': ^3.9.0 utils: lodash: ^4.17.21 - "@types/lodash": ^4.17.20 - "@types/node": ^24.0.3 + '@types/lodash': ^4.17.20 + '@types/node': ^24.0.3