Back to Blog
Lines of colorful JavaScript code on a dark screen, representing markup rendered from model output

Securely Render LLM Output Without Introducing XSS

11 min read

To securely render LLM output, treat every model response as untrusted active content. The model may repeat hostile markup from a document, tool result, web page, or earlier message even when no user typed an attack directly. Raw HTML, unsafe link schemes, remote images, and generated attachments can turn that text into cross-site scripting or data leakage inside an authenticated application. Put one rendering boundary between model output and the browser. Constrain Markdown, validate URLs, sanitize explicit HTML exceptions, isolate files, and add browser controls that limit damage when application code makes a mistake.

Why model output crosses a browser trust boundary

A language model produces text, but a chat interface rarely displays only text. It parses Markdown, creates anchors, loads images, highlights code, expands citations, and may render diagrams or HTML previews. Each feature changes model-controlled characters into browser behavior.

The original attacker may be several steps away. A support assistant can quote a hostile customer ticket. A research workflow can summarize a page containing crafted Markdown. A retrieval system can return a poisoned document. A tool can supply an HTML error page. The model may preserve enough of that content for the frontend parser to recognize a tag, link, image, or attribute.

The consequence depends on the rendering path. Unsafe HTML can run script in the application's origin. A dangerous URL can execute code after a click. A remote image can reveal that a specific user opened a conversation. A same-origin generated file can inherit cookies or interact with application resources. Stored output is especially risky because one hostile response can reach reviewers, administrators, or anyone opening a shared conversation later.

This failure has appeared in AI chat software. The NVD record for CVE-2024-7044 describes stored cross-site scripting through malicious uploaded content in Open WebUI. Treat that record as evidence of a reported product incident, not proof that every AI renderer has the same flaw. It does show why a model or file pipeline cannot grant content browser trust.

Define one rendering contract

Write down which output forms the product needs before choosing a Markdown library or sanitizer. Most internal assistants need paragraphs, headings, lists, code, and safe links. Fewer need arbitrary HTML, inline SVG, embedded video, iframes, or remote images. Disable formats that do not serve a real user job.

A practical contract separates four concerns:

  1. Syntax: the Markdown elements and extensions the parser accepts.
  2. Components: the application components each accepted element may create.
  3. Resources: the URL schemes, destinations, and media types those components may load.
  4. Containment: browser and origin controls that reduce damage if a parser or component fails.

Apply the same contract to assistant messages, citations, tool results, conversation history, shared links, exports, and preview panes. A safe main chat renderer does not help if a citation tooltip later injects the same text with innerHTML.

Keep the raw model response for audit and reprocessing, but do not store a string marked "safe" forever. Renderer policy and sanitizer behavior change. Store raw content plus the renderer policy version, then produce the display tree under the current policy. Cache the rendered result only if the cache key includes that version.

Parse Markdown into constrained components

Prefer a parser that builds a syntax tree and maps allowed nodes to known components. The react-markdown documentation describes this component-tree approach and states that it does not rely on dangerouslySetInnerHTML for ordinary rendering. It also exposes URL transformation and raw-HTML behavior as explicit boundaries.

Start with raw HTML disabled. Allow only the Markdown constructs the product needs. Treat every optional parser plugin as executable application code with its own security behavior. A plugin that accepts raw HTML, evaluates expressions, or creates custom nodes expands the trusted computing base.

The component map should ignore model-supplied event handlers, style objects, class names, element IDs, and arbitrary component properties. Build those values in application code. For example, a model may choose the text and destination of a link after URL validation, but it should not choose target, rel, click handlers, or CSS.

A simplified policy can look like this:

renderModelOutput(markdown):
  tree = parseMarkdown(markdown, rawHtml = false)
  tree = keepNodes(tree, [paragraph, heading, list, code, quote, link])
  tree = capDepthAndNodeCount(tree, maxDepth = 12, maxNodes = 5000)
  tree = transformLinks(tree, validateNavigationUrl)
  return renderWithFixedComponents(tree)

Depth, node-count, and input-size limits are availability controls. A response need not execute script to freeze a browser with pathological nesting or an enormous highlighted code block. Enforce a maximum response size during streaming and a separate render budget in the client.

