Skip to content
Merged
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
14 changes: 11 additions & 3 deletions packages/nimbus/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@
}
}
},
"files": ["dist", "package.json"],
"files": [
"dist",
"package.json"
],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
Expand All @@ -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": {
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:", "#", "//"];
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand Down
17 changes: 14 additions & 3 deletions packages/nimbus/src/components/inline-svg/inline-svg.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,14 @@ const multiColorSvg = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/sv
</svg>`;

// Malicious SVG data for security testing
const maliciousSvg = `<svg fill="none" height="24" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg" onclick="alert('XSS')" onLoad="alert('XSS2')">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" onmouseover="alert('XSS4')"/>
<polyline points="7.5 4.21 12 6.81 16.5 4.21"/>
const maliciousSvg = `<svg fill="none" height="24" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" onclick="alert('XSS')" onLoad="alert('XSS2')">
<path xlink:href="data:x,<script>alert('XSS5')</script>" d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" onmouseover="alert('XSS4')"/>
<polyline xlink:malicious="<script>alert('XSS6')</script>"points="7.5 4.21 12 6.81 16.5 4.21"/>
<polyline points="7.5 19.79 7.5 14.6 3 12"/>
<polyline points="21 12 16.5 14.6 16.5 19.79"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" x2="12" y1="22.08" y2="12"/>
<a xlink:href="https://www.google.com/"/>
<style>body { display: none; }</style>
<script>alert('XSS3')</script>
</svg>`;
Expand Down Expand Up @@ -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);
}
});
});
});
Expand Down
161 changes: 21 additions & 140 deletions packages/nimbus/src/components/inline-svg/utils/sanitize-svg.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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,
});
}

/**
Expand Down
19 changes: 19 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading