Topic 9.3
Insecure Output Handling
In one line
A model's reply is untrusted input: it may repeat hostile text it read. Render it as text, allow only safe link protocols, never run generated code, and put any generated HTML in a sandboxed frame. Then write a test that proves no active content gets through.
Think of it like this
A shop assistant handing a customer a parcel that a stranger gave them to pass on. The assistant is honest, but the parcel may contain anything. A careful shop opens it at the counter, looks, and only then hands over what is safe. Model output is that parcel: it passed through your assistant, but the contents may have come from a stranger.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Sink
- A place where text becomes code or a request:
innerHTML,href,src,eval,srcdoc. - XSS
- Cross-site scripting: hostile text ends up running as script on your page, with your site's powers.
- Sanitise
- Remove or neutralise everything dangerous from HTML, keeping only a list of allowed tags and attributes.
- Protocol allow-list
- A fixed list of URL schemes you accept in links, such as
https:andmailto:. - sandbox (iframe)
- An iframe attribute that removes permissions from the framed page. Each
allow-...token gives one back. - Content Security Policy (CSP)
- A response header that tells the browser which script and image sources are allowed. A backstop, not a replacement for safe rendering.
- Audit function
- Test code that scans the rendered page for scripts, frames and bad links.
- Opaque origin
- The identity of a sandboxed frame without
allow-same-origin: other pages cannot read it and it cannot read them.
Step by step
01Render the reply as parts, not as a string of HTML
The first live example turns a reply into React elements: plain text stays text, [label](url) becomes a link only if the URL passes the check, and an image becomes a visible note. Nothing is ever assigned to innerHTML.
The hostile reply contains <script>alert(1)</script>. React shows those characters on screen exactly as typed, because it escapes text. That is the whole defence against script injection through text.
02Check links by parsing, not by looking
safeHref calls new URL(raw, base). The browser's own parser decides the protocol, so odd capitals, leading spaces and a hidden tab are all normalised the same way the browser would normalise them at click time. Anything but https: and mailto: becomes plain text with the note "(link removed)".
const ALLOWED = ["https:", "mailto:"];
function safeHref(raw) {
try {
const u = new URL(raw, "https://chai.example"); // relative links resolve against our own site
return ALLOWED.includes(u.protocol) ? u.href : null;
} catch {
return null; // not a URL at all
}
}03Audit what was really rendered
After rendering, the example counts active elements in the DOM: scripts, frames, images, and links with a bad protocol. The numbers are the test's answer. This is how you verify, instead of trusting that the renderer "should" be safe.
04Sandbox generated pages, show generated code as text
The second example does two things. It prints a verdict for four sandbox attribute values, flagging the dangerous combinations. Then it creates a real frame with an empty sandbox, and after the frame loads checks that the parent cannot read it (contentDocument is null).
It also shows code as text: a reply containing <b>hi</b> appears as the characters, and no <b> element exists.
05Add a policy header as a backstop
A Content Security Policy cannot run in this preview, so it appears as a plain block below. It tells the browser to refuse inline scripts and outside image hosts even if your rendering code has a bug. Treat it as the second lock, not the first.
// Content-Security-Policy value (one line in the real header)
default-src 'self';
script-src 'self';
img-src 'self' https://chai.example;
object-src 'none';
base-uri 'none';
frame-ancestors 'none'Try it yourself
- 1
Allow a new protocol on purpose
In the first example, change
ALLOWEDto["https:", "mailto:", "data:"]and run it.The
data:link in the disguised reply now appears among the hrefs. Every protocol you add is a decision about what a click may do. Remove it again. - 2
Give the frame too much
In the second example, change the
setAttribute("sandbox", "")call to"allow-scripts allow-same-origin"and run it.The verdict line says UNSAFE and
sandbox permissions grantedbecomes2. This is the combination the checker exists to catch.parent can read the framestill saysfalsehere, only because this preview page itself has an opaque origin; on your own site, with a real origin, the framed page would share it and could reach into your page.
Code & diagrams
The attacker text is only stand-in text: `alert(1)` and an address that does not exist. The audit is the test you keep: it reads the real DOM, not your intentions.
Preview
Console
Nothing logged yet.Expected console output
hostile: scripts 0, frames 0, images 0, handlers 0
hrefs: ["https://chai.example/track/1042"]
script tag shown as visible text: true
disguised: scripts 0, frames 0, images 0, handlers 0
hrefs: ["https://chai.example/returns","mailto:help@chai.example"]
empty: scripts 0, frames 0, images 0, handlers 0
hrefs: []An empty `sandbox=""` is the strictest setting. Each token you add gives one permission back, so every token needs a reason.
Preview
Console
Nothing logged yet.Expected console output
"" ok (no permissions)
"allow-scripts" ok (allow-scripts)
"allow-scripts allow-same-origin" UNSAFE: the frame can remove its own sandbox
"allow-forms allow-top-navigation" UNSAFE: the frame can send the whole tab elsewhere
code shown as text: true, real <b> elements: 0
sandbox permissions granted: 0
parent can read the frame: false
verdict: ok (no permissions)Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Put the reply in as HTML
In the first example, delete the flushSync(...) line in the loop and put these two lines in its place: box.innerHTML = text; and then continue (keep the audit lines).
Myth vs fact
Myth
Output from our own model is safe to put in the page.
Fact
The model repeats what it read. Anything an attacker got into its input can come out in the reply.
Myth
Checking that a URL starts with http is enough.
Fact
Parse the URL and compare the protocol. Prefix checks break on capitals, spaces, tabs, relative links and look-alike schemes.
Myth
An iframe is always a safe container.
Fact
Only with the right sandbox. allow-scripts together with allow-same-origin gives the page enough power to undo its own sandbox.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
React protects text, not attributes you build yourself.
<a href={model.url}>is a sink even in React, which is why the protocol check belongs in one helper that every link goes through. Search your codebase fordangerouslySetInnerHTML,innerHTML,href={,src={andsrcDocin code that touches model output. - ▸
Make the audit part of the build: a test that renders each reply from a folder of hostile samples and fails on any active element. When you meet a new trick in the wild, add the sample first, watch the test fail, then fix the renderer.
- ▸
A CSP is easiest to add early. Start in report-only mode (
Content-Security-Policy-Report-Only) to learn what would break, fix it, then switch to enforcing. A strict CSP also limits the damage from a mistake in code you did not review, such as a dependency.
Remember this
- 1
The problem this solves: Developers think of model output as "our own" text because it came from our server. But the model writes by repeating and combining what it read, and some of that was written by an attacker. If your page puts the reply into the document as HTML, a link or a script, the attacker's text now runs with your site's powers. This is LLM05, Improper Output Handling in the OWASP Top 10 for LLM Applications (2025).
- 2
Rule: treat model output exactly like text typed by an anonymous visitor. You would never put a visitor's comment into the page as raw HTML. The same rule applies to a reply, a tool result, a citation title, a filename and any other string a model produced. Topic 6.3 gave you the basic rule (no
innerHTML); this topic goes deeper into detection and verification. - 3
Where unsafe output does harm (the sinks). A sink is a place text can become code or a request. The main ones:
innerHTMLand React'sdangerouslySetInnerHTML; a Markdown library with raw HTML turned on;hrefandsrcattributes (ajavascript:link runs code when clicked);eval,new FunctionandsetTimeoutwith a string;iframe srcdoc; and imagesrc(a request to an outside address, Topic 9.4). React's normal{text}is safe: it escapes everything. - 4
Safe rendering in three moves. (1) Put text through React as text, not HTML. (2) For links, parse the URL with
new URL(...)and allow only listed protocols (https:,mailto:). Parsing matters:JaVaScRiPt:in odd capitals, or a tab inside the word, still counts asjavascript:to a browser, so a plainstartsWith("http")test is easy to get wrong in both directions. (3) Addrel="noopener noreferrer"to links that open a new tab. - 5
Generated code is data, never a command. If Mitra writes a code sample, show it with
textContent(or a code component), add a Copy button, and stop. Neverevalit, never insert it into a<script>tag. If a feature must run generated code (a playground), run it in a sandboxed iframe with noallow-same-origin, in a worker with no network, or on a server sandbox, and ask the user first. - 6
Generated HTML goes in a sandbox. If a model produces a whole web page for a preview, put it in
<iframe sandbox="" srcdoc="...">. An emptysandboxattribute removes almost every permission: no scripts, no forms, no access to your page. Two traps: addingallow-scriptstogether withallow-same-originlets the framed page reach into your page and remove its own sandbox, so never combine them for untrusted content; andallow-top-navigationlets it send the whole tab elsewhere. - 7
Detect. Write an audit function that looks at the rendered DOM for active content:
script,iframe,object,embed,form, elements withon...attributes, andhrefvalues with a bad protocol. Run it in tests against a set of hostile replies, and in development builds on every real reply with a warning in the console. Optionally add a Content Security Policy (a response header that tells the browser which scripts and image hosts are allowed) as a backstop: it catches what your code missed. - 8
Verify. The test that matters is outcome-based: render a hostile reply, run the audit, assert zero active elements and that the dangerous text appears as visible text. Keep that test in your suite forever, and add a new hostile sample whenever you meet a new trick. Sanitising libraries exist (DOMPurify is the widely used one) for the cases where you must accept HTML; prefer not to accept it.
Explain it without notes
Why is a model's reply untrusted input even when it came from your own server?
Why should link checks parse the URL instead of testing a prefix?
What is wrong with sandbox="allow-scripts allow-same-origin" for a generated page?
How do you verify that your rendering is safe, rather than assume it?
Practice
Extend safeHref so that links to a host other than chai.example are allowed but marked with an "external" flag, and render those with the text "(external)" after the label.
Write the audit for one more rule: any <a> that opens a new tab (target="_blank") without rel="noopener noreferrer" is counted as a problem.
Trade-offs
- ↔
A strict renderer (text, a few link types, no raw HTML) loses some formatting a model might want to produce. The fix is to support the Markdown features you need (Topic 6.1), not to open the door to raw HTML.
- ↔
A CSP adds a header to maintain and can break inline scripts and third-party widgets. Report-only mode lets you adopt it gradually.
Done when you can
I can name the main sinks where model output becomes code or a request.
I can render a reply as text and links without
innerHTML.I can allow-list link protocols by parsing the URL.
I can put generated HTML in an empty-sandbox iframe and spot unsafe sandbox combinations.
I can write an audit test that fails if a reply produces active content.