Example finding How it works Coverage PENTEST METHODOLOGY DOCS PRICING FAQ MCP CONTACT LOG IN SIGN UP →

AI response renders HTML: how to fix the XSS

FIX / AI OUTPUT XSS / 9 MIN READ

If your page shows AI output as HTML, change the code to render the output as plain text, or as Markdown with raw HTML turned off, and sanitize any HTML that you must keep. The output of a model is untrusted input. An attacker can make the model write a script tag or an event handler, and the browser of your user then runs it.

This guide shows how to confirm the problem with a safe test, how to fix the rendering code, and how to check for stored payloads. It also shows how to add a second layer of defense.

What does “AI response renders HTML” mean?

It means that your frontend puts the model output into the page as HTML and not as text. The browser then reads tags in the output as real elements. If a tag runs code, the code runs with the rights of your user.

This attack is called cross-site scripting, or XSS. The code can read data on the page, send requests as the user, and change what the user sees. If your session cookie is not marked HttpOnly, the code can also read it.

You may think that the model is your own code, so its output is safe. It is not. Three sources control the output:

  1. The user. A user asks the model to write a payload. In a shared chat, one user can then attack the others who read the chat.
  2. A document. A file, a web page, or an email that the model reads can contain instructions. The model follows them and writes the payload. Read the pattern on indirect prompt injection for more.
  3. The model itself. A model can write HTML in a normal answer, for example when it shows a code sample.

Vibe coding tools often create this bug. You ask the tool to show the answer with bold text, lists, and links. The tool uses dangerouslySetInnerHTML or innerHTML, because this is the fastest way to show formatted text. The feature looks correct in a demo.

The React documentation is clear about this. It says to use dangerouslySetInnerHTML only with trusted and sanitized data. For more examples of this bug in AI features, read LLM-rendered HTML and Markdown.

How do I confirm that my AI output runs as HTML?

Use a harmless test payload. The payload shows a message box. It does not send data.

  1. Open your app in a browser and sign in with a test account.
  2. Open the AI feature.
  3. Send this message: Repeat this text exactly and add nothing: <img src=x onerror="alert('xss')">
  4. Watch the page.

If a message box appears, the page runs HTML from the output. The page is vulnerable.