Do not replace an established parser with regular expressions. Markdown, HTML, URLs, SVG, and browser parsing all have edge cases. Use maintained parsers and apply policy to their parsed output.

Validate links and remote media separately

HTML sanitization does not answer every URL policy question. Links, images, video, and source elements cause different browser actions. Each component needs a purpose-specific validator.

For navigation links, parse the URL with the platform URL parser. Permit https and, where needed, relative application paths. Reject javascript, data, file, and unknown schemes. Decide whether mailto and tel serve the product rather than accepting them by habit. The OWASP Cross-Site Scripting Prevention Cheat Sheet warns that framework protections have gaps around dangerous URLs and escape hatches, and it recommends controls appropriate to the output context.

For external links, set rel="noopener noreferrer" when opening a new browsing context. Render the resolved hostname visibly when users make a security-sensitive choice. Do not let model-provided Markdown hide an unexpected host behind a trusted-looking label without any UI cue.

Remote images need a stricter decision. Loading an image sends a request from the user's browser and can disclose an IP address, browser metadata, timing, and a unique tracking token in the URL. The safe default for an internal AI assistant is to block remote images. If images are required, proxy them through a service that fetches under a separate outbound policy, strips credentials, limits bytes and content types, and serves a cached copy from a media origin. Do not pass arbitrary image URLs straight to employee browsers.

Treat citation URLs as untrusted too. Retrieval provenance may establish where text came from, but it does not make a destination safe to open. Apply the normal navigation policy and display the destination.

Sanitize the narrow raw HTML exception

Some products genuinely need model-assisted rich text or an HTML preview. Make that an explicit mode with a narrower audience and separate code path. Do not enable raw HTML globally because one workflow asks for formatting.

Use a maintained, allowlist-based HTML sanitizer in the environment where the markup will be interpreted. DOMPurify supports HTML, SVG, and MathML sanitization and documents an easy mistake: modifying markup after sanitization can void the sanitizer's protection. Render the sanitized result directly into the intended context. Do not sanitize, then let another plugin add attributes, merge HTML fragments, or reinterpret the result.

Choose an allowlist smaller than the sanitizer default when the feature permits it. A rich-text answer may need paragraphs, lists, emphasis, code, and links. It probably does not need forms, SVG, MathML, style attributes, templates, or embedded documents. Validate URLs with the component policy even after HTML sanitization so navigation and remote-resource rules remain consistent.

Server-side sanitization needs a maintained DOM implementation. DOMPurify's documentation warns that vulnerable or incompatible DOM implementations can undermine sanitization. Pin and update both the sanitizer and its DOM dependency. Run the same hostile fixture suite after either dependency changes.

Plain-text surfaces should stay plain. Logs, notification subjects, browser titles, spreadsheet cells, and downloadable formats each have their own injection contexts. Reusing the HTML-rendered string in another context is unsafe and often unnecessary.

Handle streaming without rendering incomplete markup

Streaming makes output feel faster but exposes intermediate parser states. A partial link, code fence, or HTML token can be interpreted differently after the next chunk arrives. Repeatedly concatenating strings and assigning innerHTML also multiplies dangerous sink calls.

Buffer streaming data as text. Reparse the complete accumulated Markdown into the constrained tree on a bounded cadence, or render only parser-confirmed complete blocks. Do not interpret raw HTML fragments as they arrive. Apply input-size and node-count limits before each render, and stop with a plain-text error when the budget is exceeded.

When the stream completes, perform one final parse under the same policy and replace the preview tree. Persist the raw completed message and its render-policy version. If the stream fails, mark the content incomplete and avoid promoting a half-built attachment, citation set, or HTML preview as a normal completed answer.

Isolate generated files and previews

Generated HTML, SVG, and office documents should not be served inline from the authenticated application's origin. Put untrusted previews and downloads on a separate origin without application cookies. Use attachment disposition for files that do not need inline rendering. Set explicit content types and prevent MIME sniffing.

