Merge commit from fork

🔒 fix(security): sanitize AI-generated HTML to prevent XSS in playground
This commit is contained in:
Calcium-Ion
2026-02-06 16:16:20 +08:00
committed by GitHub
2 changed files with 83 additions and 17 deletions

View File

@@ -93,6 +93,49 @@ export function Mermaid(props) {
); );
} }
function SandboxedHtmlPreview({ code }) {
const iframeRef = useRef(null);
const [iframeHeight, setIframeHeight] = useState(150);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
const handleLoad = () => {
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (doc) {
const height =
doc.documentElement.scrollHeight || doc.body.scrollHeight;
setIframeHeight(Math.min(Math.max(height + 16, 60), 600));
}
} catch {
// sandbox restrictions may prevent access, that's fine
}
};
iframe.addEventListener('load', handleLoad);
return () => iframe.removeEventListener('load', handleLoad);
}, [code]);
return (
<iframe
ref={iframeRef}
sandbox='allow-same-origin'
srcDoc={code}
title='HTML Preview'
style={{
width: '100%',
height: `${iframeHeight}px`,
border: 'none',
overflow: 'auto',
backgroundColor: '#fff',
borderRadius: '4px',
}}
/>
);
}
export function PreCode(props) { export function PreCode(props) {
const ref = useRef(null); const ref = useRef(null);
const [mermaidCode, setMermaidCode] = useState(''); const [mermaidCode, setMermaidCode] = useState('');
@@ -227,7 +270,7 @@ export function PreCode(props) {
> >
HTML预览: HTML预览:
</div> </div>
<div dangerouslySetInnerHTML={{ __html: htmlCode }} /> <SandboxedHtmlPreview code={htmlCode} />
</div> </div>
)} )}
</> </>

View File

@@ -91,22 +91,45 @@ const codeThemeStyles = {
}, },
}; };
const highlightJson = (str) => { const escapeHtml = (str) => {
return str.replace( return str
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, .replace(/&/g, '&amp;')
(match) => { .replace(/</g, '&lt;')
let color = '#b5cea8'; .replace(/>/g, '&gt;')
if (/^"/.test(match)) { .replace(/"/g, '&quot;')
color = /:$/.test(match) ? '#9cdcfe' : '#ce9178'; .replace(/'/g, '&#039;');
} else if (/true|false|null/.test(match)) {
color = '#569cd6';
}
return `<span style="color: ${color}">${match}</span>`;
},
);
}; };
const linkRegex = /(https?:\/\/[^\s<"'\]),;}]+)/g; const highlightJson = (str) => {
const tokenRegex =
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g;
let result = '';
let lastIndex = 0;
let match;
while ((match = tokenRegex.exec(str)) !== null) {
// Escape non-token text (structural chars like {, }, [, ], :, comma, whitespace)
result += escapeHtml(str.slice(lastIndex, match.index));
const token = match[0];
let color = '#b5cea8';
if (/^"/.test(token)) {
color = /:$/.test(token) ? '#9cdcfe' : '#ce9178';
} else if (/true|false|null/.test(token)) {
color = '#569cd6';
}
// Escape token content before wrapping in span
result += `<span style="color: ${color}">${escapeHtml(token)}</span>`;
lastIndex = tokenRegex.lastIndex;
}
// Escape remaining text
result += escapeHtml(str.slice(lastIndex));
return result;
};
const linkRegex = /(https?:\/\/(?:[^\s<"'\]),;&}]|&amp;)+)/g;
const linkifyHtml = (html) => { const linkifyHtml = (html) => {
const parts = html.split(/(<[^>]+>)/g); const parts = html.split(/(<[^>]+>)/g);
@@ -184,14 +207,14 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
const highlightedContent = useMemo(() => { const highlightedContent = useMemo(() => {
if (contentMetrics.isVeryLarge && !isExpanded) { if (contentMetrics.isVeryLarge && !isExpanded) {
return displayContent; return escapeHtml(displayContent);
} }
if (isJsonLike(displayContent, language)) { if (isJsonLike(displayContent, language)) {
return highlightJson(displayContent); return highlightJson(displayContent);
} }
return displayContent; return escapeHtml(displayContent);
}, [displayContent, language, contentMetrics.isVeryLarge, isExpanded]); }, [displayContent, language, contentMetrics.isVeryLarge, isExpanded]);
const renderedContent = useMemo(() => { const renderedContent = useMemo(() => {