Treat model output like any untrusted input and handle it for the parser that will consume it. Render plain answers with textContent or your framework’s default text binding. Render formatted answers from Markdown with raw HTML disabled, then sanitize with an allowlist, and send a CSP that blocks inline script and third-party images. Never execute generated SQL, shell commands or code: expose narrow operations whose values you bind and whose permission you check.
- Improper Output Handling is LLM10:2026. Its usual results are XSS, SSRF, SQL injection and code execution in whatever consumes the model’s text.
- Choose the defense by destination: HTML text, attributes, URLs, SQL values, shell arguments and file paths each need their own safe API.
- “No script ran” is not the test: an injected image or link can still send data to an attacker’s server.
- Parameter binding protects a value, not the statement. A bound
DELETEis still aDELETEthe user may not be allowed to run. - Structured output that validates against a JSON schema still carries untrusted values; check ranges and authorize the action separately.
| Setting | Value | Note | Source |
|---|---|---|---|
| Plain answers | textContent, or the framework’s default text binding such as {text} in React | — | ASVS v5.0.0-3.2.2 |
| Markdown renderer | markdown-it with html: false (its default) and linkify: false | Raw HTML in model output then stays visible text. | — |
| Sanitizer | DOMPurify with explicit ALLOWED_TAGS and ALLOWED_ATTR; no img unless images are a feature | — | ASVS v5.0.0-1.3.1 |
| Link schemes | https only; drop javascript:, data: and http: | — | — |
| Content-Security-Policy | script-src 'self' without 'unsafe-inline'; img-src 'self' | Defense in depth: it limits damage from a rendering mistake but does not define safe output. | — |
| Database and process access | Application-owned statements and argument lists; no generated SQL or shell strings | — | ASVS v5.0.0-1.2.5 |
- Render
<img src=https://example.test/p>in chat, preview, export and email and confirm no element is created and no request is made. - Render Markdown containing a
javascript:link and an image; confirm neither survives. - Check the page’s CSP header has no
'unsafe-inline'inscript-srcand limitsimg-srcto your own origins. - Pass a generated
DELETEto the database adapter and confirm it is rejected and no row changes. - Render ordinary text with quotes, accents and code samples and confirm it displays unchanged.
- Applies to
- markdown-it 15.x, with the html option left at false
- DOMPurify 3.x (example tested with 3.4.16)
Rendering or acting on model output in browsers, exports, databases and processes. Structured output validity does not confer permission or semantic safety.
- Part of
- Learning path: Secure an AI application (step 6 of 11). Workflows: Fix a finding, Secure a feature, Test an application
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
What you will learn
- Choose defenses according to the destination parser
- Explain why parameter binding cannot authorize generated SQL
- Test actual rendering and side effects with deterministic strings
Before you start
Application, platform and software security engineers
- Identify whether generated content becomes HTML, a URL, a command or a downstream API argument.
Refresh first: Prevent XSS at the output boundary
Go to the guided practiceModel output may be influenced by untrusted inputs, so it enters downstream systems with no special trust. The same string can be harmless when displayed as text and dangerous when interpreted as HTML, shell syntax, a database query or a filesystem path. The OWASP Top 10 for LLM Applications 2026 lists this as Improper Output Handling (LLM10:2026), and the Agentic Top 10 tracks the worst case, generated code that runs, as Unexpected Code Execution (ASI05:2026). Valid JSON does not make the values inside it safe or authorized.
Map every output destination: visible responses, generated reports, downloaded files, image and link rendering, email templates, database operations and agent tools. Safety checks must match each destination’s interpretation rather than applying one generic sanitizer to all output.
Consider the generated value <img src="https://outside.example.test/pixel?case=7">. In a text node it displays as characters. Assigned to innerHTML it creates an image element, and the browser fetches the URL even though no JavaScript runs. The security property for a text-only chat is therefore not “no script executed”; it is “no generated elements and no automatic external requests”. Inspect the DOM and network behavior of the final renderer.
Rich text is a separate feature contract. The code below renders plain answers with textContent. For formatted answers it parses Markdown with markdown-it’s html option off, so raw HTML stays visible text, then passes the result through DOMPurify with an allowlist that has no img element and accepts only https links. Exports and previews often use a different library, so test them separately: a safe chat renderer does not protect an HTML download.
For a database, use an application-owned statement and bound values. Binding a value in WHERE title = ? prevents that value from becoming SQL syntax. It does not make a model-authored statement safe: a perfectly parameterized DELETE FROM invoices WHERE tenant_id = ? may still be an unauthorized operation. Expose a narrow search function with current resource authorization rather than a generic SQL tool.
For process execution, avoiding a shell removes metacharacter interpretation but does not validate the invoked program’s arguments. A filename beginning with a dash can be read as an option unless the argument contract prevents it. Prefer a narrow operation with a fixed executable and explicit argument handling. Authorization and resource bounds still apply after syntax is constrained.
import DOMPurify from 'dompurify';
import MarkdownIt from 'markdown-it';
// Plain answers: the browser inserts characters, never markup.
export function renderPlain(element, modelText) {
element.textContent = modelText;
}
// Formatted answers: Markdown with raw HTML disabled, then an allowlist sanitizer.
const markdown = new MarkdownIt({html: false, linkify: false});
export function renderRich(element, modelText) {
element.innerHTML = DOMPurify.sanitize(markdown.render(modelText), {
ALLOWED_TAGS: [
'p',
'em',
'strong',
'code',
'pre',
'ul',
'ol',
'li',
'blockquote',
'a',
],
ALLOWED_ATTR: ['href'],
ALLOWED_URI_REGEXP: /^https:\/\//i, // no javascript:, data: or plain http: links
});
}Use the framework’s normal text rendering for plain output: textContent in the DOM, {text} in React and {{ text }} in Vue all insert characters, not markup. Avoid dangerouslySetInnerHTML, v-html and innerHTML for model text. If a feature needs rich content, render Markdown with raw HTML disabled and sanitize the result with a maintained allowlist sanitizer such as DOMPurify, configured as in /domains/appsec/browser-security/rich-text-dom-security/. Remote images and automatic link previews create network requests that can carry data out, so allow them only when the product needs them.
Keep a Content Security Policy as defense in depth, not as permission to inject generated markup: script-src 'self' without 'unsafe-inline' stops injected script, and img-src 'self' stops pixel requests to other hosts. A policy does not stop deceptive links or every unwanted request, so test the final rendered document and browser requests, including exports and preview components that use a different renderer.
For database access, expose known operations with bound values and resource authorization rather than executing generated SQL. For process execution, use a narrow API with a fixed executable and an argument list, never a shell string. For structured output, validate the response against a schema (JSON Schema, Pydantic or Zod) to reject extra fields, wrong types and excessive sizes, then authorize the requested action separately: a valid object can still ask for something the user may not do. /domains/ai-emerging/llm-security/calling-llm-apis-safely/ shows the schema check on a real provider call in Python and TypeScript.
For generated paths or URLs, constrain the resource namespace and destination through the application’s normal file and network defenses. Avoid a recovery path that asks the model to “fix” a rejected action and then executes it without the same checks.
When running generated code is the feature itself, as in a data-analysis tool or a coding agent, run it in a disposable sandbox with no network, no secrets, no host mounts and hard limits, and keep its side effects behind an approval: /domains/ai-emerging/agent-security/agent-code-sandboxing/ covers the isolation options, the container flags and the escape tests.
Test the output boundary with controlled strings instead of hoping a live model emits a particular attack. Include ordinary punctuation, code examples and non-ASCII text so the repair preserves useful content. Verify DOM behavior, network activity and database or filesystem side effects, not just the transformed string, and record the renderer and library versions with the evidence. The output-rendering lab (/labs/output-rendering/) repairs a renderer that inserts model text as markup.
Removing a small list of substrings is fragile because equivalent syntax and different output contexts behave differently. Use the destination’s normal safe construction APIs and a reviewed rich-content policy where necessary. Escaping for HTML text is not escaping for an inline script, URL or shell; /domains/appsec/secure-coding/xss-output-encoding/ covers each context.
A content security policy can reduce the impact of some rendering mistakes, but it does not define which links are truthful or which data may be sent. Test the feature’s intended behavior directly; do not treat the absence of one CSP report as proof that no unwanted effect occurred.
Each row is a supplied output or operation and the result its destination must produce. The output-rendering lab on this page has you repair a renderer until the same cases pass.
| Case | Expected result | What it establishes |
|---|---|---|
| O1 in text renderer | All punctuation and Unicode retained | Useful ordinary content preserved |
| O2 in text renderer | Literal characters; no img element or request | Output is not interpreted as HTML |
Parameterized generated DELETE | Reject operation | Binding does not grant delete permission |
| Known search statement with bound value and authorization | Allow permitted search | Narrow operation remains useful |
Guided practice
Review the two renderer implementations and database proposal against the supplied contracts. This is a boundary analysis, not a request to contact the example destination.
Supplied scenario material
Text-only renderer contract: display generated strings as text; no generated elements or automatic external requests.
O1: Hello, O'Connor — café
O2: <img src="https://outside.example.test/pixel?case=7">
Database contract: allowed operation is "search invoices visible to the authenticated user". Proposed generated statement: DELETE FROM invoices WHERE tenant_id = ? with bound value North.
Renderer A assigns output to element.innerHTML.
Renderer B assigns output to element.textContent.
Observed A/O2: img element created; external image request attempted; no script runs.
Observed B/O2: exact string displayed; no new element/request.
Database adapter binds North to the generated DELETE statement and executes it.
Developer claims both cases are safe because no script ran and SQL parameters were bound.1. Evaluate the rendering claim
Decide A/O2 and B/O2 using every clause of the text-only contract.
Expected result and reasoning
A fails: it creates an element and attempts an external request. No script execution is not enough. B satisfies the supplied text-only observations by retaining the string as text.
2. Preserve legitimate output
Explain what should happen to O1 and why stripping punctuation is a poor repair.
Expected result and reasoning
O1 should display unchanged, including the apostrophe, dash and accented character. Safe text construction does not require destroying ordinary user content; stripping it would reduce usefulness without establishing the correct boundary.
3. Evaluate the SQL claim
Explain why the bound
DELETEis still forbidden and propose the narrow operation.Expected result and reasoning
Binding protects the value’s SQL interpretation, not the operation’s authority. Reject arbitrary statements. Use an application-owned search query with bound search terms and the authenticated user’s permitted invoice scope.
4. Test the full delivery path
Name concrete observations for chat, preview and export.
Expected result and reasoning
For each renderer, inspect generated elements and network requests using O1/O2, and retain ordinary formatting tests. For the database boundary, inspect rows/effects and confirm the allowed search works while
DELETEproposals create no mutation.
All outputs and observations are supplied. Do not contact the example destination; the exercise covers one renderer pair and one database operation.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Repair the code
XSS lab: Render a model’s answer as text instead of active markup
Run a local project
Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.
Run model-generated Python in a locked-down container
Repair an agent's run-Python tool until it refuses to fall back to the host, shares no folder with it, runs each job offline with memory, CPU and process limits, kills the container at the deadline and checks the result envelope the code could have forged.
Needs: The live tests need Docker with Linux containers and internet access once to pull the Python image (about 45 MB compressed). The offline tests need only Python.
In the extracted folder, run:
python3 -m unittest -v test_sandbox.py
docker pull python:3.14.8-slim-trixie@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170
python3 -m unittest -v test_live.py13 regression tests · The starter intentionally fails some cases.
Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image.
The offline tests use a stand-in for the docker CLI. The 12 live tests ran in Docker 29.4.3 with python:3.14.8-slim-trixie pinned by digest: network, host environment and files, a link to a host file, a bounded fork bomb, 1 GiB of memory, CPU share, a deadline and an output flood, plus three legitimate jobs. Containers share the host kernel; gVisor and microVMs were not tested.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- OWASP Top 10 for LLM Applications 2026 (LLM10 Improper Output Handling). 3 August 2026.
- OWASP LLM05:2025 Improper Output Handling (2025 list; LLM10 in 2026)
- OWASP Cross Site Scripting Prevention Cheat Sheet
- DOMPurify README: Can I configure DOMPurify?
- markdown-it API: MarkdownItOptions (html defaults to false). v15.0.2.
- OWASP AI Agent Security Cheat Sheet
Reference information
- SDLC phase
- DesignDevelopment
- ASVS 5.0.0 requirements
- v5.0.0-1.2.4 · v5.0.0-1.2.5 · v5.0.0-1.3.1 · v5.0.0-3.2.2