ProvenPin Install

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
pinned versions5
dompurify@3.4.17jsdom@30.1.3@types/node@26.6.5tsx@4.23.15@types/jsdom@30.0.0
run it
npm install --no-audit --no-fund --ignore-scripts=false
npm test
test environment
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.

package.json
{
  "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"
  }
}
src/sanitize.ts
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;
}
test/sanitize.test.ts
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.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.