The risk: model text that runs as code in the browser
The concrete failure is this. Your AI feature gets a response from an LLM, and the app renders that response as HTML rather than as plain text. If the model ever returns a string containing a script tag, an onerror attribute, or a javascript: URL, the browser does not show those characters, it executes them. That is cross-site scripting (XSS), driven by content you did not write and cannot fully predict.
This is insecure output handling, the OWASP LLM Top 10 category LLM02. It is the mirror image of prompt injection: prompt injection is about untrusted text going into the model, insecure output handling is about the model's output being trusted too much on the way out. An attacker who can influence the prompt, through a chat message, a document you summarize, a product review you ask the model to rewrite, can try to make the model emit markup that runs when your app displays it.
Separate what is observable from what is only possible. Observable: whether your render path treats model output as HTML or as text. That you can check directly. Only possible until proven: whether a specific prompt makes the model emit a working payload. You do not need to prove the second to fix the first. If the output is rendered as HTML at all, the unsafe path exists regardless of whether you have seen it fire.
Why AI-built apps land here by default
The trouble usually starts with formatting. LLMs love to answer in Markdown, with headings, bold, lists, and links. To make that render nicely, the fast path is to run the text through a Markdown-to-HTML converter and inject the result with innerHTML, or the framework equivalent. The app suddenly looks polished, so it looks finished. The fact that the same path will happily render a script tag is invisible from the inside.
A few specifics push generated code toward the unsafe version:
Frameworks escape interpolated text on purpose. The moment generated code reaches for the raw-HTML escape hatch to render model output, that protection is gone for exactly the data you control least.
- The quickest way to show formatted text is innerHTML in plain JS, v-html in Vue, dangerouslySetInnerHTML in React, or a Markdown library configured to allow raw HTML. All of these bypass the framework's built-in escaping. The framework was protecting you by default, and this opts out of it.
- Many Markdown renderers pass raw HTML through unless you turn that off. The default is convenience, not safety.
- The demo prompt during development is friendly, so the output is always clean. Nobody tries the message that contains markup, so the sink never fires in testing.
- Streaming responses get appended to the DOM token by token, and a sanitizer that runs once on a finished string is easy to skip when the text arrives in pieces.
Check your own render path before launch
You can confirm how your app handles model output without attacking anything. Run these on your own app.
Read the render path in your own code first. Search your frontend for the raw-HTML sinks: innerHTML, outerHTML, dangerouslySetInnerHTML, v-html, insertAdjacentHTML, and any Markdown renderer call. Then trace backward: does any of that markup come from an LLM response, directly or after a Markdown conversion? If model output reaches one of these sinks without sanitization in between, that is the suspicious result. If model output only ever lands in text nodes (textContent, normal Vue or React interpolation, a Markdown renderer with HTML disabled and an allowlist), that is the safer result.
Do a safe self-test in the running app. In a context where you control the input, such as a chat box or a field the model echoes back, submit a benign, non-executing string and watch what the DOM does. A harmless probe is an image tag pointing at a non-existent source with an onerror that does nothing dangerous, or simply a bold tag and a plain less-than sign. Open DevTools and inspect the rendered node:
Keep the probe inert. The point is to learn whether the path parses HTML, not to run an exploit. Do this only against your own app, never against anyone else's.
- Suspicious result: your less-than sign became a real element, or the bold tag rendered as formatting rather than showing as literal characters. That means the string was parsed as HTML, so a real payload would execute.
- Safer result: the characters show on screen exactly as you typed them, as visible text. That means they were escaped and treated as data, not markup.
How to fix it, and what the fix does not cover
The reliable default is to stop rendering model output as HTML at all. Put LLM text into the page as text, using textContent, standard React or Vue interpolation, or a Markdown renderer with raw HTML disabled. Then the worst a malicious response can do is look ugly, not run code.
When you genuinely need formatted output, sanitize on the render path:
State the limits plainly. Sanitizing the output stops the XSS sink. It does not stop the model from being manipulated in the first place, that is prompt injection, a separate problem covered in the prompt injection note. It does not protect server-side consumers: if your backend feeds LLM output into a SQL query, a shell command, or another system, that needs its own escaping and parameterization, and HTML sanitization does nothing for it. And sanitization is an authorization-neutral control: it prevents script execution, but it does not decide who is allowed to do what. Distinct concern, distinct fix.
- Render Markdown with raw HTML turned off and use the library's allowlist, so the model can produce bold and lists but not arbitrary tags.
- If you must allow some HTML, run it through a maintained sanitizer such as DOMPurify before it reaches the DOM, and sanitize the final string, including for streamed responses, not the partial tokens.
- In React, avoid dangerouslySetInnerHTML for model output. In Vue, avoid v-html. If you cannot, sanitize the value first, every time.
- Be strict about links and embeds: strip or allowlist href and src schemes so javascript: URLs and unexpected sources cannot slip through.
Where VibeCodeGuard fits, and what it does not prove
A strict Content-Security-Policy is real defense in depth against LLM-output XSS. A CSP that disallows inline scripts and untrusted script sources blocks many of these sinks even when a payload reaches the DOM, because the injected script has no permission to run. VibeCodeGuard's Launch Check inspects your public surface and flags whether a CSP is present and how loose it is, alongside the rest of your security headers. A missing or permissive CSP is exactly the gap that turns an unsafe render path into a working exploit. For the wider header picture, see the security headers note and the CSP note listed below.
What the scan cannot do is read your render path. Confirming whether model output is sanitized before it hits innerHTML, v-html, or a Markdown renderer is a code question, and code review is where it gets answered. The Launch Check sees the public-surface signal, the CSP, not the line of code that decides how the response is rendered.
Next step: open your frontend, search for the raw-HTML sinks, and confirm whether any model output reaches one without sanitization. Fix that path first, then run a focused Launch Check against the public URL to confirm a CSP is in place as a backstop before you share the link publicly.
A clean header scan does not prove your AI feature is safe from output-handling XSS. A good CSP reduces the blast radius, but the actual sink lives in your render code, where only inspection of the path from LLM response to DOM can confirm sanitization. Treat the CSP as a backstop, not the fix.
> launch check
Scan the public surface before launch.
Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.