Production AIGuide

LLM HTML Output: Sanitize Before Rendering

Give generated answers a narrow formatting contract, validate their links, and verify the final browser DOM with a runnable fixture suite.

Published: · Reviewed:

Loose sheets pass along a conveyor through an amber-lit mesh gate toward a transparent cyan browser window, above a stack of white sheets.
Put a deliberate rendering boundary between generated content and the browser.

1. Decide what the output is allowed to do

A support assistant returns a useful paragraph followed by an image with an event handler. Your application only asked for a summary, but it inserts the entire response as HTML. The rendering path has now accepted more behavior than the product needs. Treat the final string as untrusted even when it arrived from your own model integration.

If your interface needs only text, assign the response to textContent on an ordinary container. The OWASP DOM XSS guidance demonstrates that text sink. For formatted answers, this guide implements a smaller contract: paragraphs, headings, lists, emphasis, code blocks and selected links. Images, forms, embedded pages, CSS and SVG have no role in this example.

This is the final rendering boundary. It complements the permissions and tool controls in AI Guardrails for Developers. It does not change what data you send to a provider; use the separate input privacy guide for that decision.

2. Run the isolated example

Download the complete demo ZIP and extract it into a disposable directory. It contains the renderer, synthetic malicious and legitimate fixtures, exact package versions, and the dependency lockfile. You need Node.js with npm. No API key or model call is involved. The browser receives locally supplied HTML, not customer conversations.

npm ci --ignore-scripts
npx playwright install chromium
npm test

The package pins DOMPurify 3.4.17 and Playwright 1.64.1. Keep the included lockfile with the demo and use npm ci when repeating it. Pinning is for reproducibility: plan dependency updates and rerun the fixture suite, rather than treating a fixed version as a permanent security decision. OWASP's XSS prevention guidance recommends maintained sanitization and regular patching.

The setup downloads packages and a browser. During the actual test, the harness blocks browser network traffic and records any attempted requests. That distinction matters: a test with no successful network access could still have attempted to load an unwanted tracking image.

3. Sanitize the final HTML into a fragment

The complete renderer below uses DOMPurify's documented configuration API. The harness loads its local browser distribution before this file. In your application, import or bundle the maintained dependency through your normal build process, then call the renderer with a dedicated output container.

If you accept Markdown, convert it to HTML first. Pass that final HTML through this boundary after conversion; a sanitizer run on the earlier Markdown string does not inspect the HTML the converter will create. The download deliberately omits a Markdown parser so its dependency and parser policy remain outside this experiment.

// DOMPurify is loaded locally by the test harness before this file.
const TAGS = ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li',
  'blockquote', 'pre', 'code', 'h2', 'h3', 'a'];
const BASE = 'https://app.example/';
const ORIGINS = new Set([new URL(BASE).origin, 'https://docs.example']);

function renderHtml(root, untrustedHtml) {
  if (!DOMPurify.isSupported) {
    root.textContent = untrustedHtml;
    return;
  }
  const fragment = DOMPurify.sanitize(untrustedHtml, {
    ALLOWED_TAGS: TAGS,
    ALLOWED_ATTR: ['href', 'title'],
    ALLOW_DATA_ATTR: false,
    ALLOW_ARIA_ATTR: false,
    RETURN_DOM_FRAGMENT: true
  });
  // Only remove attributes here. Do not append new untrusted markup.
  for (const element of fragment.querySelectorAll('*')) {
    if (element.tagName !== 'A') element.removeAttribute('href');
  }
  for (const link of fragment.querySelectorAll('a[href]')) {
    try {
      const url = new URL(link.getAttribute('href'), BASE);
      if (url.protocol !== 'https:' || !ORIGINS.has(url.origin) ||
          url.username || url.password) throw new Error('Rejected URL');
      link.setAttribute('href', url.href);
    } catch {
      link.removeAttribute('href');
    }
  }
  root.replaceChildren(fragment);
}
window.renderHtml = renderHtml;

