From de46f10fc2593c6b92a39de5b19408f77bba615c Mon Sep 17 00:00:00 2001 From: Cure53 Date: Wed, 17 Jun 2026 12:37:15 +0200 Subject: [PATCH 1/7] Fix typos and add relationship section in README --- README.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 89c8df9..0bec85d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ the DOM. You don't touch the code. You don't even need to know where the bug is. It's for the sites you can't easily fix: complex apps or legacy apps nobody wants to touch, the third-party widget you can't patch, the 2000+ `innerHTML` sinks written before anyone had heard of XSS. -**Just ship the policy, and the browser automtically protects every HTML sink with DOMPurify or other sanitizers.** +**Just ship the policy, and the browser automatically protects every HTML sink with DOMPurify or other sanitizers.** ## Is there a demo? @@ -188,6 +188,12 @@ It's a retrofit, not magic. Know the edges: - **Trusted Types sinks only.** Inline handlers (`onclick=`), `style`, and `href` URLs aren't TT sinks. Close those with a real `script-src` that drops `'unsafe-inline'`. - **One sanitizer.** A bypass in the sanitizer is a bypass in everything it guards. +- **It sanitizes a string, then the sink re-parses it.** The `default` policy returns sanitized HTML as a + string that the browser parses again in context - the serialize/re-parse step that can re-open + [mutation XSS](https://html.spec.whatwg.org/multipage/dynamic-markup-insertion.html#sanitizer-security-mxss). + DOMFortify leans on the sanitizer's own mXSS hardening (DOMPurify's, by default) to close it; a weaker + sanitizer reopens it. A browser-native sink-level sanitizer avoids the round trip entirely - see + [Relationship to the platform](#relationship-to-the-platform). ## Security @@ -196,11 +202,17 @@ Found a hole? Please report it privately - see [SECURITY.md](SECURITY.md). Don't --- Built on the shoulders of Frederik Braun's -[Perfect types with setHTML()](https://frederikbraun.de/perfect-types-with-sethtml.html) and Jun +[Perfect types with setHTML()](https://frederikbraun.de/perfect-types-with-sethtml.html) and his Mozilla explainer [Trusted or Sanitized HTML](https://github.com/mozilla/explainers/blob/main/trusted-or-sanitized-html.md), plus Jun Kokatsu's "Perfect Types". By [Cure53](https://cure53.de). +## Relationship to the platform + +DOMFortify implements, in userland and available today, the model Mozilla has proposed for standardization in [Trusted or Sanitized HTML](https://github.com/mozilla/explainers/blob/main/trusted-or-sanitized-html.md) (Frederik Braun, 2026): an opt-in CSP keyword that makes the browser sanitize HTML at every sink with no code changes, and refuse script sinks it cannot vet. Where that proposal adds a browser-native `trusted-types 'sanitize-html'` keyword that sanitizes each sink in its parsing context, DOMFortify reaches the same outcome now through a `default` Trusted Types policy backed by a sanitizer. When browsers ship `'sanitize-html'`, DOMFortify becomes a thin compatibility shim or simply unnecessary - which is the goal, not a threat. + +The one design difference worth stating plainly: the platform proposal sanitizes the parsed fragment directly at the sink, in context, avoiding a serialize/re-parse round trip. DOMFortify's policy returns a sanitized string that the sink re-parses, so it depends on the sanitizer's mutation-XSS hardening to stay safe (see [What it won't do](#what-it-wont-do)). With DOMPurify as the sanitizer that surface is well covered; with a weaker sanitizer it may not be. + ## Prior Art DOMFortify builds on established browser and ecosystem concepts rather than claiming to invent Trusted Types-based HTML sanitization from scratch. The underlying enforcement mechanism is the browser-native [Trusted Types API](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API), and the sanitizer commonly used with DOMFortify, [DOMPurify](https://github.com/cure53/DOMPurify), already provides Trusted Types integration. Earlier tooling such as [`melloware/csp-webpack-plugin`](https://github.com/melloware/csp-webpack-plugin) and its Rspack counterpart [`rspack-contrib/csp-rspack-plugin`](https://github.com/rspack-contrib/csp-rspack-plugin) also demonstrated the idea of installing a DOMPurify-backed `default` Trusted Types policy to retrofit protection for legacy `innerHTML`-style sinks. -DOMFortify differs in its focus and packaging: it is a standalone runtime hardening layer, not a bundler-side CSP helper, and it emphasizes safer defaults for script-like sinks, sanitizer abstraction, route-aware configuration, CSP/telemetry integration, and defensive handling of configuration and prototype-pollution edge cases. Related ecosystem work includes framework- or type-system-oriented approaches such as [Angular’s Trusted Types integration](https://angular.dev/best-practices/security), Google’s [`safevalues`](https://github.com/google/safevalues), and earlier Trusted Types integrations collected by the [W3C Trusted Types project](https://github.com/w3c/trusted-types/wiki/Integrations). +DOMFortify differs in its focus and packaging: it is a standalone runtime hardening layer, not a bundler-side CSP helper, and it emphasizes safer defaults for script-like sinks, sanitizer abstraction, route-aware configuration, CSP/telemetry integration, and defensive handling of configuration and prototype-pollution edge cases. Related ecosystem work includes framework- or type-system-oriented approaches such as [Angular’s Trusted Types integration](https://angular.dev/best-practices/security), Google’s [`safevalues`](https://github.com/google/safevalues), and earlier Trusted Types integrations collected by the [W3C Trusted Types project](https://github.com/w3c/trusted-types/wiki/Integrations). The browser-native direction this whole approach points toward is set out in Mozilla's [Trusted or Sanitized HTML](https://github.com/mozilla/explainers/blob/main/trusted-or-sanitized-html.md) explainer (see [Relationship to the platform](#relationship-to-the-platform)). From 46984d44b004024ccad8f582bce96039d7c4fc1d Mon Sep 17 00:00:00 2001 From: Cure53 Date: Wed, 17 Jun 2026 12:38:17 +0200 Subject: [PATCH 2/7] Add CODEOWNERS file for review process --- CODEOWNERS | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 CODEOWNERS diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..2818ed8 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,3 @@ +# .github/CODEOWNERS +# All changes require review from a core maintainer +* @x00mario From d0e0d1b906d98df2543c1985f1871a035473d710 Mon Sep 17 00:00:00 2001 From: Mario Heiderich Date: Wed, 17 Jun 2026 14:25:44 +0200 Subject: [PATCH 3/7] chore: updated the workflows to sign and no loner auto-release docs: updated the README to be ready for first alpha release --- .github/workflows/publish.yml | 35 ------------------------------ .github/workflows/sign-release.yml | 9 ++++++++ README.md | 8 +++---- package.json | 3 +-- 4 files changed, 14 insertions(+), 41 deletions(-) delete mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index b1ab9b7..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Publish - -on: - release: - types: [published] - -permissions: - contents: read - -jobs: - publish: - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write # npm provenance via OIDC - steps: - - name: Harden the runner (audit all outbound calls) - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 - with: - egress-policy: audit - - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - persist-credentials: false - - name: Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: 20 - registry-url: https://registry.npmjs.org - cache: npm - - run: npm ci - - run: npm test - - run: npm publish --provenance --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/sign-release.yml b/.github/workflows/sign-release.yml index 55698e3..72136ac 100644 --- a/.github/workflows/sign-release.yml +++ b/.github/workflows/sign-release.yml @@ -18,7 +18,16 @@ jobs: egress-policy: audit - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + ref: ${{ github.event.release.tag_name }} persist-credentials: false + # Build the tagged release's dist so we sign exactly what was released + # (and the same bytes slsa-provenance attests), not stale committed dist + # from whatever branch the workflow happened to check out. + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + - run: npm ci --ignore-scripts + - run: npm run build - uses: sigstore/gh-action-sigstore-python@5b79a39c381910c090341a2c9b0bf022c8b387e1 # v3.0.1 with: inputs: dist/fortify.min.js dist/fortify.js diff --git a/README.md b/README.md index 0bec85d..cb15c15 100644 --- a/README.md +++ b/README.md @@ -50,13 +50,13 @@ attacker could reach. Pin both with SRI so a bad CDN day fails closed instead of ```html ``` diff --git a/package.json b/package.json index d5e59ff..0ed6d9e 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,6 @@ }, "packageManager": "npm@10", "publishConfig": { - "access": "public", - "provenance": true + "access": "public" } } From 82154d8415e9240f93264582f2cacdb2f16f9763 Mon Sep 17 00:00:00 2001 From: Mario Heiderich Date: Wed, 17 Jun 2026 14:58:40 +0200 Subject: [PATCH 4/7] Update README.md --- README.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/README.md b/README.md index cb15c15..312a061 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,6 @@ # DOMFortify -[![License: MPL-2.0 OR Apache-2.0](https://img.shields.io/badge/license-MPL--2.0%20OR%20Apache--2.0-blue.svg)](LICENSE) -[![Build & Test](https://github.com/cure53/DOMFortify/actions/workflows/build-and-test.yml/badge.svg)](https://github.com/cure53/DOMFortify/actions/workflows/build-and-test.yml) -[![CodeQL](https://github.com/cure53/DOMFortify/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/cure53/DOMFortify/actions/workflows/codeql-analysis.yml) -[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/cure53/DOMFortify/badge)](https://scorecard.dev/viewer/?uri=github.com/cure53/DOMFortify) +[![npm](https://img.shields.io/npm/v/domfortify.svg)](https://www.npmjs.com/package/domfortify) [![License](https://img.shields.io/badge/license-MPL--2.0%20OR%20Apache--2.0-blue.svg)](https://github.com/cure53/DOMFortify/blob/main/LICENSE) ![npm package minimized gzipped size (select exports)](https://img.shields.io/bundlejs/size/domfortify?color=%233C1&label=gzip) [![Build & Test](https://github.com/cure53/DOMFortify/actions/workflows/build-and-test.yml/badge.svg?branch=main)](https://github.com/cure53/DOMFortify/actions/workflows/build-and-test.yml) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/cure53/DOMFortify/badge)](https://scorecard.dev/viewer/?uri=github.com/cure53/DOMFortify) [![Socket Badge](https://badge.socket.dev/npm/package/domfortify/latest)](https://badge.socket.dev/npm/package/domfortify/latest) DOMFortify turns on Trusted Types for a page and quietly takes over the browser's `default` policy, so that old, vulnerable code like `el.innerHTML = location.hash` gets sanitized before it ever hits From b7e50e4c1eac2b3454e152cf92568a342e54fe94 Mon Sep 17 00:00:00 2001 From: Mario Heiderich Date: Fri, 19 Jun 2026 12:09:09 +0200 Subject: [PATCH 5/7] chore: refactor code and update documentation --- README.md | 154 +++++++++++++------ dist/fortify.cjs.js | 317 +++++++++++++++++++++++----------------- dist/fortify.cjs.js.map | 2 +- dist/fortify.d.ts | 12 -- dist/fortify.es.mjs | 317 +++++++++++++++++++++++----------------- dist/fortify.es.mjs.map | 2 +- dist/fortify.js | 317 +++++++++++++++++++++++----------------- dist/fortify.js.map | 2 +- dist/fortify.min.js | 4 +- dist/fortify.min.js.map | 2 +- package.json | 2 +- src/fortify.ts | 282 ++++++++++++++++++----------------- website/index.html | 24 ++- 13 files changed, 832 insertions(+), 605 deletions(-) diff --git a/README.md b/README.md index 312a061..0187c4a 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,45 @@ # DOMFortify -[![npm](https://img.shields.io/npm/v/domfortify.svg)](https://www.npmjs.com/package/domfortify) [![License](https://img.shields.io/badge/license-MPL--2.0%20OR%20Apache--2.0-blue.svg)](https://github.com/cure53/DOMFortify/blob/main/LICENSE) ![npm package minimized gzipped size (select exports)](https://img.shields.io/bundlejs/size/domfortify?color=%233C1&label=gzip) [![Build & Test](https://github.com/cure53/DOMFortify/actions/workflows/build-and-test.yml/badge.svg?branch=main)](https://github.com/cure53/DOMFortify/actions/workflows/build-and-test.yml) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/cure53/DOMFortify/badge)](https://scorecard.dev/viewer/?uri=github.com/cure53/DOMFortify) [![Socket Badge](https://badge.socket.dev/npm/package/domfortify/latest)](https://badge.socket.dev/npm/package/domfortify/latest) +[![npm](https://img.shields.io/npm/v/domfortify.svg)](https://www.npmjs.com/package/domfortify) [![License](https://img.shields.io/badge/license-MPL--2.0%20OR%20Apache--2.0-blue.svg)](https://github.com/cure53/DOMFortify/blob/main/LICENSE) ![npm package minimized gzipped size](https://img.shields.io/bundlejs/size/domfortify?color=%233C1&label=gzip) [![Build & Test](https://github.com/cure53/DOMFortify/actions/workflows/build-and-test.yml/badge.svg?branch=main)](https://github.com/cure53/DOMFortify/actions/workflows/build-and-test.yml) [![CodeQL](https://github.com/cure53/DOMFortify/actions/workflows/codeql-analysis.yml/badge.svg?branch=main)](https://github.com/cure53/DOMFortify/actions/workflows/codeql-analysis.yml) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/cure53/DOMFortify/badge)](https://scorecard.dev/viewer/?uri=github.com/cure53/DOMFortify) [![Socket Badge](https://badge.socket.dev/npm/package/domfortify/latest)](https://badge.socket.dev/npm/package/domfortify/latest) -DOMFortify turns on Trusted Types for a page and quietly takes over the browser's `default` policy, -so that old, vulnerable code like `el.innerHTML = location.hash` gets sanitized before it ever hits -the DOM. You don't touch the code. You don't even need to know where the bug is. +DOMFortify turns Trusted Types on for a page and quietly takes over the browser's `default` policy, so +that old, vulnerable code like `el.innerHTML = location.hash` gets sanitized before it ever reaches the +DOM. You don't touch the code. You don't even need to know where the bug is. -It's for the sites you can't easily fix: complex apps or legacy apps nobody wants to touch, the third-party widget you -can't patch, the 2000+ `innerHTML` sinks written before anyone had heard of XSS. +It's for the sites you can't easily fix: sprawling apps and legacy code nobody wants to touch, the +third-party widget you can't patch, the 2000-plus `innerHTML` sinks written before anyone had heard of +XSS. -**Just ship the policy, and the browser automatically protects every HTML sink with DOMPurify or other sanitizers.** +**Ship the policy, and the browser routes every HTML sink through DOMPurify (or any sanitizer you give +it) on its way into the DOM.** ## Is there a demo? -Of course there is. [Play with DOMFortify](https://cure53.de/fortify) - throw payloads at a -deliberately broken page and watch the browser neutralize them before they reach the DOM. +Of course. [Play with DOMFortify](https://cure53.de/fortify) - throw payloads at a deliberately broken +page and watch the browser neutralize them before they reach the DOM. ## How it works -Trusted Types lets a page register one `default` policy that the browser calls for every dangerous +Trusted Types lets a page register one `default` policy that the browser consults for every dangerous sink. DOMFortify is that policy. -HTML goes through [DOMPurify](https://github.com/cure53/DOMPurify) -(or any sanitizer you hand it); script sinks like `eval` and `script.src` are refused outright, -because there is no safe way to sanitize executable code. +HTML goes through [DOMPurify](https://github.com/cure53/DOMPurify) (or any sanitizer you hand it). Script +sinks like `eval` and `script.src` are refused outright, because there is no safe way to sanitize +executable code. -## Usage +It does two jobs and no more: own the `default` policy, and route sinks. Whether enforcement is even on +is a CSP's job, not the library's - so DOMFortify reports honestly, through `status()`, whether the page +is actually protected. -Two parts. First, turn enforcement on with a CSP - a response header if you can set one: +## Quick start (CDN) + +Two parts. First, turn enforcement on with a CSP. A response header is the sturdiest option: ``` Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default dompurify; ``` -...or via `` tag if you cannot set any headers: +...or a `` tag when you cannot set headers (it must be present at parse time): ```html ``` -Second, load the sanitizer and then DOMFortify, **first thing in ``**, before anything an -attacker could reach. Pin both with SRI so a bad CDN day fails closed instead of open: +Second, load the sanitizer and then DOMFortify **first thing in ``**, before anything an attacker +could reach. Pin both with SRI so a bad CDN day fails closed instead of open: ```html ``` -That's it. The script installs itself on load. Want to check it actually worked? +That's it. This build installs itself on load. Check it actually worked: + +```js +DOMFortify.status().protected; // true when enforced, owning the policy, and the sanitizer is ready +``` + +> Pin a version you have vetted and regenerate the SRI hash whenever you change it, for example +> `openssl dgst -sha384 -binary purify.min.js | openssl base64 -A`. The two hashes above are for the +> exact versions named in the URLs. + +## Using it from npm + +```sh +npm install domfortify +``` + +The package ships three builds and TypeScript types, picked automatically by your tooling: + +| Build | File | What it does | +| ------------------- | --------------------- | --------------------------------------------------------------- | +| ESM | `dist/fortify.es.mjs` | `import { init } from 'domfortify'` - you call `init()` | +| CommonJS | `dist/fortify.cjs.js` | `const { init } = require('domfortify')` - you call `init()` | +| IIFE (auto-install) | `dist/fortify.min.js` | self-installs on load; this is the `