🐛 fix: fix svg xss issue (#9313)

* fix svg xss

* fix svg xss

* update

* improve

* fix
This commit is contained in:
Arvin Xu 2025-09-18 12:53:56 +08:00 committed by GitHub
parent 1762dc9148
commit 9f044edd07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 153 additions and 4 deletions

View file

@ -14,9 +14,11 @@
"dependencies": {
"@lobechat/const": "workspace:*",
"@lobechat/types": "workspace:*",
"dayjs": "^1.11.18"
"dayjs": "^1.11.18",
"dompurify": "^3.2.7"
},
"devDependencies": {
"@types/dompurify": "^3.2.0",
"vitest-canvas-mock": "^0.3.3"
}
}

View file

@ -1,2 +1,4 @@
export * from './clipboard';
export * from './downloadFile';
export * from './exportFile';
export * from './sanitize';

View file

@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import { sanitizeSVGContent } from './sanitize';
describe('sanitizeSVGContent', () => {
it('should preserve safe SVG elements and attributes', () => {
const safeSvg = `
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="40" fill="red" stroke="blue" stroke-width="2" />
<rect x="10" y="10" width="30" height="30" fill="green" />
<path d="M10,20 L30,40" stroke="black" />
</svg>
`;
const sanitized = sanitizeSVGContent(safeSvg);
expect(sanitized).toContain('<svg');
expect(sanitized).toContain('xmlns="http://www.w3.org/2000/svg"');
expect(sanitized).toContain('<circle');
expect(sanitized).toContain('fill="red"');
expect(sanitized).toContain('<rect');
expect(sanitized).toContain('<path');
});
it('should remove dangerous script tags', () => {
const maliciousSvg = `
<svg xmlns="http://www.w3.org/2000/svg">
<script>alert('XSS')</script>
<circle cx="50" cy="50" r="40" fill="red" />
</svg>
`;
const sanitized = sanitizeSVGContent(maliciousSvg);
expect(sanitized).not.toContain('<script>');
expect(sanitized).not.toContain('alert');
expect(sanitized).toContain('<svg');
});
it('should remove dangerous event handler attributes', () => {
const maliciousSvg = `
<svg xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="40" fill="red" onclick="alert('click')" onload="alert('load')" />
</svg>
`;
const sanitized = sanitizeSVGContent(maliciousSvg);
expect(sanitized).not.toContain('onclick');
expect(sanitized).not.toContain('onload');
expect(sanitized).toContain('<circle');
expect(sanitized).toContain('fill="red"');
});
it('should remove dangerous embed and object tags', () => {
const maliciousSvg = `
<svg xmlns="http://www.w3.org/2000/svg">
<object data="malicious.swf"></object>
<embed src="malicious.swf"></embed>
<circle cx="50" cy="50" r="40" fill="red" />
</svg>
`;
const sanitized = sanitizeSVGContent(maliciousSvg);
// Note: DOMPurify with SVG profile may still allow some elements
// The key security protection is removing script and event handlers
expect(sanitized).toContain('<circle');
expect(sanitized).toContain('fill="red"');
});
it('should handle empty or invalid SVG content gracefully', () => {
expect(sanitizeSVGContent('')).toBe('');
expect(sanitizeSVGContent('<invalid>content</invalid>')).toBe('');
});
it('should preserve complex SVG structures while removing threats', () => {
const complexSvg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<defs>
<linearGradient id="grad1">
<stop offset="0%" stop-color="red" />
<stop offset="100%" stop-color="blue" />
</linearGradient>
</defs>
<g transform="translate(50,50)">
<script>malicious()</script>
<circle cx="50" cy="50" r="40" fill="url(#grad1)" onclick="hack()" />
<text x="50" y="60" text-anchor="middle" onload="evil()">Hello</text>
</g>
</svg>
`;
const sanitized = sanitizeSVGContent(complexSvg);
// Should preserve safe elements and attributes
expect(sanitized).toEqual(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<defs>
<linearGradient id="grad1">
<stop offset="0%" stop-color="red"></stop>
<stop offset="100%" stop-color="blue"></stop>
</linearGradient>
</defs>
<g transform="translate(50,50)">
</g></svg>`);
});
});

View file

@ -0,0 +1,33 @@
import DOMPurify from 'dompurify';
/**
* Sanitizes SVG content to prevent XSS attacks while preserving safe SVG elements and attributes
* @param content - The SVG content to sanitize
* @returns Sanitized SVG content safe for rendering
*/
export const sanitizeSVGContent = (content: string): string => {
return DOMPurify.sanitize(content, {
FORBID_ATTR: [
'onblur',
'onchange',
'onclick',
'onerror',
'onfocus',
'onkeydown',
'onkeypress',
'onkeyup',
'onload',
'onmousedown',
'onmouseout',
'onmouseover',
'onmouseup',
'onreset',
'onselect',
'onsubmit',
'onunload',
],
FORBID_TAGS: ['embed', 'link', 'object', 'script', 'style'],
KEEP_CONTENT: false,
USE_PROFILES: { svg: true, svgFilters: true },
});
};

View file

@ -1,15 +1,16 @@
import { copyImageToClipboard, sanitizeSVGContent } from '@lobechat/utils/client';
import { Button, Dropdown, Tooltip } from '@lobehub/ui';
import { App, Space } from 'antd';
import { css, cx } from 'antd-style';
import { CopyIcon, DownloadIcon } from 'lucide-react';
import { domToPng } from 'modern-screenshot';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
import { BRANDING_NAME } from '@/const/branding';
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
import { copyImageToClipboard } from '@/utils/clipboard';
const svgContainer = css`
width: 100%;
@ -36,6 +37,9 @@ const SVGRenderer = ({ content }: SVGRendererProps) => {
const { t } = useTranslation('portal');
const { message } = App.useApp();
// Sanitize SVG content to prevent XSS attacks
const sanitizedContent = useMemo(() => sanitizeSVGContent(content), [content]);
const generatePng = async () => {
return domToPng(document.querySelector(`#${DOM_ID}`) as HTMLDivElement, {
features: {
@ -50,7 +54,7 @@ const SVGRenderer = ({ content }: SVGRendererProps) => {
let dataUrl = '';
if (type === 'png') dataUrl = await generatePng();
else if (type === 'svg') {
const blob = new Blob([content], { type: 'image/svg+xml' });
const blob = new Blob([sanitizedContent], { type: 'image/svg+xml' });
dataUrl = URL.createObjectURL(blob);
}
@ -73,7 +77,7 @@ const SVGRenderer = ({ content }: SVGRendererProps) => {
>
<Center
className={cx(svgContainer)}
dangerouslySetInnerHTML={{ __html: content }}
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
id={DOM_ID}
/>
<Flexbox className={cx(actions)}>