The supported-runtime check falls back to literal text. The allowlist excludes event attributes, style, id, name, data attributes and ARIA attributes. This is a product choice for compact answers: it will remove richer content, including tables. If you need another tag, specify the behavior it enables and add both a legitimate fixture and an abuse fixture before expanding the policy.

4. Validate links against an explicit base

For this demo, a relative URL resolves against https://app.example/. Only that exact origin and https://docs.example are accepted, over HTTPS, without username or password fields. Change these example origins to your application's policy before integration. The fixed base prevents the current page directory or a document base element from silently changing how a relative link is interpreted.

The URL pass uses the browser URL parser, checks the parsed protocol and origin, and removes rejected href attributes. It also removes href from elements other than links. An encoded JavaScript scheme, a data URL, an unapproved host, protocol-relative navigation to that host, and a credential-bearing URL all have explicit test cases. The link text survives rejection, so the answer stays readable.

This policy is narrower than merely allowing HTTP and HTTPS. It also means a perfectly legitimate external citation will lose its link unless you approve its origin. An approved origin does not establish that a generated path exists, that a citation supports the sentence, or that the destination content is trustworthy. Validate those claims separately. Links open in the same tab because the sanitizer does not permit target attributes.

5. Check the browser result, including legitimate output

Local verification on November 5, 2026 used Windows, Node 24.13.0 and Chromium 156.0.8078.4. All 12 fixtures passed. The test compared exact output HTML, checked an execution marker after each insertion, and recorded zero dialogs and zero attempted browser requests. These are observed results for the downloaded example.

Fixture groupExpected result
Event handler, script, SVG, forms and CSSActive elements or attributes disappear; permitted paragraph text survives.
Encoded JavaScript, data URL, unapproved host, protocol-relative host and credentialsLink text remains with no href.
Formatting, escaped code text and approved linksHeadings and lists survive, code remains text, and allowed links resolve to the expected HTTPS URL.

The final output line should report 12 fixtures, zero dialogs and zero requests. The browser version can change when you deliberately update Playwright. A failed equality assertion names the fixture; inspect the actual HTML before changing its expected result. Preserving legitimate output matters as much as removing hostile features: a renderer that deletes every answer would also avoid executing these payloads.

The test does not click links, exercise every malformed document, run other browser engines, or cover a production framework integration. Passing it is regression evidence, not proof that all XSS is absent. Add examples from your real formatting contract and run the same checks inside the actual component before release.

6. Keep the boundary at the last write

After creating the fragment, the renderer only removes attributes or assigns a URL that passed the fixed policy. It then replaces the output container's children. Do not concatenate an unsanitized footer, run a formatter that reparses arbitrary HTML, or insert new model output after this step. OWASP warns that later mutations can undermine sanitization. If a transformation produces more HTML, move it before the final sanitizer and test the combined path.

Keep loading placeholders and status labels outside this output container, and render their text through text sinks. For streaming responses, buffer the answer before this final pass, or define and test a separate incremental renderer; the streaming guide covers transport concerns. Recheck this boundary whenever the sanitizer, browser support, Markdown converter, allowed origins or downstream formatter changes.

FAQ

Should I sanitize Markdown before converting it to HTML?

Convert Markdown first, then sanitize the resulting HTML immediately before rendering. This example accepts final HTML and does not include a Markdown parser.

Can I display LLM output as plain text instead?

Yes. Use textContent in an ordinary text container when you do not need HTML formatting.

Does passing the browser test prove there is no XSS?

No. These results cover the listed fixtures in one Chromium build. Review other rendering paths and rerun tests when dependencies or policies change.

Does an allowed HTTPS link mean the destination is trustworthy?

No. The policy controls schemes and origins. It does not verify the truth of a model-generated link or the content at its destination.

Primary sources — checked November 5, 2026