Command Palette

Search for a command to run...

Hectal
PHASE 9Advanced ~24 min· topic 3 of 8

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: and mailto:.
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)".

safe-href.jswhole filejs
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.

Sandbox generated pages, show generated code as textdiagram
Rendering diagram…

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.

next.config.ts (shape of the header)whole filets
// 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. 1

    Allow a new protocol on purpose

    In the first example, change ALLOWED to ["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. 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 granted becomes 2. This is the combination the checker exists to catch. parent can read the frame still says false here, 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

Hostile replies, safe screen

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

Press Run to see the page.

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: []
Sandboxes and code as text

An empty `sandbox=""` is the strictest setting. Each token you add gives one permission back, so every token needs a reason.

Preview

Press Run to see the page.

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).

terminal
$ Console, the hostile reply
── what you'll see ──
hostile: scripts 1, frames 0, images 0, handlers 0
hrefs: []
script tag shown as visible text: false

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 for dangerouslySetInnerHTML, innerHTML, href={, src={ and srcDoc in 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. 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. 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. 3

    Where unsafe output does harm (the sinks). A sink is a place text can become code or a request. The main ones: innerHTML and React's dangerouslySetInnerHTML; a Markdown library with raw HTML turned on; href and src attributes (a javascript: link runs code when clicked); eval, new Function and setTimeout with a string; iframe srcdoc; and image src (a request to an outside address, Topic 9.4). React's normal {text} is safe: it escapes everything.

  4. 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 as javascript: to a browser, so a plain startsWith("http") test is easy to get wrong in both directions. (3) Add rel="noopener noreferrer" to links that open a new tab.

  5. 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. Never eval it, never insert it into a <script> tag. If a feature must run generated code (a playground), run it in a sandboxed iframe with no allow-same-origin, in a worker with no network, or on a server sandbox, and ask the user first.

  6. 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 empty sandbox attribute removes almost every permission: no scripts, no forms, no access to your page. Two traps: adding allow-scripts together with allow-same-origin lets the framed page reach into your page and remove its own sandbox, so never combine them for untrusted content; and allow-top-navigation lets it send the whole tab elsewhere.

  7. 7

    Detect. Write an audit function that looks at the rendered DOM for active content: script, iframe, object, embed, form, elements with on... attributes, and href values 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. 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

01

Why is a model's reply untrusted input even when it came from your own server?

02

Why should link checks parse the URL instead of testing a prefix?

03

What is wrong with sandbox="allow-scripts allow-same-origin" for a generated page?

04

How do you verify that your rendering is safe, rather than assume it?

Practice

01

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.

02

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.