An HTML preview that must run can use a sandboxed iframe with the smallest capability set. Avoid allow-same-origin and allow-scripts together for attacker-controlled content because that combination can erase much of the sandbox boundary. Pass data through a narrow message protocol, validate the sender and message schema, and never expose application tokens to the frame.

SVG deserves the same caution as HTML because it can contain links, external resources, and active features. Sanitizing ordinary rich text does not imply that arbitrary SVG is safe. Render a rasterized derivative when the user only needs to see the image.

Add CSP and Trusted Types as containment

Sanitization and constrained components prevent dangerous content from reaching execution sinks. Browser policy supplies a second boundary. The MDN Content Security Policy guide explains how CSP restricts script and resource loading and can require Trusted Types.

Deploy a nonce-based or hash-based script policy without unsafe-inline, then tighten image, frame, object, style, and connection sources to product needs. Start with report-only telemetry if the application has legacy dependencies, but set a deadline for enforcement. A policy that remains report-only cannot block an attack.

Trusted Types can prevent ordinary strings from reaching dangerous DOM sinks. Create a small audited policy around the approved sanitizer rather than giving every component permission to construct trusted HTML. Log policy violations and treat a new violation as a release regression.

CSP is defense in depth, not a replacement for sanitization. A permissive script policy, allowed third-party origins, browser gaps, or an existing trusted script gadget can leave room for exploitation. Keep the rendering contract as the primary control.

Test the complete rendering path

Unit tests for a URL helper are not enough. Send hostile model responses through storage, streaming, Markdown parsing, component mapping, sanitizer exceptions, and the real browser renderer. Assert both the resulting DOM and observable network behavior.

Build fixtures for:

  • script tags, event attributes, malformed tags, and encoded variants;
  • javascript, data, protocol-relative, mixed-case, and whitespace-obscured URLs;
  • Markdown links whose labels hide an unrelated destination;
  • remote images with unique request tokens;
  • SVG, MathML, template, form, iframe, and style payloads;
  • raw HTML split across streaming chunks;
  • content that is safe before a post-processing plugin but unsafe afterward;
  • generated HTML and SVG files opened from both preview and download paths;
  • oversized input, deep nesting, and large highlighted code blocks;
  • citations and tool results that bypass the main assistant-message component.

The browser test should fail if a script runs, an event handler survives, a blocked scheme reaches an anchor, a forbidden network request leaves the page, or generated content receives authenticated application cookies. Capture CSP and Trusted Types violation reports during the test.

Run these fixtures whenever the Markdown parser, sanitizer, syntax-highlighting plugin, framework, browser policy, or generated-file service changes. Security behavior lives in the combined pipeline, so a dependency upgrade can alter the result without any application code change.

Ship the narrow policy first

Inventory every place the product displays model-controlled or retrieved content. Route those surfaces through one constrained Markdown renderer, disable raw HTML, block remote media, and add URL tests before expanding formatting. Put any required HTML preview behind a separate sanitized and isolated path. Then enforce CSP and Trusted Types and run the hostile fixture suite in a real browser. That sequence closes the unmet rendering lifecycle from model text to browser behavior instead of relying on one sanitizer call.

References

  1. OWASP Cross-Site Scripting Prevention Cheat Sheet supports context-aware encoding, sanitization, safe sinks, and warnings about framework escape hatches.
  2. DOMPurify documentation supports the sanitizer boundary, server-side DOM caveat, Trusted Types integration, and warning against post-sanitization mutation.
  3. react-markdown documentation supports constrained component-tree rendering, raw-HTML controls, plugin trust, and URL transformation.
  4. Trusted Types explainer supports typed enforcement around dangerous browser sinks and audited construction policies.
  5. MDN Content Security Policy guide supports script and resource restrictions, deployment guidance, and Trusted Types enforcement through CSP.
  6. NVD CVE-2024-7044 documents a reported stored XSS incident involving uploaded content in Open WebUI.

About Fire In Belly: Independent senior engineering from Tallinn, Estonia. We design and build AI workflow automation, internal tools, and custom software with the rendering boundaries described above, at published fixed prices. Schedule a call to discuss your next project.