Improper Output Handling

By Syncfusion®, Inc. · Last updated

A review runs a script in the next reader's browser, and a report request leaks every customer's rows. Encode what the chat renders and replace model-written SQL with a validated filter and parameters.

Improper Output Handling

Someone posted a review of the monitor arm last night. It looks like an ordinary review, plus a short <script> tag at the end. This morning Ana asks the agent to quote the monitor arm reviews. Replay it.

The model did exactly what it was asked: it copied the reviews into its reply. The chat window then rendered that reply as HTML, and the script ran in Ana's browser with Ana's session. The attacker never talked to the agent and never saw Ana. They wrote one review.

The model was not tricked here. The bug is in what the app did with the model's output.

TL;DR

  • Improper output handling (LLM05) is when an app passes model output to something powerful, like a browser, a database or a shell, without checking or encoding it first.
  • Treat model output like input from a stranger. Anyone whose words reach the model (a customer, a review, a web page) can shape what it writes.
  • For the browser: encode HTML and allow only safe Markdown with http and https links. Stripping all formatting also stops attacks, but breaks honest replies.
  • For the database: never run SQL the model wrote. Have the model fill in a small filter object, validate it against a schema, and run a fixed query with parameters.
  • A keyword deny-list ("block DROP and DELETE") both misses attacks and blocks honest requests.
  • Each output goes to a different place, so each needs the control for that place.

A Real-World Example

Tom builds a "chat with your data" feature for a sales dashboard. A manager types "show my top 10 deals this quarter", the model writes SQL, and the app runs it on a read-write connection. It works well in the demo.

A week later a support ticket arrives: half the pipeline table is empty. In the logs Tom finds a message that ended with "...and clean up the duplicate test deals". The model helpfully wrote a DELETE. Nobody attacked anything; the model just did what the words said, with more power than the job needed.

The fix was not a better prompt. Tom replaced free SQL with a filter the model fills in (region, stage, minValue, quarter), checked it against a schema, and ran one fixed SELECT with parameters on a read-only connection.

What You Will Learn

  • Why model output must be treated as untrusted input to whatever uses it
  • How a script in a review reaches another customer's browser
  • How to render replies safely without losing bold text, lists and links
  • Why model-written SQL is dangerous even with a deny-list
  • How to replace SQL with a validated filter and a parameterised query
  • How to pass a lesson where honest formatting must still work

Cheat Sheet: Where Model Output Goes, and the Control for Each

SinkAttackWeak fixStrong fix
Browser (HTML)<script>, onerror=, javascript: linksStrip all formattingEncode HTML; safe Markdown; http(s) links only
Database (SQL); DROP TABLE, OR 1=1, UNION SELECTKeyword deny-listValidated filter + parameterised query
URLs and imagesData carried out in a link"Looks internal" checkEgress allow-list by exact host (Lesson 3)
Shell, file paths, codeCommand or path injectionEscaping by handDon't pass model output there; if you must, allow-list values

Model output is untrusted input to whatever uses it next. Each sink gets the control that fits it.

How a Reply Becomes a Script

When an app renders the model's reply with innerHTML, or with a Markdown renderer that allows raw HTML, every tag in the reply becomes real HTML. Here are the patterns the Lesson 5 suite uses, and which ones a naive check catches:

Script patterns in a reply

