Skip to content

Commit 014f9be

Browse files
CRAFT-1586: feat(inline-svg): introduce InlineSvg component (#382)
* feat(inline-svg): introduce InlineSvg component for rendering sanitized SVGs - Added InlineSvg component to render arbitrary SVG markup with built-in XSS protection. - Implemented sanitization logic to remove potentially dangerous elements and attributes. - Included accessibility features with title and description support. - Created associated stories and documentation for usage examples and testing. This addition enhances the component library by providing a secure and flexible way to handle SVGs. * refactor(inline-svg): simplify InlineSvg component and enhance sanitization - Removed deprecated props (title, description, preserveViewBox) from InlineSvg component. - Updated sanitization logic to utilize constants for forbidden tags and attributes. - Improved accessibility handling by rendering SVGs with role="presentation" and preserving title/description directly in SVG markup. - Refactored related stories and documentation to reflect changes in props and accessibility features. - Introduced new hooks for better SVG handling and sanitization. * feat(inline-svg): add useInlineSvg hook for SVG processing and sanitization - Introduced a new hook, useInlineSvg, to handle the processing and sanitization of raw SVG data. - The hook utilizes useMemo for performance optimization and checks for DOM availability. - Implements sanitization logic to ensure safe SVG rendering, extracting and preserving specific attributes. - Returns processed SVG attributes, content, and a validation status for improved SVG handling in components. * refactor(inline-svg): update forbidden attributes in sanitization constants - Removed explicit listing of event handler attributes from DEFAULT_FORBIDDEN_ATTRIBUTES. - Added a note clarifying that event handlers are blocked by pattern matching in sanitize-svg.ts. - Streamlined the sanitization constants for improved clarity and maintainability. * refactor(inline-svg): update ALLOWED_PROTOCOLS and clarify forbidden attributes - Added "//" to ALLOWED_PROTOCOLS for improved URL handling. - Clarified the note regarding event handler attributes in DEFAULT_FORBIDDEN_ATTRIBUTES for better understanding of sanitization logic. * refactor(inline-svg): update component structure and enhance sanitization - Moved InlineSvg component from Media to Components menu for better organization. - Updated color props from "primary.500" to "primary.9" and similar adjustments for other colors to align with new design standards. - Improved sanitization logic by removing deprecated constants and ensuring all attributes are preserved correctly. - Enhanced stories to reflect changes in color usage and added tests for SVG sanitization against malicious content. - Removed unused examples and streamlined the documentation for clarity. * refactor(inline-svg): simplify InlineSvg component and add ref support - Removed forwardRef from InlineSvg component for a cleaner structure. - Added ref prop to InlineSvgProps interface to allow direct access to the SVG element. - Streamlined the component logic while maintaining sanitization checks for SVG rendering. * refactor(inline-svg): enhance component functionality and documentation - Updated InlineSvg component to always render as an SVG, removing support for 'as' and 'asChild' props. - Improved documentation to clarify the use of predefined and custom sizes for SVGs. - Enhanced example usage in stories to demonstrate various size options and sanitization of SVG content. - Updated InlineSvgProps interface to exclude 'as' and 'asChild' from prop types for better type safety. * refactor(inline-svg): remove className prop from InlineSvgProps interface * CRAFT 1586 | inline svg - use DOMPurify instead of maintaining custom dom sanitization code (#433) feat(inline svg): use dompurify instead of custom parsing solution so that malicious/malformed attrs are removed from the svg but the svg is still displayed * chore(changeset): add changeset for InlineSvg component addition --------- Co-authored-by: Byron Wall <[email protected]>
1 parent ffe580f commit 014f9be

17 files changed

+847
-20
lines changed

.changeset/smart-oranges-nail.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@commercetools/nimbus": minor
3+
---
4+
5+
InlineSvg component added

packages/nimbus/package.json

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@
2323
}
2424
}
2525
},
26-
"files": ["dist", "package.json"],
26+
"files": [
27+
"dist",
28+
"package.json"
29+
],
2730
"publishConfig": {
2831
"access": "public",
2932
"registry": "https://registry.npmjs.org/"
@@ -32,10 +35,14 @@
3235
"type": "git",
3336
"url": "https://github.com/commercetools/nimbus.git"
3437
},
35-
"sideEffects": ["*.css"],
38+
"sideEffects": [
39+
"*.css"
40+
],
3641
"typesVersions": {
3742
"*": {
38-
"*": ["./dist/index.d.ts"]
43+
"*": [
44+
"./dist/index.d.ts"
45+
]
3946
}
4047
},
4148
"dependencies": {
@@ -77,6 +84,7 @@
7784
"@vueless/storybook-dark-mode": "catalog:tooling",
7885
"apca-w3": "^0.1.9",
7986
"axe-core": "^4.10.2",
87+
"dompurify": "catalog:",
8088
"glob": "catalog:tooling",
8189
"playwright": "catalog:tooling",
8290
"react": "catalog:react",

packages/nimbus/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export * from "./badge";
3232
export * from "./card";
3333
export * from "./form-field";
3434
export * from "./icon";
35+
export * from "./inline-svg";
3536
export * from "./loading-spinner";
3637
export * from "./password-input";
3738
export * from "./split-button";
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export {
2+
DEFAULT_FORBIDDEN_TAGS,
3+
ALLOWED_PROTOCOLS,
4+
} from "./sanitization.constants";
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Tags that are forbidden in SVG content for security reasons
3+
*/
4+
export const DEFAULT_FORBIDDEN_TAGS = [
5+
"script",
6+
"style",
7+
"iframe",
8+
"embed",
9+
"object",
10+
"applet",
11+
"link",
12+
"base",
13+
"meta",
14+
];
15+
16+
/**
17+
* Protocols allowed in URL attributes
18+
*/
19+
export const ALLOWED_PROTOCOLS = ["http:", "https:", "#", "//"];
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { useInlineSvg } from "./use-inline-svg";
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { useMemo } from "react";
2+
import { sanitizeSvg, canUseDOM } from "../utils";
3+
4+
/**
5+
* Hook for processing and sanitizing SVG data
6+
* @param data - Raw SVG markup as a string
7+
* @returns Processed SVG attributes, content, and sanitization status
8+
*/
9+
export function useInlineSvg(data: string) {
10+
// Process and sanitize SVG data in a single operation
11+
const processedSvg = useMemo(() => {
12+
if (!canUseDOM()) {
13+
return {
14+
isValid: true,
15+
svgAttributes: {},
16+
innerSvgContent: data,
17+
};
18+
}
19+
20+
const sanitized = sanitizeSvg(data, {
21+
allowStyles: false,
22+
forbiddenAttributes: [],
23+
forbiddenTags: [],
24+
});
25+
26+
if (!sanitized) {
27+
console.warn("InlineSvg: Failed to sanitize SVG data");
28+
return {
29+
isValid: false,
30+
svgAttributes: {},
31+
innerSvgContent: "",
32+
};
33+
}
34+
35+
const parser = new DOMParser();
36+
const doc = parser.parseFromString(sanitized, "image/svg+xml");
37+
const svgEl = doc.querySelector("svg");
38+
39+
if (!svgEl) {
40+
console.warn("InlineSvg: No SVG element found in markup");
41+
return {
42+
isValid: false,
43+
svgAttributes: {},
44+
innerSvgContent: "",
45+
};
46+
}
47+
48+
const attrs: Record<string, string> = {};
49+
50+
// Preserve all attributes from the sanitized SVG element
51+
// Security: Only attributes that passed sanitization are included
52+
for (const attr of Array.from(svgEl.attributes)) {
53+
// Convert kebab-case to camelCase for React compatibility
54+
const reactAttrName = attr.name.replace(/-([a-z])/g, (_, letter) =>
55+
letter.toUpperCase()
56+
);
57+
attrs[reactAttrName] = attr.value;
58+
}
59+
60+
return {
61+
isValid: true,
62+
svgAttributes: attrs,
63+
innerSvgContent: svgEl.innerHTML,
64+
};
65+
}, [data]);
66+
67+
return processedSvg;
68+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { InlineSvg } from "./inline-svg";
2+
export type { InlineSvgProps } from "./inline-svg.types";
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
---
2+
id: Components-InlineSvg
3+
title: InlineSvg
4+
description: Render arbitrary SVG markup with XSS protection
5+
order: 999
6+
menu:
7+
- Components
8+
- Media
9+
- InlineSvg
10+
tags:
11+
- component
12+
- svg
13+
- icon
14+
- inline
15+
- security
16+
---
17+
18+
# InlineSvg
19+
20+
The `InlineSvg` component allows you to render arbitrary SVG markup as an icon with built-in XSS protection. It sanitizes the provided SVG string to remove potentially dangerous elements and attributes before rendering.
21+
22+
## Features
23+
24+
- **Security First**: Automatic sanitization of SVG content to prevent XSS attacks
25+
- **API Compatible**: Drop-in replacement for existing InlineSvg implementations
26+
- **Accessibility**: Support for title and description elements
27+
- **Consistent Styling**: Uses the same recipe system as the Icon component
28+
- **Performance**: Memoized sanitization for optimal re-renders
29+
- **Zero External Dependencies**: Uses native browser APIs for parsing and sanitization
30+
31+
## Basic Usage
32+
33+
```jsx-live
34+
const App = () => {
35+
const svgData = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
36+
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5
37+
3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0
38+
3.78-3.4 6.86-8.55 11.54L12 21.35z" fill="currentColor"/>
39+
</svg>`;
40+
41+
return (
42+
<InlineSvg data={svgData} size="md" />
43+
);
44+
};
45+
```
46+
47+
## Sizes
48+
49+
### Predefined Sizes
50+
51+
The component supports all standard icon sizes through the `size` prop:
52+
53+
```jsx-live
54+
const App = () => {
55+
const svgData = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
56+
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5
57+
3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0
58+
3.78-3.4 6.86-8.55 11.54L12 21.35z" fill="currentColor"/>
59+
</svg>`;
60+
61+
return (
62+
<Flex gap="400" align="center">
63+
<InlineSvg data={svgData} size="2xs" />
64+
<InlineSvg data={svgData} size="xs" />
65+
<InlineSvg data={svgData} size="sm" />
66+
<InlineSvg data={svgData} size="md" />
67+
<InlineSvg data={svgData} size="lg" />
68+
<InlineSvg data={svgData} size="xl" />
69+
</Flex>
70+
);
71+
};
72+
```
73+
74+
### Custom Sizes
75+
76+
For custom sizing, you can use the `boxSize`, `width`, or `height` props with any valid CSS value or design token:
77+
78+
```jsx-live
79+
const App = () => {
80+
const svgData = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
81+
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5
82+
3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0
83+
3.78-3.4 6.86-8.55 11.54L12 21.35z" fill="currentColor"/>
84+
</svg>`;
85+
86+
return (
87+
<Flex gap="400" align="center">
88+
<InlineSvg data={svgData} boxSize="800"/>
89+
<InlineSvg data={svgData} boxSize="1600"/>
90+
<InlineSvg data={svgData} width="800" height="800"/>
91+
</Flex>
92+
);
93+
};
94+
```
95+
96+
## Colors
97+
98+
Apply colors using design tokens:
99+
100+
```jsx-live
101+
const App = () => {
102+
const svgData = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
103+
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5
104+
3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0
105+
3.78-3.4 6.86-8.55 11.54L12 21.35z" fill="currentColor"/>
106+
</svg>`;
107+
108+
return (
109+
<Flex gap="400" align="center">
110+
<InlineSvg data={svgData} size="lg" color="primary.9" />
111+
<InlineSvg data={svgData} size="lg" color="neutral.9" />
112+
<InlineSvg data={svgData} size="lg" color="info.9" />
113+
<InlineSvg data={svgData} size="lg" color="positive.9" />
114+
<InlineSvg data={svgData} size="lg" color="warning.9" />
115+
<InlineSvg data={svgData} size="lg" color="critical.9" />
116+
</Flex>
117+
);
118+
};
119+
```
120+
121+
## Complex SVG
122+
123+
The component preserves complex SVG structures including groups, multiple paths, and transformations:
124+
125+
```jsx-live
126+
const App = () => {
127+
const svgData = `<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">
128+
<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"/>
129+
<polyline points="7.5 4.21 12 6.81 16.5 4.21"/>
130+
<polyline points="7.5 19.79 7.5 14.6 3 12"/>
131+
<polyline points="21 12 16.5 14.6 16.5 19.79"/>
132+
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
133+
<line x1="12" x2="12" y1="22.08" y2="12"/>
134+
</svg>`;
135+
136+
return (
137+
<InlineSvg data={svgData} size="xl" color="primary.9" />
138+
);
139+
};
140+
```
141+
142+
## Multi-Color SVG
143+
144+
SVGs with inline colors are preserved:
145+
146+
```jsx-live
147+
const App = () => {
148+
const svgData = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
149+
<rect x="3" y="3" width="18" height="18" rx="2" fill="#e11d48"/>
150+
<circle cx="12" cy="12" r="5" fill="#10b981"/>
151+
<path d="M12 9v6M9 12h6" stroke="white" stroke-width="2"/>
152+
</svg>`;
153+
154+
return (
155+
<InlineSvg data={svgData} size="xl" />
156+
);
157+
};
158+
```
159+
160+
## Security
161+
162+
The component automatically sanitizes potentially dangerous content:
163+
164+
- Removes `<script>` tags
165+
- Removes event handlers (onclick, onload, etc.)
166+
- Removes `<style>` tags
167+
- Sanitizes URLs in href attributes (blocks javascript:, data:, etc.)
168+
- Removes other potentially dangerous elements
169+
170+
```jsx-live
171+
const App = () => {
172+
// This malicious SVG content will be sanitized
173+
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')">
174+
<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')"/>
175+
<polyline points="7.5 4.21 12 6.81 16.5 4.21"/>
176+
<polyline points="7.5 19.79 7.5 14.6 3 12"/>
177+
<polyline points="21 12 16.5 14.6 16.5 19.79"/>
178+
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
179+
<line x1="12" x2="12" y1="22.08" y2="12"/>
180+
<style>body { display: none; }</style>
181+
<script>alert('XSS3')</script>
182+
</svg>`;
183+
184+
return (
185+
<Stack gap="400">
186+
<Text>The following SVG has malicious content that gets sanitized:</Text>
187+
<InlineSvg data={maliciousSvg} size="lg" />
188+
<Text fontSize="sm" color="neutral.9">
189+
Script tags, event handlers, and style tags are automatically removed
190+
</Text>
191+
</Stack>
192+
);
193+
};
194+
```
195+
196+
## Specs
197+
198+
199+
## Props
200+
<PropsTable id="InlineSvg" />
201+
202+
## Accessibility
203+
204+
The SVG is rendered with `role="presentation"` as it's intended for decorative use. If you need accessible SVGs, include `<title>` and `<desc>` elements directly in your SVG markup - they will be preserved during sanitization.
205+
206+
## Security Considerations
207+
208+
The component provides comprehensive XSS protection:
209+
210+
### What Gets Removed
211+
212+
- All JavaScript execution vectors (`<script>`, event handlers)
213+
- Style injection attempts (`<style>` tags, style attributes)
214+
- Dangerous protocols in URLs (`javascript:`, `data:`, etc.)
215+
- Potentially dangerous elements (`<iframe>`, `<embed>`, etc.)
216+
217+
### What Gets Preserved
218+
219+
- SVG structure elements (`<g>`, `<path>`, `<circle>`, etc.)
220+
- Visual attributes (fill, stroke, transform, etc.)
221+
- Accessibility elements (`<title>`, `<desc>`)
222+
- Safe URLs (http:, https:, relative paths, fragments)
223+
224+
## Best Practices
225+
226+
1. **Always provide a title** for non-decorative SVGs
227+
2. **Use design tokens** for colors to maintain consistency
228+
3. **Test with real SVG data** from your actual use cases
229+
4. **Validate SVG markup** before storing in your database
230+
5. **Consider performance** for very large or complex SVGs

0 commit comments

Comments
 (0)