Digital experiences · · 6 min read
DOMPurify: sanitize AI-generated HTML before showing a preview
Create a small rich-text allowlist and test that formatting survives while scripts, event handlers and disallowed markup are removed.
By Sociologix Editorial

Treat a generated preview as incoming content
An AI assistant can draft a service description with paragraphs, lists and links. If your application accepts that output as HTML, its preview becomes another input boundary. A useful prompt alone does not enforce which elements and attributes the application will render.
This AI-assisted editorial guide builds a deliberately small DOMPurify policy for that rich-text preview. An original local test checked eight synthetic inputs, inspecting the resulting elements and attributes. It made no model calls and used no customer content. The results establish how these fixtures were transformed, rather than a security guarantee for an entire website.
1. Reproduce the checked environment
The checked packages are DOMPurify 3.4.17 and jsdom 30.1.3. This example ran with Node.js 24.19.0 on October 20, 2026. The jsdom package accepts Node 22.22.2 within the 22 branch, Node 24.15.0 within the 24 branch, or Node 26 and newer; confirm your runtime before installing. DOMPurify offers Apache-2.0 or MPL-2.0 licensing. [1][2]
Use a scratch folder. Run the first three PowerShell commands, save both JavaScript blocks below in order as sanitize-demo.mjs, then run the final command. Installation needs internet access. The fixtures run locally after installation and require no API keys.
mkdir ai-preview-demo
cd ai-preview-demo
npm install --ignore-scripts "dompurify@3.4.17" "jsdom@30.1.3"
# Save both JavaScript blocks below as sanitize-demo.mjs first.
node sanitize-demo.mjs2. Define what the preview actually needs
Our proposed preview supports paragraphs, emphasis, lists and ordinary anchors. It excludes images, frames, forms, SVG and styling. DOMPurify exposes ALLOWED_TAGS and ALLOWED_ATTR for narrowing markup. Keep its default URL checks; this example does not enable unknown protocols. [3]
The policy also disables custom data and ARIA attributes supplied by the generated snippet. Those settings default to true. Here accessibility structure belongs to the application’s controlled preview template; disabling incoming ARIA is a choice for this limited format, not a rule for every interface. [4]
Keep the allowed format small enough that the product team can explain it. If the assistant returns headings or tables, this preview will not preserve that markup. Decide whether the feature needs those elements before broadening the policy.
import assert from "node:assert/strict";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
const window = new JSDOM("").window;
const purify = createDOMPurify(window);
function cleanPreview(html) {
return purify.sanitize(html, {
ALLOWED_TAGS: ["p", "strong", "em", "ul", "ol", "li", "a"],
ALLOWED_ATTR: ["href", "title"],
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: false,
});
}3. Check the resulting DOM, not whether a script ran
Append this fixture block to the same file. It checks a permitted formatting element, an HTTPS anchor and six categories of unwanted markup or attributes. Assertions inspect parsed output rather than depending on exact whitespace in the serialized HTML.
jsdom disables script execution and subresource loading by default, and this test leaves those defaults in place. Observing that nothing executed would therefore prove little. The meaningful checks below examine which elements and attributes remain after sanitization. [5]
// Synthetic fixtures only. Inspect DOM results; do not execute markup.
const fixtures = [
["formatting retained", '<p>Hello <strong>team</strong></p>',
fragment => assert.equal(fragment.querySelector("strong").textContent, "team")],
["script removed", '<p>Hi</p><script>alert(1)</script>',
fragment => assert.equal(fragment.querySelector("script"), null)],
["event handler removed", '<p onclick="alert(1)">Hi</p>',
fragment => assert.equal(fragment.querySelector("p").hasAttribute("onclick"), false)],
["javascript link removed", '<a href="javascript:alert(1)">Read</a>',
fragment => assert.equal(fragment.querySelector("a").hasAttribute("href"), false)],
["HTTPS link retained", '<a href="https://example.com/guide">Read</a>',
fragment => assert.equal(fragment.querySelector("a").getAttribute("href"), "https://example.com/guide")],
["extra attributes removed", '<p style="color:red" data-note="x" aria-label="x">Hi</p>',
fragment => assert.equal(fragment.querySelector("p").attributes.length, 0)],
["iframe removed", '<iframe src="https://example.com/"></iframe><p>OK</p>',
fragment => assert.equal(fragment.querySelector("iframe"), null)],
["SVG removed", '<svg><circle cx="2" cy="2" r="1"></circle></svg><p>OK</p>',
fragment => assert.equal(fragment.querySelector("svg"), null)],
];
for (const [name, input, check] of fixtures) {
const clean = cleanPreview(input);
const fragment = JSDOM.fragment(clean);
check(fragment);
assert.equal(fragment.querySelector("script, iframe, svg, [onclick], [style], [data-note], [aria-label]"), null);
console.log(name + ": passed");
}
window.close();4. Read the observed results accurately
All eight fixtures passed locally. The strong element retained its text and the HTTPS link retained its destination. The script, event-handler attribute, JavaScript link destination, extra attributes, iframe and SVG were removed as checked. A shared assertion also checked that none of those selected unwanted elements or attributes remained in each output.
The retained HTTPS link is not an endorsement of its destination. Default URI validation is not an HTTPS-only business policy. If your preview must link only to approved domains, design and test that additional restriction explicitly. Likewise, sanitization does not verify that an AI-written claim, quote or offer is accurate.
5. Keep sanitization at the rendering boundary
Apply the policy to the final HTML that reaches the preview. The maintainer warns that modifying sanitized markup afterward, or feeding it into another library that changes it, can invalidate the protection. Inspect the complete editor-to-preview path, including any Markdown conversion or plugin that runs after your initial check. [3]
DOMPurify’s threat model does not promise CSS sanitization or complete control of resource leaks and other output contexts. This narrow example omits style and resource-loading elements instead of trying to make arbitrary generated pages safe to execute. It is a rich-text preview policy, not a sandbox for generated websites. [6]
Keep the sanitizer and server-side DOM implementation maintained, retain a lockfile for reproducibility and rerun your regression fixtures when dependencies or the allowed format change. Test the real browser preview as well. Separate content approval and publishing permissions from the ability to submit a draft: sanitizing the draft does not authorize it to go live.
Sources & further reading
Build useful AI previews with clear publishing controls
Sociologix can help design AI content tools, preview boundaries, approval steps and website integrations around your workflow. Bring the content format you want to generate and the actions users should be allowed to take.
Talk to Sociologix