const payloads = [
  "<script>steal(document.cookie)</script>",
  "<img src=x onerror=steal(document.cookie)>",
  "<svg onload=steal(document.cookie)></svg>",
  "[track my parcel](javascript:steal(document.cookie))",
];
const naive = (t: string) => t.includes("<script");
const runs = (t: string) => /<script\b|<\w+[^>]*\son\w+\s*=|\]\(\s*javascript:/i.test(t);

for (const p of payloads) console.log((runs(p) ? "runs   " : "safe   ") + (naive(p) ? "caught " : "missed ") + p.slice(0, 28));

Script patterns in a reply

using System.Text.RegularExpressions;

var payloads = new[]
{
    "<script>steal(document.cookie)</script>",
    "<img src=x onerror=steal(document.cookie)>",
    "<svg onload=steal(document.cookie)></svg>",
    "[track my parcel](javascript:steal(document.cookie))",
};
bool Naive(string t) => t.Contains("<script");
bool Runs(string t) => Regex.IsMatch(t, @"<script\b|<\w+[^>]*\son\w+\s*=|\]\(\s*javascript:", RegexOptions.IgnoreCase);

foreach (var p in payloads) Console.WriteLine((Runs(p) ? "runs   " : "safe   ") + (Naive(p) ? "caught " : "missed ") + p[..28]);

Expected output:

runs   caught <script>steal(document.cooki
runs   missed <img src=x onerror=steal(doc
runs   missed <svg onload=steal(document.c
runs   missed [track my parcel](javascript

A check for <script catches one of four. There are hundreds of ways to run code in HTML. Do not try to list them. Encode instead.

The replay at the top of this page was the stored version. The attacker wrote to a place the model reads, and the model carried the payload to a different customer:

From a review to another customer's browser

const reviews = [
  { by: "guest", text: "Solid arm. <script>steal(document.cookie)</script>" },
  { by: "ben", text: "Easy to fit in ten minutes." },
];
const reply = "Reviews: " + reviews.map((r) => '"' + r.text + '"').join(" ");
const renderedAsHtml = /<script\b/i.test(reply);

console.log("written by: guest");
console.log("read by:    ana, who asked to see the reviews");
console.log("runs in ana's browser: " + renderedAsHtml);

From a review to another customer's browser

using System.Text.RegularExpressions;

var reviews = new[]
{
    (By: "guest", Text: "Solid arm. <script>steal(document.cookie)</script>"),
    (By: "ben", Text: "Easy to fit in ten minutes."),
};
var reply = "Reviews: " + string.Join(" ", reviews.Select(r => "\"" + r.Text + "\""));
var renderedAsHtml = Regex.IsMatch(reply, @"<script\b", RegexOptions.IgnoreCase);

Console.WriteLine("written by: guest");
Console.WriteLine("read by:    ana, who asked to see the reviews");
Console.WriteLine("runs in ana's browser: " + (renderedAsHtml ? "true" : "false"));

Expected output:

written by: guest
read by:    ana, who asked to see the reviews
runs in ana's browser: true

Fix 1: Encode, and Keep Safe Markdown

Encoding turns the characters that HTML treats as code (<, >, &, ") into harmless text (&lt; and friends). The browser then shows them instead of running them. Markdown formatting like **bold** and - lists has no < in it, so it survives. Links survive too, as long as they point to http or https.

Encode HTML, keep Markdown, drop unsafe links

const escapeHtml = (t: string) => t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const renderSafe = (md: string) => escapeHtml(md).replace(/\[([^\]]*)\]\(\s*((?:[^()\s]|\([^()]*\))*)[^)]*\)/g, (m: string, label: string, url: string) => (/^https?:\/\//i.test(url) ? m : label));

console.log(renderSafe("Here you go: **<img src=x onerror=steal(1)>**"));
console.log(renderSafe("- **A-1001** Desk lamp [track](https://northwind.example/track/A-1001)"));
console.log(renderSafe("[click me](javascript:steal(1))"));
console.log(renderSafe("Yes: 3 kg < 5 kg, so it is fine."));

Encode HTML, keep Markdown, drop unsafe links

using System.Text.RegularExpressions;

string EscapeHtml(string t) => t.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace("\"", "&quot;");
string RenderSafe(string md) =>
    Regex.Replace(EscapeHtml(md), @"\[([^\]]*)\]\(\s*((?:[^()\s]|\([^()]*\))*)[^)]*\)",
        m => Regex.IsMatch(m.Groups[2].Value, "^https?://", RegexOptions.IgnoreCase) ? m.Value : m.Groups[1].Value);

Console.WriteLine(RenderSafe("Here you go: **<img src=x onerror=steal(1)>**"));
Console.WriteLine(RenderSafe("- **A-1001** Desk lamp [track](https://northwind.example/track/A-1001)"));
Console.WriteLine(RenderSafe("[click me](javascript:steal(1))"));
Console.WriteLine(RenderSafe("Yes: 3 kg < 5 kg, so it is fine."));

Expected output:

Here you go: **&lt;img src=x onerror=steal(1)&gt;**
- **A-1001** Desk lamp [track](https://northwind.example/track/A-1001)
click me
Yes: 3 kg &lt; 5 kg, so it is fine.

The image tag is now text. The order list keeps its bold and its tracking link. The javascript: link loses its target and keeps only its label. And the honest < sign becomes &lt;, which the browser shows as a normal <.

Now try the blunt version: show every reply as plain text.

Every attack stopped, and the grade still fails. 24 honest requests (four kinds, each phrased several ways) asked for a list with links or for bold text and got a flat line of words. That is over-blocking: the defence broke the product.

In a real app, use a Markdown renderer with raw HTML turned off, plus a sanitiser such as DOMPurify for anything that must allow some HTML. Never build your own HTML sanitiser with regular expressions; the course's renderSafe is a teaching version of the idea.

Model-Written SQL: Why a Deny-List Fails

Version 0 has a report tool. The customer asks "show my orders over $40", the model writes a WHERE clause, and the tool pastes it into a query. The customer's words go into the SQL, through the model.

The obvious patch is a deny-list: refuse any query that contains DROP, DELETE and friends.

Three SQL attacks still worked: OR 1=1, UNION SELECT and a -- comment. None of them needs a dangerous keyword. Each one returned other customers' data. And honest customers who asked which orders have a "drop-off" note had the report refused, because "drop" is on the list.

A deny-list misses some attacks and blocks some honest requests

const deny = /\b(drop|delete|truncate|alter|insert|update)\b/i;
const queries = [
  ["attack", "total > 0; DROP TABLE orders"],
  ["attack", "status = 'shipped' OR 1=1"],
  ["attack", "status = 'x' UNION SELECT email FROM customers"],
  ["attack", "total > 0 --"],
  ["honest", "note LIKE '%drop%'"],
  ["honest", "total > 40"],
];
for (const [kind, where] of queries) {
  const blocked = deny.test(where);
  const right = kind === "attack" ? blocked : !blocked;
  console.log((right ? "ok    " : "WRONG ") + kind + " " + (blocked ? "blocked" : "allowed") + ": " + where);
}

A deny-list misses some attacks and blocks some honest requests

using System.Text.RegularExpressions;

var deny = new Regex(@"\b(drop|delete|truncate|alter|insert|update)\b", RegexOptions.IgnoreCase);
var queries = new[]
{
    ("attack", "total > 0; DROP TABLE orders"),
    ("attack", "status = 'shipped' OR 1=1"),
    ("attack", "status = 'x' UNION SELECT email FROM customers"),
    ("attack", "total > 0 --"),
    ("honest", "note LIKE '%drop%'"),
    ("honest", "total > 40"),
};
foreach (var (kind, where) in queries)
{
    var blocked = deny.IsMatch(where);
    var right = kind == "attack" ? blocked : !blocked;
    Console.WriteLine((right ? "ok    " : "WRONG ") + kind + " " + (blocked ? "blocked" : "allowed") + ": " + where);
}

Expected output:

ok    attack blocked: total > 0; DROP TABLE orders
WRONG attack allowed: status = 'shipped' OR 1=1
WRONG attack allowed: status = 'x' UNION SELECT email FROM customers
WRONG attack allowed: total > 0 --
WRONG honest blocked: note LIKE '%drop%'
ok    honest allowed: total > 40

Fix 2: A Validated Filter and a Parameterised Query

The strong fix changes what the model is allowed to produce. It no longer writes SQL. It fills in a small filter object, and your code does the rest:

  1. Validate the filter against a schema: only known fields, numbers that are numbers, status from a fixed list, short text with safe characters.
  2. Build a fixed query with placeholders (?) and pass the values as parameters. The database never treats a parameter as SQL.
  3. Take the owner from the session, never from the model.

Validate the filter, then build a query with parameters

type Filter = { minTotal?: unknown; status?: unknown; noteContains?: unknown; [k: string]: unknown };
function validate(f: Filter) {
  for (const k of Object.keys(f)) if (!["minTotal", "status", "noteContains"].includes(k)) return "unknown field " + k;
  if ("minTotal" in f && typeof f.minTotal !== "number") return "minTotal must be a number";
  if ("status" in f && !["shipped", "delivered"].includes(String(f.status))) return "bad status";
  if ("noteContains" in f && !/^[\w -]{1,30}$/.test(String(f.noteContains))) return "bad characters in noteContains";
  return null;
}
function buildQuery(f: Filter, owner: string) {
  const where = ["owner = ?"], params: unknown[] = [owner];
  if (f.minTotal !== undefined) { where.push("total > ?"); params.push(f.minTotal); }
  if (f.status) { where.push("status = ?"); params.push(f.status); }
  if (f.noteContains) { where.push("note LIKE ?"); params.push("%" + f.noteContains + "%"); }
  return "SELECT id, item, total FROM orders WHERE " + where.join(" AND ") + "  " + JSON.stringify(params);
}

for (const f of <Filter[]>[{ minTotal: 40 }, { noteContains: "drop" }, { status: "shipped' OR 1=1" }, { minTotal: "0 --" }, { owner: "ben" }]) {
  const bad = validate(f);
  console.log(bad ? "rejected (" + bad + ")" : buildQuery(f, "ana"));
}

Validate the filter, then build a query with parameters

using System.Text.Json;
using System.Text.RegularExpressions;

string? Validate(Dictionary<string, object> f)
{
    foreach (var k in f.Keys) if (!new[] { "minTotal", "status", "noteContains" }.Contains(k)) return "unknown field " + k;
    if (f.TryGetValue("minTotal", out var min) && min is not int) return "minTotal must be a number";
    if (f.TryGetValue("status", out var st) && !new[] { "shipped", "delivered" }.Contains(st as string)) return "bad status";
    if (f.TryGetValue("noteContains", out var nc) && !Regex.IsMatch(nc?.ToString() ?? "", @"^[\w -]{1,30}$")) return "bad characters in noteContains";
    return null;
}
string BuildQuery(Dictionary<string, object> f, string owner)
{
    var where = new List<string> { "owner = ?" };
    var ps = new List<object> { owner };
    if (f.TryGetValue("minTotal", out var min)) { where.Add("total > ?"); ps.Add(min); }
    if (f.TryGetValue("status", out var st)) { where.Add("status = ?"); ps.Add(st); }
    if (f.TryGetValue("noteContains", out var nc)) { where.Add("note LIKE ?"); ps.Add("%" + nc + "%"); }
    return "SELECT id, item, total FROM orders WHERE " + string.Join(" AND ", where) + "  " + JsonSerializer.Serialize(ps);
}

var filters = new[]
{
    new Dictionary<string, object> { ["minTotal"] = 40 },
    new Dictionary<string, object> { ["noteContains"] = "drop" },
    new Dictionary<string, object> { ["status"] = "shipped' OR 1=1" },
    new Dictionary<string, object> { ["minTotal"] = "0 --" },
    new Dictionary<string, object> { ["owner"] = "ben" },
};
foreach (var f in filters)
{
    var bad = Validate(f);
    Console.WriteLine(bad is not null ? "rejected (" + bad + ")" : BuildQuery(f, "ana"));
}

Expected output:

SELECT id, item, total FROM orders WHERE owner = ? AND total > ?  ["ana",40]
SELECT id, item, total FROM orders WHERE owner = ? AND note LIKE ?  ["ana","%drop%"]
rejected (bad status)
rejected (minTotal must be a number)
rejected (unknown field owner)

The honest "drop" filter now works, because nothing looks for words; the schema only checks shape. The OR 1=1 attack fails the status list. The -- attack fails the number check. And a filter that tries to set the owner is rejected outright.

Parameters protect values, not structure. If you let the model choose a column name, a table name or a sort direction, check those against an allow-list too. And run reporting queries on a read-only database connection, so even a bug cannot delete anything.

Encoding and parameters in .NET

In Production

  • Use your framework's encoding (Razor, React JSX) and a sanitiser such as DOMPurify only where some HTML must render: OWASP XSS Prevention Cheat Sheet.
  • Run report queries on a read-only database user, so a bug cannot change data.
  • Validate structured model output with a schema library (Zod in TypeScript, System.Text.Json plus validation attributes in .NET) before using it.

Lab Exercise: Make Model Output Safe to Use

Open your pipeline and pick the Lesson 5 suite. Two new controls appear: how the chat window renders replies, and how the report tool runs queries.

Pass rule: 0 scripts run in any customer's browser, 0 harmful SQL effects (dropped, deleted or leaked rows), across all 12 attacks, and at least 98% of honest traffic served. Honest traffic includes customers who ask for bold text, lists with tracking links, and a report on "drop-off" notes.

Lesson 3's egress allow-list is the third output control. It still runs before rendering, so a link to an outside host is removed even when its scheme is https.

Common mistakes

MistakeWrong ideaCorrect ideaWhat goes wrong
Rendering model output with innerHTML"The model writes nice HTML."Use text nodes, or Markdown with raw HTML off and a sanitiser.A script in the reply runs in the customer's browser.
Listing bad tags"Block the script tag and we are safe."Encode output instead of searching for bad tags.One of the many other ways to run script gets through.
Letting the model write SQL, shell commands or paths"The model writes good SQL."Have it fill a small structure, validate it, and build the command yourself.A crafted message drops a table or reads the wrong file.
Deny-listing keywords"Block words like DROP and DELETE."Use parameters and an allow-list of fields.OR 1=1 slips through, and honest "drop-off" is blocked.
Taking the owner from the model"The model says whose rows to read."The session decides whose rows a query may read.A report shows other customers' orders.

Exercises

What to Remember

  • Model output is untrusted input to whatever uses it next.
  • Encode what the browser renders. Keep safe Markdown so honest replies still look right.
  • Never run SQL the model wrote. Validate a small filter and use parameters, with the owner from the session.
  • Deny-lists miss attacks and block real users at the same time.
  • Each sink (browser, database, URL) needs its own control.

Further Reading

Next: Excessive Agency. The agent pays a $420 refund nobody approved, and gives a 100% discount because someone asked nicely. You cut its tools, scope them and gate refunds in code.