DOMPurify HTML sanitizing in Node with jsdom: working versions (tested 2026-10-15)
Sanitize untrusted HTML server-side with DOMPurify on a jsdom window, using profiles, allow-lists and a hook that hardens links with rel and target.
passed4 passed · 0 failed2026-10-15 17:10 UTC- Status
- passed
- Last passed
- 2026-10-15 17:10 UTC
- Tests
- 4 / 4
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38775053802
The code
Exactly the files that passed. Your agent can pull them with get_recipe("dompurify-jsdom-sanitize") (install), or see them on GitHub.
{
"name": "dompurify-jsdom-sanitize",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"dompurify": "3.4.17",
"jsdom": "30.1.3"
},
"devDependencies": {
"@types/node": "26.6.5",
"tsx": "4.23.15",
"@types/jsdom": "30.0.0"
}
}
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
const { window } = new JSDOM("");
const purify = createDOMPurify(window as unknown as Parameters<typeof createDOMPurify>[0]);
// Open external links in a new tab without leaking window.opener.
purify.addHook("afterSanitizeAttributes", (node) => {
if (node.tagName === "A" && node.getAttribute("href")?.startsWith("http")) {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer");
}
});
export function sanitizeHtml(dirty: string): string {
return purify.sanitize(dirty, { USE_PROFILES: { html: true } });
}
/** Comments and chat: only a few inline tags survive, everything else is unwrapped to text. */
export function sanitizeComment(dirty: string): string {
return purify.sanitize(dirty, {
ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "code"],
ALLOWED_ATTR: ["href", "target", "rel"],
KEEP_CONTENT: true,
});
}
export function removedCount(dirty: string): number {
purify.sanitize(dirty, { USE_PROFILES: { html: true } });
return purify.removed.length;
}
import { test } from "node:test";
import assert from "node:assert/strict";
import { sanitizeHtml, sanitizeComment, removedCount } from "../src/sanitize.ts";
test("strips scripts, event handlers and javascript: URLs", () => {
const out = sanitizeHtml('<p onclick="x()">hi<script>alert(1)</script></p><img src=x onerror="alert(1)"><a href="javascript:alert(1)">bad</a>');
assert.equal(out.includes("<script"), false);
assert.equal(out.includes("onerror"), false);
assert.equal(out.includes("onclick"), false);
assert.equal(out.includes("javascript:"), false);
assert.match(out, /<p>hi<\/p>/);
});
test("hardens external links via hook", () => {
const out = sanitizeHtml('<a href="https://example.com">x</a> <a href="/local">y</a>');
assert.match(out, /<a href="https:\/\/example.com" target="_blank" rel="noopener noreferrer">x<\/a>/);
assert.match(out, /<a href="\/local">y<\/a>/);
});
test("comment allow-list keeps text but drops disallowed tags", () => {
const out = sanitizeComment('<h1>Big</h1><b>bold</b> <iframe src="https://evil"></iframe><span style="color:red">red</span>');
assert.equal(out, "Big<b>bold</b> red");
});
test("reports how many nodes or attributes were removed", () => {
assert.equal(removedCount("<b>fine</b>"), 0);
assert.ok(removedCount('<b onclick="x">x</b><script></script>') >= 2);
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Preact components with Signals state, tested with Testing Library on jsdom
A Preact 11 cart using @preact/signals signal and computed state plus a hooks-based async form, tested in Vitest on jsdom with @testing-library/preact and user-event, including re-render on signal change without new props.
SolidJS signals, memos and resources, tested with Solid Testing Library
SolidJS components using createSignal, createMemo, For, Show and createResource with Suspense, compiled by vite-plugin-solid and tested in Vitest on jsdom with @solidjs/testing-library and user-event.
TanStack Query + React Testing Library hooks and components (Vitest + jsdom)
Test React components and hooks that use TanStack Query v5 with Testing Library on jsdom: fresh QueryClient per test, retries off, mocked fetchers, mutations with invalidation.