OWASP LLM05 (Improper Output Handling) is the vulnerability that turns a language problem into a classic web exploit.
Tier: Core · Workflow area: LLM attack surface & defense
The model's output is untrusted the moment it can be influenced by untrusted input — and if your app pipes that output into a browser, a shell, a SQL string, or an HTTP client without treating it as tainted, you have handed an attacker XSS, SSRF, SQL injection, or remote code execution through the model. This playbook treats every LLM response as attacker-controlled data and hardens the sinks it flows into, in your own app.
Threat, briefly. The model, steered by a prompt injection, emits <img src=x onerror="fetch('/admin/delete')"> and your frontend renders it as trusted HTML — stored XSS. Or it emits a SQL fragment you string-concatenate, or a URL your server fetches (SSRF), or shell text your agent runs (RCE). The model is just the delivery vehicle; the bug is the unescaped sink.
Map every sink first. Grep the codebase for where model output leaves the LLM boundary: template rendering, innerHTML, dangerouslySetInnerHTML, eval, subprocess, DB query builders, HTTP clients, file writes. You cannot defend sinks you haven't listed.
Encode at the boundary, per sink type. The fix is the same discipline as any untrusted data: context-appropriate output encoding.
import html
# HTML sink: escape, never render model text as raw markup
safe = html.escape(model_output)
# SQL sink: parameterize, never concatenate
cur.execute("SELECT * FROM notes WHERE body = %s", (model_output,))
# Shell sink: no shell=True, pass an arg vector, allow-list the command
subprocess.run(["ripgrep", "--", model_output], shell=False)
Render markdown safely. If you must render model markdown, run it through a sanitizer that strips scripts, event handlers, and dangerous URI schemes, and disable raw HTML passthrough.
import bleach
clean = bleach.clean(
rendered_html,
tags=["p", "ul", "ol", "li", "code", "pre", "strong", "em", "a"],
attributes={"a": ["href"]},
protocols=["http", "https"], # drop javascript:, data:
strip=True,
)
Constrain the shape of output. Where the output feeds a machine sink, don't accept free text — force a schema so anything outside it is rejected before it reaches the sink.
from pydantic import BaseModel
class DbFilter(BaseModel):
status: str # validated against an allow-list downstream
limit: int
# response = client.messages.parse(..., output_format=DbFilter)
Test the sink against your local app. On a local, deliberately vulnerable copy, inject a payload that steers the model to emit an XSS or SSRF string, then confirm the encoded/sanitized build neutralizes it.
# local target only: prove the sink is closed
curl -s localhost:8080/summarize \
-d '{"note":"Summarize: <img src=x onerror=alert(1)>"}' | grep -o '<img'
# defended build: no <img passes through; grep returns nothing
Never let LLM output reach a shell, eval, or raw-HTML render on the assumption the model "wouldn't say that." Under injection it will say exactly that.
The defense holds when the exploit fires on the vulnerable build and dies on the patched one. For XSS: the payload string appears escaped (<img) in the DOM and no script executes — check with a headless browser assertion, not by eye. For SSRF: the model-supplied URL is rejected by an egress allow-list and your server never makes the outbound request (confirm in the network log). For RCE: the command runs through an arg vector against an allow-listed binary, so shell metacharacters are inert. Keep one regression test per sink so a refactor that reintroduces innerHTML fails CI.
javascript: and data: URIs in "safe" markdown links — a classic bypass. Restrict protocols explicitly.bleach, DOMPurify-class) — vendor-neutral HTML/markdown sanitization at the render boundary.innerHTML, eval, subprocess, or unparameterized queries across the codebase.For client engagements, the sink review is the same but the code is theirs — get scope approval before pointing any SAST tool or assistant at a client repo. If public tools are barred, run the sink grep and the sanitizer locally, and describe the vulnerable pattern abstractly (no client hostnames, schema names, or URLs) to any approved assistant helping you reason about fixes. The mitigations — output encoding, parameterization, egress allow-lists, sandboxing — are framework- and vendor-independent and work fully offline, so the client's data never needs to leave their environment to build the defense.