XSS vulnerabilities that slip through AI-generated React code
作者:Seaworthy · 2026年4月14日
Photo by Chris Ried on Unsplash
React's JSX escaping is genuinely good. It converts <, >, &, and " to their HTML entity equivalents before anything touches the DOM. For the vast majority of rendering paths, you can trust it. The problem is that LLMs don't generate "the vast majority of rendering paths". They reach for escape hatches, construct URLs from user input, and wire up rich-text editors in ways that bypass the escaping entirely. I've reviewed enough AI-generated React to have opinions about this.
Why JSX escaping doesn't cover everything
JSX escaping happens at the expression level: {userContent} is safe because React treats it as a text node, never markup. But there are at least three ways to leave that safe path, and LLMs hit all of them routinely.
The escape hatches exist for legitimate reasons. The issue is that an LLM optimising for "make this work" will reach for them without stopping to consider what the data flowing in actually is.
dangerouslySetInnerHTML: the obvious one
Ask an LLM to render some server-supplied HTML and there's a good chance the response includes:
// What the LLM gives you
function ArticleBody({ html }: { html: string }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
If html comes from a CMS you control and is never touched by end users, this might be acceptable. If it originates anywhere near user input, you've just handed an attacker a direct path to document.cookie.
The React-idiomatic fix depends on what you actually need. If the content is plain text with line breaks, use CSS white-space: pre-wrap and {content} — no HTML required. If you need real markup, sanitise before rendering:
import DOMPurify from 'dompurify';
function ArticleBody({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'br'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
FORCE_BODY: true,
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
The ALLOWED_TAGS list there is deliberately conservative. You probably don't need <script>, <iframe>, <object>, or <svg> in a blog post body. The FORCE_BODY option prevents a class of bypass where the attacker smuggles content before the HTML document root.
javascript: URLs and open redirect via href
This one is subtler, and LLMs get it wrong constantly. The pattern looks innocent:
// Vulnerable: url comes from query params or user profile data
function ProfileLink({ url, label }: { url: string; label: string }) {
return <a href={url}>{label}</a>;
}
JSX escaping doesn't help here. href is an attribute that accepts a URL, and javascript:alert(document.cookie) is a valid URL as far as the browser is concerned. Click the link, run the script.
LLMs often generate this exact pattern when asked to render user-provided links, especially in profile pages, comment sections, or anywhere a URL comes from a route parameter.
The fix requires validation, not just escaping:
const SAFE_PROTOCOLS = new Set(['https:', 'http:', 'mailto:']);
function safeHref(url: string): string {
try {
const parsed = new URL(url);
if (!SAFE_PROTOCOLS.has(parsed.protocol)) {
return '#';
}
return parsed.href;
} catch {
// Relative URLs won't parse with new URL(); allow them
if (url.startsWith('/') || url.startsWith('./') || url.startsWith('../')) {
return url;
}
return '#';
}
}
function ProfileLink({ url, label }: { url: string; label: string }) {
return (
<a href={safeHref(url)} rel="noopener noreferrer">
{label}
</a>
);
}
Falling back to # on an invalid URL is the right call. It renders a harmless anchor rather than silently dropping the link or crashing. The rel="noopener noreferrer" is there regardless, because user-supplied links pointing to external sites shouldn't get a window.opener reference.
Rich-text editor output
This is where things get genuinely messy. Editors like Quill, TipTap, and Slate produce HTML strings. That's their output format. You almost always need to render that output somewhere, and dangerouslySetInnerHTML is the natural tool for the job.
LLMs know this and will generate the combination without comment:
// Vulnerable: raw editor output rendered directly
const [content, setContent] = useState('');
return (
<>
<QuillEditor value={content} onChange={setContent} />
<div dangerouslySetInnerHTML={{ __html: content }} />
</>
);
The problem is that Quill's default configuration allows users to paste arbitrary HTML, including <img src=x onerror=...> and <a href="javascript:...">. The editor itself isn't sanitising; it's just capturing formatted text.
You need DOMPurify on the way out, applied to the stored value before it goes into dangerouslySetInnerHTML. Run it at render time rather than on input, so sanitisation is always applied regardless of how the content reached your database. Apply it server-side too if you're storing and serving this content to other users.
Content Security Policy as a second line
Sanitisation can be bypassed. Library bugs get discovered. CSP is what limits the damage when that happens.
A minimal CSP header for a React app that doesn't need inline scripts:
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://api.yourdomain.com;
frame-ancestors 'none';
unsafe-inline on styles is unfortunate but often unavoidable with CSS-in-JS. script-src 'self' without unsafe-inline or unsafe-eval blocks injected script tags and javascript: URL payloads that somehow slip through sanitisation. That's the most important directive.
For apps that need inline scripts: use a nonce. Set a cryptographically random nonce per request, inject it into your CSP header and your <script> tags, and block everything else. It's more work than it sounds, but it's the right answer.
I'm not going to pretend CSP is painless to deploy. Report-only mode (Content-Security-Policy-Report-Only) lets you see what would be blocked without breaking anything, and it's the sensible starting point for a production app that didn't have CSP from day one.
The pattern to notice
All three of these vulnerabilities share a structure: LLM-generated code takes data from outside the application boundary and puts it somewhere that React's built-in escaping doesn't reach. JSX expressions are safe. Attributes that accept URLs are not. dangerouslySetInnerHTML is not. Anything that goes from user-controlled input to raw DOM content is not.
When you review AI-generated React, look for that pattern first. If data crosses a trust boundary and the code doesn't explicitly validate or sanitise it before rendering, assume it's vulnerable until proven otherwise. Seaworthy flags this pattern automatically.
本文由 AI 生成,总结了公开可用的来源。
Seaworthy 会扫描你的代码仓库,发现类似本文提到的各种问题。
安全漏洞、泄露的密钥和配置错误——在部署前捕获。免费运行,无需账户。