Lesson 1 of 6 · 7 min read
Insecure Output Handling
In this lesson
TL;DR: Treat model output exactly as you would treat input from an anonymous user, because an attacker who can put text into the model's context can shape it. Rendered as HTML, it can carry script (XSS). Concatenated into SQL, it is SQL injection. Passed to a shell,
evalor a template engine, it is code execution. Used as a URL your server fetches, it is SSRF. The controls are the classic ones, applied at each sink: context-aware encoding, parameterised queries, allowlisted commands with structured arguments, schema-validated tool calls, a content security policy and egress limits.
Before you read
Pick an answer before you read further. The lesson below explains it, and guessing first, right or wrong, helps the explanation stay with you.
1A developer says: 'The model writes the SQL, but the model is ours and only our users talk to it, so the query is trusted.' What is wrong with that reasoning?
Why output is untrusted
The model writes what its context leads it to write. That context contains the user's message and possibly retrieved documents, web pages and tool results written by third parties. Any of them can influence output through prompt injection, and some do not even need injection: a user can simply ask for the output they want. So model output is not "our" data in the security sense; it is attacker-influenced data that happens to pass through our model.
The bugs that follow are not new. They are the classic injection vulnerabilities, reached through a new path.
Sinks and what goes wrong
| Where output goes | What an attacker can do | Control |
|---|---|---|
| Rendered as HTML or markdown in a browser | Script execution (XSS); tracking or exfiltration through image and link URLs | Encode for HTML context; sanitise markdown; CSP; no auto-loading of external images |
| Built into SQL | Read or modify data (SQL injection) | Parameterised queries; generate only parameters, not SQL; read-only role and views |
Passed to a shell or eval | Remote code execution | Never; use allowlisted operations with structured arguments, or run in a sandbox |
| Rendered through a template engine | Server-side template injection | Treat output as data, never as template source |
| Used as a URL the server fetches | SSRF to internal services and cloud metadata | Allowlist destinations; egress proxy; block private ranges |
| Used as a file path | Path traversal | Resolve and confine to an allowed directory |
| Parsed as a tool call | Unauthorised or malformed actions | Strict schema validation and authorization in the tool backend |
| Sent in email or messages | Phishing from your own domain | Restrict recipients and content; label AI-generated text |
Context
User + docs + tools
Model
Writes output
Output handling
Encode, validate
Browser
HTML, markdown
Also in the picture
Database
- Queries
Shell
- Commands
URL fetcher
- Server-side
Influence enters through the context
Users and third-party content shape what the model writes.
When model output reached exec: three CVEs
This is not a theoretical class. Early LLM frameworks shipped features whose design was "the model writes code and we run it", and each became a remote code execution vulnerability once someone asked what an attacker could put in the model's context.
| CVE | Component | What happened |
|---|---|---|
| CVE-2023-29374 | LangChain LLMMathChain, through 0.0.131 | The chain ran model-written Python with exec to answer maths questions, so a prompt could make it run arbitrary code |
| CVE-2023-36258 | LangChain PALChain, before 0.0.236 | Program-aided reasoning executed generated Python, reachable through exec, eval or os.system |
| CVE-2024-5565 | Vanna.AI ask with chart output on (the default) | The model generated Python plotting code that the library executed; prompt injection replaced the chart code with arbitrary Python (CVSS 8.1) |
The pattern is the same in all three: the model call is a source of untrusted data, exec is a code-execution sink, and nothing in between constrained what could flow from one to the other. The fix in each case is the same too: stop executing generated code in the application process.
Worked example: reviewing a text-to-chart feature
A product lets users ask questions about their sales data and get a chart, in the style of the Vanna case. The code generates SQL and Python plotting code with a model and runs both. A source-to-sink review goes like this:
- Mark the source. Every model call is a taint source. Its input includes the user's question and any text the model reads from the database, such as product names that customers can edit.
- Find the sinks. Generated Python goes to
exec: code injection. Generated SQL goes to the database driver: SQL injection, with whatever privileges the connection has. - Fix the chart sink by removing it. Have the model return a chart specification instead of code: a chart type from an enum, column names checked against the result schema, an aggregation from an enum. Fixed plotting code renders the specification.
- If generated code really is the product, run it in a sandbox with no network, no credentials and strict limits on time and memory, never in the web process. See sandboxing untrusted code.
- Constrain the SQL leg as described below: ideally the model produces parameters for fixed queries; if it must write SQL, run it with a read-only role over views limited to the user's own rows.
Model writes plotting code; app runs it
Question plus product names from the DB
Model writes Python
attacker-shaped
exec() in the web process
Outcome, unsafe: Remote code execution
The same shape as the LangChain and Vanna CVEs.
Model returns a chart spec; fixed code draws it
Question plus product names from the DB
Model returns type, columns, aggregation
enums and schema check
Fixed plotting code renders it
Outcome, safe: No code sink
The worst outcome is a wrong or ugly chart.
Generated code as the interface
Anything that can influence the model can write the code the application runs.
Structured output is a format guarantee
Structured outputs and constrained decoding make the model's response match a JSON schema. That removes a whole class of parsing bugs, and it is worth using. It does not make the values safe. A schema-valid {"image_url": "..."} can hold an attacker's URL; a valid order_id can name someone else's order; a valid sql string is still SQL. After the shape is guaranteed, validate the values: URLs against an allowlist, identifiers against what this user may access, free text encoded for wherever it is going.
Text-to-SQL when it is the product
Some products exist to answer questions with generated SQL. Then the goal is to make the worst possible query harmless.
- 1
1 · Read-only database role
Never the application's main credentials
- No write, DDL or admin rights
- Separate from the app's own connection
Bounds what any query can change
- 2
2 · Views and row-level security
Only this user's or tenant's rows
- Tenant set by the server, not the model
- Sensitive columns excluded
Bounds what any query can see
- 3
3 · Statement checks
Parse before running
- A single read statement only
- Allowlisted tables and functions
Rejects anything else
- 4
4 · Resource limits
Timeouts and result caps
- Statement timeout
- Row and byte limits on results
Bounds cost and denial of service
- 5
5 · Audit
Question, query, user, rows returned
- Replay and review
- Alert on rejected statements
Shows misuse and probing
Privilege and visibility
The role and row-level security decide the worst case regardless of what SQL arrives.
The tenant or user filter must be set by the server from the session, for example as a session variable that a row-level security policy reads, never as a value the model writes into the query.
Rendering: sanitise the HTML you produce
Markdown is not one syntax. Links and images can be written inline, in reference style with the URL defined elsewhere, as autolinks, or as raw HTML if the renderer allows it. The EchoLeak vulnerability in Microsoft 365 Copilot used reference-style markdown to get past redaction that handled only the inline form. The robust approach is to render markdown to HTML first and then sanitise the resulting HTML with an allowlist of elements and attributes, so every syntax is checked in one form. Do not auto-load images or link previews from model output; the channels and the vendor fixes are covered in data exfiltration channels.
Patterns that make output safe by construction
- Ask for data, not code. Have the model produce structured values (an order ID, a date range, a filter enum) validated against a schema, and let your code build the query or command. A model that never writes SQL cannot write malicious SQL.
- Least privilege at the sink. If generated queries are unavoidable, run them on a read-only role against views limited to what the user may see, with timeouts and row limits.
- No general interpreters. Replace "run this shell command" with a small set of named operations. Where code execution is the product, isolate it in a sandbox (see sandboxing untrusted code).
- Render safely by default. Encode text for its context, sanitise markdown with an allowlist, set a strict content security policy, and do not load images or previews from arbitrary origins (see data exfiltration channels).
- Validate tool calls against schemas and authorize them in the backend.
Misconceptions interviewers listen for
- "Structured output makes it safe." It guarantees format, not that a URL, ID or string is acceptable.
- "We sanitise the markdown." Which syntaxes? Sanitise the rendered HTML instead.
- "A content security policy solves image exfiltration." Allowed first-party proxies and redirectors can still carry data.
- "Our model is aligned, so it will not emit script." A user can simply ask, and injected text can arrange it.
What if · interview follow-ups
Hold your answer as the constraints change
You are reviewing an analytics assistant that turns users' questions into SQL, draws charts and renders its answers as markdown in the browser. The interviewer walks you from the model call to each sink.
Start here
Why is this model's output untrusted when it is your own model?
Who wrote the text in its context?
The output is shaped by everything in the context: the user's question, and database text such as product names that customers can edit. Any of those authors can influence it, and the user can simply ask for what they want. So it is attacker-influenced data that passes through our model, and each sink needs its usual control.
Carry it forwardTreat model output as input from every author in the context.
Weak answerTrusting it because the model is ours.
Constraint changes
The chart feature runs model-written Python. What do you change?
Remove the sink or contain it.
Remove the sink: have the model return a chart specification with enumerated types, schema-checked columns and enumerated aggregations, drawn by fixed code. That is the lesson of the LangChain and Vanna CVEs. If arbitrary code is truly the product, run it in a sandbox with no network and no credentials, outside the web process.
Carry it forwardReplace generated code with validated data wherever possible.
Weak answerFiltering the generated code for dangerous functions.
Constraint changes
The team says strict JSON schemas make the output safe. Do you agree?
Shape versus values.
They guarantee shape, which removes parsing bugs, but not that values are acceptable. A valid URL field can point at an attacker's server and a valid ID can name another user's record. Validate values after parsing: URLs against an allowlist, IDs against the user's permissions, text encoded for its destination.
Carry it forwardSchema validation checks shape; value validation and authorization still follow.
Weak answerAgreeing that schema-valid means safe.
Constraint changes
Generated SQL is the core of the product. How do you bound it?
Privilege, visibility, shape, cost.
Run it with a read-only role, over views and row-level security that the server scopes to the user's tenant from the session; parse each statement and allow only a single read against allowlisted tables; set a statement timeout and result caps; log question, query and rows returned. Never use the application's main credentials.
Carry it forwardMake the worst possible query harmless rather than trying to predict queries.
Weak answerAsking the model to only write SELECT statements.
Constraint changes
Answers are rendered as markdown. What goes wrong, and where do you sanitise?
How many ways can markdown express a link or image?
Script injection if raw HTML passes through, and data exfiltration through image or link URLs that encode context, including reference-style links that simple redaction misses, as in EchoLeak. Render to HTML, then sanitise the HTML with an allowlist, do not auto-load external images, and restrict link destinations.
Carry it forwardSanitise the rendered HTML, not the markdown source.
Weak answerWriting regular expressions for markdown image syntax.
Now defend it
As the defender, what would you log to detect abuse of this assistant's sinks?
Generated SQL with the user and rows returned, statements rejected by the parser, chart specifications that fail validation, sandbox executions with their resource use and any blocked network attempts, and sanitiser removals or blocked URLs in rendered answers.
How this shows up in interviews
Expect both conceptual and practical versions: "why is LLM output untrusted?" and a code review where model output flows into subprocess, a SQL string or innerHTML. In a code review, trace from source (the model call) to sink, name the vulnerability class, and fix it at the sink with the classic control. Saying "we'll tell the model not to output dangerous content" is the answer that fails.
Say it in 30 seconds: "Model output is shaped by everything in its context, including attacker-controlled text, so I treat it like anonymous user input. If it reaches a browser it can be XSS, SQL it can be injection, a shell or eval it's code execution, a URL fetcher it's SSRF. The fixes are the classic ones at each sink: encoding, parameterised queries, structured operations instead of interpreters, schema validation for tool calls, CSP and egress allowlists. Best of all, have the model produce validated data and let code build the query."
Check yourself
Say or write your answer first, then reveal. Explaining it from memory, in your own words, is the skill an interviewer is checking.
1An analytics assistant turns questions into SQL. Name three controls that limit damage even if the model writes malicious SQL.
2What did CVE-2023-29374, CVE-2023-36258 and CVE-2024-5565 have in common?
3Why sanitise rendered HTML rather than the markdown the model produced?