Controls that actually hold
Every technique above shares one root cause: the application trusted model output before a downstream interpreter consumed it. The model is a non-deterministic, attacker-influenceable text source - so its output is untrusted input to whatever sink receives it. Durable defence is the same posture you already apply to user input: encode for the destination, validate against a schema or allowlist, and gate any action in code.
One root cause under every lesson in this series
Every weakness in this series came from the same decision. The application took text a model wrote and passed it to something that interprets text: a markdown renderer, a browser, a database engine, a Python interpreter, a spreadsheet, an HTTP client, a workflow that moves money. A sink is the place where that text is interpreted. OWASP files the failure under LLM10:2026, Improper Output Handling.
A large language model (LLM) is a text source an attacker can steer. The attacker does not need to type into the chat. Retrieved documents, support tickets, forum posts and uploaded files land in the same context and shape what comes out. The output is also non-deterministic, so the same input can produce different text on the next run. Treat model output the way you treat user input: untrusted until code has handled it for the place it is going.
That posture has four parts, and the rest of this lesson applies them sink by sink.
- Encode or bind for the sink. The safe form of a string depends on what reads it next. HTML escaping does nothing for a SQL query or a spreadsheet cell, so no single sanitize step covers every sink.
- Validate structure. When the app reads output as JSON, a URL or a file path, check it against a strict schema or an allowlist before anything uses it.
- Gate the effect in code. Between a parsed decision and any side effect, a deterministic check decides whether the action is allowed, using data the model cannot change.
- Keep secrets out of reach. A token that is never in the model's context cannot be written into an image URL, a query or a formula, so a control that slips has nothing to carry.
Two quick fixes do not hold. A rule in the system prompt sits in the same context as the attacker's text, and the model weighs both. Each lab in this lesson already has one: Relaycast is told not to restate the access token, Triage Assist that a customer's own words never authorize a refund, and Lumen Reporting's planner to return a category and nothing else. An output blocklist lists the forms of attack its author thought of, and every sink accepts more forms than that.
A rule in the system prompt. A filter that strips one markdown marker, removes quotes or blocks a keyword. Cleaning the input and then trusting what the model returns. Each acts on wording, and the attacker controls the wording.
Text rendering, bound parameters, an argument list in place of a shell string, a first-character check on every cell, a host allowlist, a strict schema, an authorization check against server data. Each acts on structure or on trusted state.
Rendered markdown and HTML: the renderer decides what loads
Lessons 1 and 2 covered this sink. Markdown has two network primitives: an image loads its URL as soon as the reply is displayed, and a link loads its URL on a click. HTML adds markup that runs code in the page. The controls sit in the renderer, on every path that displays model output.
- Render as text by default. In the browser that means
textContentor the framework's default escaping. Never pass model text to a raw-HTML property or a helper that marks a string as trusted HTML. - Sanitize against an allowlist when you need formatting. Use a sanitizer such as
DOMPurifywith few allowed tags and a hook that checks every URL attribute against your allowed schemes and hosts. - Do not fetch model-chosen URLs. Strip images from model output, or serve them through an image proxy you control that fetches only approved hosts. A link-preview service that builds cards on the server is a renderer too.
- Add a Content Security Policy (CSP). This response header limits which hosts the page may load images and scripts from, so a tag the sanitizer missed reaches less.
In March 2026, Discourse's AI triage automation rendered model output into the moderator Review Queue as trusted HTML. Forum content steered the triage model into emitting markup that ran in the session of the moderator who opened the item: stored cross-site scripting (XSS) with a model as the author. The fix was to HTML-escape all model-generated content. See Discourse AI triage stored XSS.
In June 2025, Aim Labs disclosed EchoLeak, a zero-click flaw in Microsoft 365 Copilot. A crafted email was pulled into context by retrieval when the user asked an unrelated question, and Copilot leaked in-scope data through URLs the environment already trusted. See EchoLeak in Microsoft 365 Copilot. A host allowlist checks where a request goes, not what it carries, so pair it with the other two controls: no automatic fetch of model-built URLs and no secrets in the context.
Generated queries and code: bind values, review code
Lesson 3 covered the database and the deploy pipeline as sinks. The control depends on how much of the query the model writes.
When the model extracts a value, bind it with a parameterized query. The placeholder travels separately from the query text, so the value is never parsed as SQL, whatever characters it holds. Then validate the value against what the field allows. If a report filter has five legal categories, reject anything else before the query runs. Bind values read back from your own database the same way.
When the model writes the whole query, binding is not available. Run the query under a database role that can read only the tables the feature needs, with no write access, a row limit and a timeout. Keep secrets in a store that role cannot reach.
Stripping quotes or blocking keywords fails because SQL can express the same thing many ways. Generated code gets the review a human change gets, a secret scan before commit, and credentials from environment variables or a secret manager.
Execution sinks: never hand output to an interpreter as code
Lesson 4 covered sinks that run model output on the server: a template engine, Python's eval and exec, an operating-system shell, an unsafe YAML loader. The control has the same shape at each one. Keep the model's text in the role of data.
- Expressions. Use a non-executing evaluator that understands only the grammar you need, never eval or exec.
- Commands. Call
subprocess.runwith an argument list and no shell. Pick the program from an allowlist in code, and let the model choose only among fixed options. - YAML. Load with
yaml.safe_load, which builds plain data types and no live objects. - Templates. Pass model text into a fixed template as a value, so the engine never parses it as template source. A sandboxed or logic-less engine limits what slips through.
- The process. Run the sink with no secrets in its environment and no network route it does not need.
LangChain's LLMMathChain is the plain case. In April 2023 it passed the model's arithmetic answer to Python's exec and eval, and a crafted prompt made the model emit code that ran on the host. It was tracked as CVE-2023-29374, rated 9.8 out of 10 on the Common Vulnerability Scoring System (CVSS), and fixed by switching evaluation to numexpr. See LangChain LLMMathChain code execution. Blocklists of dangerous functions or shell characters fail here because an interpreter offers many routes to the same capability.
Spreadsheet export: check the first character of every cell
Lesson 5 moved the interpreter off your server. A CSV (comma-separated values) file opens in a spreadsheet application on an analyst's machine, or in a pipeline that recalculates it, and a cell that begins with an equals sign, a plus sign, a minus sign, an at sign, a tab or a carriage return is read as a formula.
The control lives in the export code. For every cell, written by the model or not, check the first character. If it is one of those leaders, prefix a single apostrophe so the application shows the value as text, and quote the field. If the report needs a computed column, build its formula in code from a fixed set. The model never writes one.
Filtering one function name misses the next function, and filtering the equals sign misses the other leaders.
URLs and structured output: parse, allowlist, reject the unknown
Lesson 6 covered output the app reads as structure. Each kind needs a parser and a check that run before anything acts on the value.
- Links shown to users. Parse the URL and compare its scheme and host to an exact allowlist, never a substring or prefix match. Better still, have the model pick a destination id and let code build the URL, which removes the open redirect.
- URLs the server fetches. Apply the same allowlist, resolve the host, and refuse private, loopback and link-local addresses, including the cloud instance-metadata address. Re-check after every redirect. Without this, an unfurl or preview feature becomes server-side request forgery (SSRF).
- XML the model composes. Parse it with external entities and document type definitions turned off. In Python, use
defusedxml. This closes XML external entity (XXE) injection. - JSON. Decode it into a typed model with a strict schema. Reject unknown fields, check every type and allowed value, and give each field one purpose.
Schema validation stops smuggled structure. A record that passes the schema with an approval field set to true is still a model's opinion, formed from text an attacker may have written, so the decision to act belongs to the next control.
Actions and file paths: the model proposes, code decides
Lesson 7 covered output that triggers an effect. The parse step is never authorization. Put a deterministic gate between the parsed output and the act, and have it re-derive permission from data the model cannot touch: the order's own eligibility record, the caller's role, the policy limit. The model's field can be one input to that check, and the check can overrule it.
- Consequential effects. Store credit, payments, deploys and irreversible workflow steps get a human approval step that shows what will happen.
- Email. Render drafts as text in the preview. A send happens only on the user's confirmation.
- File paths. Let the model choose a document id from a list your code owns, and map the id to a path in code. If you must accept a name, resolve it with
os.path.realpathand confirm the result sits inside the base directory before opening it. Writes go to a directory the app neither serves nor runs.
Keeping secrets out of reach applies across every sink. Relaycast holds a privileged access token in the context that composes its posts, and Lumen Reporting keeps a finance key in the database its report query reads. Remove the secret, and a control that fails elsewhere leaves nothing to take.
Prove the fix by replaying the attack
Each lab runs as Attack, Defend, Verify, and the loop works on real systems too. Reproduce the bug and save the input along with the evidence it left at the sink. Add the control. Run the same input again, with three checks:
- Baseline. With the control off, the attack must fire. If it does not fire, a later pass means nothing.
- Security. With the control on, the same input no longer reaches the sink. Then try a variant with the same intent and different wording. A blocklist written from your first input will pass the first replay and fail the second.
- Utility. Ordinary use still works: a normal post, a normal report, a legitimate refund request routed for review.
Read the evidence at the sink. The outbound request log, the query that ran and the record the action changed tell you whether a control held. A clean reply tells you little, because a model that declined once may comply on the next run.
The three labs below are attack labs that you use here to validate a fix. Attack ends in Submit Evidence. Defend offers a short list of controls, and not every option holds. If the attack stalls, use the lab's own hints.
Rendered-Output Exfiltration via a Link Preview (Advanced). Relaycast composes a post for the Halcyon Media #ops channel from each submitted work item, with a privileged access token in its context. You never see the post. You see the outbound-request log of the channel's link-preview service, which fetches URLs from the rendered post on the server. Your attack shows the token can reach that log. At Verify the attack runs again with your chosen control switched on. Ask of each option whether it stops the fetch or only one way of writing a URL.
SQL Injection via a Model-Extracted Field (Advanced). Lumen Reporting has a small model extract one expense category from an analyst's question, builds the report query around that value, runs it against a real SQLite ledger and prints the SQL. Your attack shows the extracted value can change the query's structure. This lab grades the control you choose without a replay, so replay it in your head: under the control, the input that worked should change only which rows match.
Structured Output Drives a Privileged Action (Advanced). At Harborline Support Desk, Triage Assist turns each customer ticket into a JSON triage record, and the backend issues store credit on the strength of one boolean field in it. The scenario ticket concerns a clearance final-sale order that policy says needs human review. Your attack shows ticket content can make the record approve the credit. At Verify your ticket runs again with the control on. Judge each option by where the approval comes from: the record the model wrote, or the order's own data on the server.
What you should be able to do now. For any sink in this series, name the control that holds there and why a prompt rule or blocklist at that point fails. Then Complete Lab for Relaycast, Lumen Reporting and Triage Assist: reproduce the attack, then choose the control that acts at the sink and test it against the input that worked.
One root cause, one posture: model output is untrusted input to whatever sink receives it - treat it exactly like user input.
Encode for the specific sink: text-render markdown, allowlist-sanitize HTML, parameterize SQL, escape CSV leaders, allowlist URLs - no single sanitize covers them all.
Validate structured output against a strict schema, reject unknown fields, and bind each field to its purpose before it drives any decision.
Gate every side effect in code, and keep secrets out of context - two independent lines of defence, on both the effect and the payload.
What is improper output handling in LLM applications?
Improper output handling is the weakness OWASP lists as LLM10:2026. It happens when an application passes text a language model wrote to something that interprets it, such as a browser, a database, a shell, a spreadsheet or a workflow, without validating or encoding it first. An attacker can steer model output through prompts, documents or tickets, so the result is familiar web bugs with a new source: cross-site scripting, SQL injection, code execution, request forgery and unauthorized actions.
How do you prevent improper output handling in an LLM app?
Treat model output as untrusted input and handle it for the sink that consumes it. Render it as text or sanitize HTML against an allowlist, bind it as a query parameter, keep it away from eval and shell strings, escape spreadsheet formula characters, check URLs against a host allowlist, and validate JSON against a strict schema. Put an authorization check in code before any action, and keep secrets out of the model's context so a missed control has nothing to leak.
Why don't system prompt rules stop insecure LLM output?
A system prompt rule is text in the same context as the attacker's text, and the model weighs both when it writes a reply. Content from a retrieved document, a ticket or a work item can outweigh the rule. The output is also non-deterministic, so a rule that held in testing can fail on the next run. The control that holds sits in code at the sink, where the wording of the input has no effect on it.
How do you test that an LLM output-handling fix works?
Replay the attack. Keep the input that reached the sink and confirm it still fires with the control off. Send it again with the control on and check the sink itself, such as the outbound request log, the query that ran or the record an action changed, instead of the model's reply. Then replay a variant with the same intent and different wording, because a blocklist built from the first input will pass the first replay. Finally confirm that legitimate use still works.
© 2026 GenAI Security Lab. All rights reserved. You may read, quote, and link to this material with attribution. Copying, republishing, redistribution, resale, or use to train models or build competing products is prohibited without prior written permission.