mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
🐛 fix: fix svg xss issue (#9313)
* fix svg xss * fix svg xss * update * improve * fix
This commit is contained in:
parent
1762dc9148
commit
9f044edd07
6 changed files with 153 additions and 4 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,4 @@
|
|||
export * from './clipboard';
|
||||
export * from './downloadFile';
|
||||
export * from './exportFile';
|
||||
export * from './sanitize';
|
||||
|
|
|
|||
108
packages/utils/src/client/sanitize.test.ts
Normal file
108
packages/utils/src/client/sanitize.test.ts
Normal 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>`);
|
||||
});
|
||||
});
|
||||
33
packages/utils/src/client/sanitize.ts
Normal file
33
packages/utils/src/client/sanitize.ts
Normal 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 },
|
||||
});
|
||||
};
|
||||
|
|
@ -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)}>
|
||||
|
|
|
|||
Loading…
Reference in a new issue