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) {
const ref = useRef(null);
const [mermaidCode, setMermaidCode] = useState('');
@@ -227,7 +270,7 @@ export function PreCode(props) {
>
HTML预览:
</div>
<div dangerouslySetInnerHTML={{ __html: htmlCode }} />
<SandboxedHtmlPreview code={htmlCode} />
</div>
)}
</>

View File

@@ -91,22 +91,45 @@ const codeThemeStyles = {
},
};
const highlightJson = (str) => {
return str.replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,
(match) => {
let color = '#b5cea8';
if (/^"/.test(match)) {
color = /:$/.test(match) ? '#9cdcfe' : '#ce9178';
} else if (/true|false|null/.test(match)) {
color = '#569cd6';
}
return `<span style="color: ${color}">${match}</span>`;
},
);
const escapeHtml = (str) => {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
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 parts = html.split(/(<[^>]+>)/g);
@@ -184,14 +207,14 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
const highlightedContent = useMemo(() => {
if (contentMetrics.isVeryLarge && !isExpanded) {
return displayContent;
return escapeHtml(displayContent);
}
if (isJsonLike(displayContent, language)) {
return highlightJson(displayContent);
}
return displayContent;
return escapeHtml(displayContent);
}, [displayContent, language, contentMetrics.isVeryLarge, isExpanded]);
const renderedContent = useMemo(() => {