Skip to content
ShipSecurelySecurity reference
Menu
Your tool here — sponsor ShipSecurely

Issues

Cross-site scripting (XSS)

User content rendered as HTML runs in your origin. It can read that user's data and call your API as them.

Share link · Share on X

Last updated

highInjection

Shows up in: Next.js, Supabase, Vercel

Markdown version

What it is

React escapes text you put in JSX. That default is why a comment rendered as {comment.body} is usually safe. The bugs are the escapes from that default: dangerouslySetInnerHTML, a markdown renderer that emits raw HTML, setting innerHTML, or an href that starts with javascript:.

Stored XSS is the version that matters for a small app. One user saves a display name or a bio. Every other user who views it runs the script. The script is same-origin, so it can call your APIs with the victim's cookies.

A content security policy reduces how useful a bug is. It does not make dangerouslySetInnerHTML of user HTML acceptable. See missing security headers.

Model output is untrusted text too. If you render an LLM reply as HTML, you have the same bug, plus prompt injection.

How to check in 5 minutes

  1. Search for dangerouslySetInnerHTML, innerHTML, markdown, and rehype-raw. Open each and see whether the HTML can include a user's string or a model's reply.

  2. Search links built from user input. An <a href={user.url}> is safe for https and dangerous for javascript:.

  3. On a staging account you own, save <img src=x onerror=alert(1)> or a markdown image with an error handler as a profile field. View it as another user. If you see the dialog, or the image request fires, the field is rendered as HTML.

  4. Check email templates and PDF rendering if they interpolate names into HTML the same way.

How to fix

Next.js

Render user text as text. If you need formatting, use a markdown renderer that does not pass raw HTML through, or sanitize with a maintained HTML sanitizer and a strict allowlist (no script, no event handlers, no javascript: URLs). DOMPurify is a widely used sanitizer. Pin the version and keep it updated.

export function Bio({ bio }: { bio: string }) {
  return <p>{bio}</p>;
}

For links, allow only https: after parsing the URL. Reject everything else.

Do not build a sanitizer with a regex. You will miss a browser quirk.

Supabase

The database can store the characters. That is fine. The bug is rendering them as HTML. You do not need to strip < on the way in if every view escapes on the way out. Do not reflect a stored error string into an admin page with dangerouslySetInnerHTML either.

Vercel

A header such as Content-Security-Policy on the deployment limits script sources. Set it in next.config or vercel.json. A CSP is a second layer. Remove the HTML sink first. See missing security headers.

Firebase

Same rendering rules. Security rules do not stop XSS. They stop the write, not the script in a field you chose to render.

Copy-paste prompt

Paste this into Cursor, Claude Code, or whichever agent is editing the repo. Read the diff before you merge it.

You are removing cross-site scripting sinks. Do not add features.

Search for dangerouslySetInnerHTML, innerHTML, and markdown or HTML renderers.

For each:
1. If the input can include user or model text, render it as text, or sanitize HTML with a maintained allowlist sanitizer. Do not write a regex sanitizer.
2. Disallow javascript: and data: URLs in links built from user input. Allow https only, unless I named another scheme.
3. Do not turn off React's escaping to make formatting work.

Show each sink and what you did with it.

Questions

React escapes everything, so can I ignore this?

React escapes JSX text. It does not escape HTML you insert on purpose, HTML you generate from markdown, or URLs in href.

Should I strip angle brackets when saving to the database?

Escaping at render is the reliable fix, because a new screen can forget a strip-on-write rule. Stripping on write also corrupts legitimate text like "1 < 2".

Sources

Related