XSS from user markdown content: how to find it and fix it
To fix XSS from user markdown, stop the renderer from passing raw HTML to the page, sanitize the final HTML with an allowlist, and add a Content Security Policy. Markdown looks safe because it is plain text. It is not safe. A markdown parser can turn user text into HTML, and the browser runs the scripts in that HTML as if you wrote them.
This guide shows how the attack works, how to confirm that your app has the problem, and how to fix it in the order of urgency. It also shows how to check for earlier abuse and how to prevent the problem in new code.
What does XSS from user markdown mean?
Cross-site scripting (XSS) means that an attacker puts JavaScript into your page, and the browser of another user runs it. The script runs with the permissions of your site. It can read data on the page, send requests as the user, and change what the user sees.
Markdown creates this problem in three ways:
- Raw HTML in the input. Most markdown parsers keep HTML tags that appear in the text. The text
<img src=x onerror=alert(1)>becomes a real image tag. Theonerrorcode runs when the image fails to load. - Dangerous link addresses. A markdown link has the form
[text](address). If the address isjavascript:..., a click can run code. Some parsers do not block this address. - Unsafe insertion. The app takes the HTML that the parser makes and writes it to the page with
innerHTML,dangerouslySetInnerHTML, orv-html. These APIs do no checks.
The risk is highest where one user writes and another user reads. Examples are comments, profile bios, support tickets, product reviews, chat messages, and notes that an admin opens. An attacker only needs to write a payload once. Every reader then runs it.
AI coding tools often add this pattern. You ask for a rich text field. The tool installs a markdown library and writes the HTML into the page, because that is the shortest way to show formatted text. The code works in every test with normal text, so the problem stays hidden.
How do I know my app has this problem?
Use these checks in order. Run them on a test account, not on the account of a real user.
Check 1: Search the code
Search your repository for the insertion APIs and for the parser options that allow HTML:
grep -rnE "dangerouslySetInnerHTML|innerHTML|v-html|\{@html|bypassSecurityTrust" src/
grep -rnE "rehype-raw|rehypeRaw|allowDangerousHtml|html: *true|sanitize: *false" src/
Each match is a place to read. A match is not always a bug. It is a bug when the value comes from a user and no sanitizer runs before it.
Check 2: Submit a harmless payload
Put this text into every field that supports markdown:
<img src=x onerror="document.title='xss-test'">
[click me](javascript:document.title='xss-test')
Open the page that shows the field. Look at the browser tab title. If the title changes to xss-test, the first line ran as code. Click the link and look at the title again. If it changes, the link address is also unsafe.
Warning: Use a payload that only changes the page title. Do not use a payload that sends data to another server. Do not test on a page that real users can open.
Check 3: Inspect the HTML
Open DevTools and select the Elements tab. Find the rendered content. Look for <img onerror=...>, <script>, <iframe>, <svg onload=...>, and <a href="javascript:...">. If any of these exist in user content, the sanitizing is missing or too weak.
What do I do first? Fix it now, in order of urgency
Do the steps in this order. Steps 1 and 2 reduce the risk in minutes. The other steps remove the cause.
Caution: Fixes for new input do not clean the content that is already in your database. Step 5 covers old data.
- Turn off raw HTML in the renderer. If you use
react-markdown, removerehype-rawand any option that allows HTML. The react-markdown README states that the library is secure by default and escapes HTML. It also warns that a plugin or a changedurlTransformcan make it insecure. - Stop using unsafe insertion where you can. Replace
dangerouslySetInnerHTMLwith a component that builds React elements, such asreact-markdown. React escapes text that it renders as elements. - Sanitize the HTML if you must render HTML. Use a tested library. See the next section.
- Block dangerous link addresses. Allow only
http,https, andmailtoin links. The defaulturlTransformofreact-markdownallowshttp,https,irc,ircs,mailto, andxmpp. Do not replace it with a function that returns the address unchanged. - Clean the stored content. Search the database for payload patterns, and sanitize or delete the matching rows. See the damage section.
- Add a Content Security Policy. See the section on CSP.
- Deploy and repeat the test. Run the payload from Check 2 on the live site.
How do I sanitize markdown output correctly?
Sanitizing means that you remove every tag, attribute, and address that is not on a list of safe items. You make the list. Everything else is removed.
Use a library for this. Do not write your own filter. A filter that removes <script> tags misses <img onerror>, <svg onload>, and many other forms. Attackers know these forms.
Option A: Do not allow HTML at all
This is the simplest option. The renderer shows the markdown formatting and shows any HTML as plain text. In react-markdown, this is the default:
import Markdown from "react-markdown";
export function Comment({ text }: { text: string }) {
return <Markdown>{text}</Markdown>;
}
The component builds React elements. It does not write a string into the page. If a user writes an <img> tag, the page shows it as text.
If you want to remove HTML instead of showing it as text, use the skipHtml option. The README says the library escapes HTML or ignores it with skipHtml.
Option B: Sanitize with DOMPurify
Use this option when you need to write the HTML string into the page. The DOMPurify README shows the basic call:
const clean = DOMPurify.sanitize(dirty);
By default it permits HTML, SVG, and MathML. For user comments, use a strict list. The README shows how to restrict the tags with ALLOWED_TAGS:
import DOMPurify from "dompurify";
import { marked } from "marked";
const html = await marked.parse(userText);
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["p", "br", "strong", "em", "ul", "ol", "li", "code", "pre", "blockquote", "a"],
ALLOWED_ATTR: ["href"],
});
Sanitize after the markdown step, not before. The markdown parser makes the final HTML. Only the final HTML tells you what the browser sees. Then write clean to the page.
Note: The DOMPurify README says that the USE_PROFILES setting overrides ALLOWED_TAGS. Use only one of them.
Keep the library current. The README says the project keeps a mailing list for security-critical releases. If you run DOMPurify on the server, the README also says to keep jsdom up to date.
What not to do
- Do not remove only
<script>tags with a regular expression. - Do not rely on the front end alone. An attacker can call your API directly and skip your form.
- Do not turn on raw HTML because a user asks for a table or a video embed. Use markdown tables, or add the exact tag to your allowlist.
How do I add a Content Security Policy?
A Content Security Policy (CSP) is an HTTP header. It tells the browser which sources of script it may run. According to the MDN CSP guide, a CSP gives a defense in depth against XSS even if sanitization fails. MDN also states that a CSP is not an alternative to sanitizing input. Use both.
If a CSP has a default-src or a script-src directive, the browser blocks inline JavaScript unless you allow it. This covers inline <script> blocks, inline event handlers such as onerror, and javascript: addresses. That is the exact set of tools in a markdown payload.
A basic header for a site that loads its scripts from its own domain:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'
The MDN guide warns that you should avoid 'unsafe-inline', because it removes most of the value of a CSP. If your framework needs inline scripts, use a nonce or a hash instead. Next.js, for example, supports nonces. Check the documentation of your framework for the exact steps.
Add the header in your host or framework settings. On Vercel, you can use the headers section of vercel.json:
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'"
}
]
}
]
}
Caution: A strict policy can break your analytics, fonts, images, and payment widgets. Start with the header Content-Security-Policy-Report-Only. The browser then reports violations and blocks nothing. Fix the reports, and then switch to the enforcing header.
How do I check the damage?
Find out if anyone stored a payload and if anyone ran it. You have two goals. You must find the unsafe rows, and you must find out what the script could do.
- Search the database. Look for stored text that contains HTML event handlers and dangerous tags. A simple query for a table named
commentswith a column namedbody:
select id, user_id, created_at, left(body, 120)
from public.comments
where body ~* '<\s*(script|iframe|svg|img|object|embed)'
or body ~* 'on[a-z]+\s*='
or body ~* 'javascript:';
- Read each match. Many matches are normal text, such as a code sample. Look for payloads that load a script from another domain or send data out.
- Check who wrote the matching rows. Note the accounts and the dates. A new account that writes a payload soon after sign-up is a strong sign of an attack.
- Check what the readers could lose. Think about what a script could read on the page. It could read data that the page shows and tokens that the page can reach. If your app stores the session token in
localStorage, any script can read it. Cookies with theHttpOnlyflag are not readable from JavaScript, but a script can still send requests as the user. - Check the admin pages. If an admin opened a page with a payload, the script ran with admin rights. Read the admin action logs for changes that nobody planned.
- Check your server logs. Look for requests from the readers that you do not recognize, such as changes to settings, email addresses, or API keys, at the times when the payload was visible.
- Rotate the secrets that a script could reach. If a payload could run on a page that shows an API key or a token, replace it. Also end all active sessions.
After the check, sanitize or delete the unsafe rows. Do not leave them in the database because the new renderer hides them. A new feature, an export, or an email template can render them again without the protection.
How do I stop it from coming back?
Use these controls for every new feature that shows user text.
- Use one render component. Create one
Markdowncomponent for the whole app. Every field that shows user markdown must use it. Then you fix the safety in one place. - Keep raw HTML off by default. Do not add
rehype-rawor similar options. If you need HTML, add the exact tags to an allowlist. - Ban unsafe APIs in review. Add a lint rule or a search step in your pull request process for
dangerouslySetInnerHTML,innerHTML, andv-html. Tell your coding tool in the project rules to never use these APIs on user content. - Set the CSP header on every page. Keep a report-only copy running, so that new violations show up in your logs.
- Test with payloads. Add a test that saves the Check 2 payload and asserts that the rendered output has no
onerrorattribute and nojavascript:link. - Scan the deployed app after each release. The live site is the only place where all the layers, including your host headers, come together.
AI-rendered text has the same risk. If a language model writes the markdown that you show, treat the output as untrusted input. The page LLM-rendered HTML and markdown explains this case. For the browser-side attack patterns in general, read prototype pollution and DOM attacks. For a wider list of checks before launch, use the production security checklist.
When should I get help?
Get help in these cases:
- You find a payload in the database that loads a script from another domain. An attacker has a plan, and you need to check what the script took.
- An admin account opened a page with a payload. Treat the admin session as compromised, and review every admin action.
- Your app stores personal data or payment data, and a script could reach it. You may have legal duties to report a breach. Ask a security engineer or a lawyer.
- You cannot find all the places that render user markdown. A scan of the deployed app shows which pages run a test payload.
- Your CSP breaks key features and you cannot fix the reports. Ask a developer who knows your framework to set up nonces.
After the fix, run the payload from Check 2 again on every page that shows user content. Confirm that the tab title does not change and that the CSP header is present in the response.
Common questions
Is markdown safe because it is not HTML?
Do I need DOMPurify if I use react-markdown?
Does a Content Security Policy replace sanitizing?
Is the problem the same for comments, bios, and chat messages?
Can I fix this by removing script tags from the input?
Test your rendered output
A VibeEval scan submits script payloads to the input fields of your deployed app and reports the ones that the browser runs when the page renders.
14-day free trial · No credit card · Cancel anytime