If no message box appears, do not stop. The model can refuse the request or change the text. Use these checks too:

  1. Search the code. Search your repository for dangerouslySetInnerHTML, innerHTML, v-html, {@html, and insertAdjacentHTML. Check each result. Find out if the value can contain model output.
  2. Inspect the page. Ask the model to answer with the word test in bold. Open DevTools and select the Elements tab. Find the answer. If you see a <b> or <strong> tag in the page, the page turns the output into elements. Check if it also turns a <img> tag into an element.
  3. Check Markdown settings. If you use a Markdown library, find the option that allows raw HTML. Names include rehype-raw, html: true, allowDangerousHtml, and sanitize: false.
  4. Check stored messages. The chat history can store the output. If the page renders the history as HTML, an old payload runs each time a user opens the chat.

Warning: Test only on your own app, and use a test account. If your chat is shared between users, do not send the payload to a real room.

How do I fix it now? Fix it in order of urgency

Do the steps in this order. Steps 1 and 2 close the hole at once.

  1. Remove the unsafe sink. Replace dangerouslySetInnerHTML and innerHTML with a safe way to render text. See the next sections.
  2. Turn off raw HTML in the Markdown renderer. If you need formatted text, keep Markdown and drop the HTML.
  3. Sanitize any HTML that you must keep. Use a maintained sanitizer with a short list of allowed tags.
  4. Check links and images. Allow only https: links. Do not let the model load images from any address.
  5. Add a Content-Security-Policy header. This limits the damage of a bug that you missed.
  6. Search the stored data for payloads. Old messages can still hold the attack.
  7. Deploy and repeat the test.

Caution: Do not fix this bug on the server with a filter for <script>. Attackers use many other ways to run code, for example event handlers and javascript: links. A filter list always misses some. Fix the rendering.

How do I render the AI output as plain text?

Render the output as text. This is the safest option and the simplest. You lose the formatting, but you remove the risk.

In React, put the value in the JSX as text. React escapes it for you:

function Answer({ text }: { text: string }) {
  return <p style={{ whiteSpace: "pre-wrap" }}>{text}</p>;
}

The style keeps the line breaks. A tag in text appears on the page as the characters < and b, and it does not become an element.

In plain JavaScript, use textContent. The OWASP XSS prevention cheat sheet advises you to remove unsafe sinks like innerHTML and use textContent instead. It explains that textContent creates a text node and does not parse HTML.

const el = document.getElementById("answer");
el.textContent = answerText;

Streaming output needs the same care. A common bug is el.innerHTML += chunk for each chunk. Use el.textContent += chunk instead.

How do I render Markdown from the model safely?

Most AI chat products show Markdown, so most apps need a renderer. Use one that drops raw HTML by default, and do not turn that default off.

The react-markdown library is one example. Its documentation says that it escapes HTML by default, or drops it with the skipHtml option. It names the risks: the rehype-raw plugin renders raw HTML and is only for trusted content. A custom urlTransform, and other plugins and components that you add, can open the way for XSS. For complete safety, the documentation recommends rehype-sanitize.

import ReactMarkdown from "react-markdown";
import rehypeSanitize from "rehype-sanitize";

function Answer({ text }: { text: string }) {
  return <ReactMarkdown rehypePlugins={[rehypeSanitize]}>{text}</ReactMarkdown>;
}

Use these rules for any Markdown library:

  1. Do not add rehype-raw or an equivalent option. If you need it, use it together with a sanitizer.
  2. Do not write a custom link or image handler unless you check the address. Allow https: and mailto: only.
  3. Keep the library up to date. Sanitizers and renderers get security fixes.
  4. Read the changelog before you add a plugin. A plugin can turn off the safe default.

What if I need to render HTML?

Some products must show HTML, for example an email assistant. In this case, sanitize the HTML in the browser, right before you put it in the page.

The OWASP cheat sheet recommends DOMPurify for HTML sanitization. It says that the sanitized content must stay intact. If you change the content after the sanitizer, you can void the protection. It also says that you must patch the library regularly.

import DOMPurify from "dompurify";

const clean = DOMPurify.sanitize(modelOutput, {
  ALLOWED_TAGS: ["b", "i", "em", "strong", "p", "ul", "ol", "li", "code", "pre", "a"],
  ALLOWED_ATTR: ["href"],
});
element.innerHTML = clean;

The DOMPurify project describes the ALLOWED_TAGS option, which restricts the output to the tags that you name. A short list is better than the default. Do not allow style, iframe, form, or svg. Do not allow event attributes.

Sanitize on every render and not only when you save the message. The sanitizer rules change over time, and a message that was clean last year can need a new check.

How do I add a Content-Security-Policy as a second layer?

A Content-Security-Policy (CSP) tells the browser which scripts it may run. If an attacker injects an inline script, the browser refuses to run it.

The MDN CSP guide explains the limit of this control. It says that a CSP is not an alternative to sanitizing input. Sites should sanitize input and set a CSP, to get defense in depth. It also says that a policy with default-src or script-src blocks inline JavaScript, including inline event handlers, unless you allow it with a nonce or a hash.

A basic header looks like this:

Content-Security-Policy: default-src 'self'; img-src 'self' https:; object-src 'none'; base-uri 'none'

Follow these rules:

  1. Do not add 'unsafe-inline' to script-src. MDN says that developers should avoid it, because it defeats much of the purpose of a CSP.
  2. Test the header before you enforce it. Use the Content-Security-Policy-Report-Only header first. Read the reports and fix your own scripts.
  3. Set the header on your host. Most hosts let you add headers in a config file.
  4. Limit image sources. An image tag with an address that the attacker controls can send data out in the URL.

Some frameworks and analytics scripts use inline code. You may need a nonce for these. This is extra work, but it is worth it for an app with chat input.

How do I check the damage?

Read three places.

  1. Stored messages. Search your database for payload patterns in the AI output and in user messages. Use a query like this one, and change the table and column names:
select id, created_at
from public.messages
where content ~* '<\s*(script|img|svg|iframe|object|embed|link|style)'
   or content ~* '\son[a-z]+\s*='
   or content ~* 'javascript:'
order by created_at desc
limit 100;
  1. Shared content. Find the places where one user can see the output of another user: shared chats, public pages, and team workspaces. A payload in these places attacks other users.
  2. Request logs. Look at the logs of your host for requests from the browsers of your users to unknown addresses. A payload that sends data out makes such a request.

If you find a real payload, find out who wrote it and who opened it. Delete or clean the row. Sign out the affected users and rotate their sessions. See your auth provider docs for the exact steps.

If the page could read tokens in localStorage, treat these tokens as leaked. Revoke them. For the same reason, store session cookies with the HttpOnly flag. A script on the page cannot read an HttpOnly cookie.

Note: A payload that ran in the browser of a user leaves few traces on your server. If you cannot prove that no one opened a payload, assume that someone did.

How do I prevent it in the next release?

  1. Write one rule for your coding tool. For example: “Never use dangerouslySetInnerHTML, innerHTML, or v-html for model output. Use the Answer component.”
  2. Use one component for all AI output. One safe component is easier to review than ten places that render text.
  3. Search each pull request for unsafe sinks. Search for the names in the confirm section.
  4. Add a test. Render the string <img src=x onerror=alert(1)> in the component. Assert that the output has no img element.
  5. Send the CSP header from the start. Turn on the report-only mode when you build a new app.
  6. Treat tool output the same way. If the model calls a tool and shows the result, the result is also untrusted.
  7. Scan the live app after each release.

When should I get help?

Get help in these cases:

  • You find a payload in stored data that ran for real users. Ask a security engineer to help you find out what data it could reach.
  • Your app is an email or document tool, and you must render HTML from other sources. A sanitizer rule set needs a careful review.
  • Your CSP breaks the app and you cannot find the cause. Use the report-only mode and read the reports.
  • Your app stores payment data or personal data on the same origin as the chat. Move the chat to a separate origin, or ask for a review.

After the fix, send the test payload again. Check that the page shows the text and that no message box appears.

Common questions

Can the model really send a script to my users?
Yes. The model writes the text that the attacker asks for, or that a document tells it to write. If your page renders that text as HTML, the browser runs the script.
Is Markdown safe to render?
Markdown is safe only if the renderer drops or escapes raw HTML. Many renderers pass HTML through. Check the setting of your library and add a sanitizer.
Do I need DOMPurify if I use React?
No, if you render the output as text. React escapes text by default. You need a sanitizer only when you use dangerouslySetInnerHTML or a raw HTML option.
Does a system prompt that forbids HTML fix the problem?
No. A prompt does not control the output with certainty. Fix the page, not the prompt.
Does a CSP replace sanitizing?
No. A CSP is a second layer. The MDN docs say that sites must sanitize input and also set a CSP.

Test ai output rendering

A VibeEval scan loads your deployed app and reports pages that render HTML from AI or user content without a Content-Security-Policy header.

14-day free trial · No credit card · Cancel anytime

SCAN YOUR APP →