Help us shape the future of Go! Take the 2026 Go Developer Survey

htmlpolicy

package module
v0.32.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 27, 2026 License: AGPL-3.0 Imports: 23 Imported by: 8

README

htmlpolicy

pipeline status coverage report Go Reference

⚠️ Pre-v1 stability warning: until this library reaches v1.0.0, the API and policy language are liable to experience breaking changes between releases.

License & contributing. htmlpolicy is free software licensed under the AGPL-3.0. The author reserves the right to also distribute it under a separate commercial license. Because of this, any contribution must be offered under terms that let the author relicense it under any license, including a proprietary/commercial one — see CONTRIBUTING.md. (Want a non-AGPL commercial license? Open an issue or get in touch.)

A policy-driven HTML, CSS, and SVG sanitizer for Go.

Most sanitizers hardcode what's "safe" in library code. htmlpolicy takes a different approach: you write a declarative policy that says exactly what to strip, allow, defang, demote, or unwrap. Anything not matched by a rule passes through unchanged. Policies are plain text files, not Go code, so they can be reviewed, versioned, composed, and swapped without recompiling.

API reference and policy language documentation (godoc)

Playground

Try policies interactively at https://grepular.gitlab.io/htmlpolicy/ — the library compiled to WebAssembly, running entirely client-side. Edit a policy and HTML/CSS side by side and watch the sanitized output, lint warnings, verbose action log, and compiled policy update live. The policy, input, and output panes are syntax-highlighted, and the policy editor autocompletes verbs and builtin: presets (Ctrl-Space to trigger). Sanitization runs entirely in your browser — nothing is uploaded — and shareable links encode everything in the URL fragment. (The preview pane renders the sanitized result in a sandboxed iframe; in document mode that output is shown as-is, so a document whose policy still permits remote resources can cause the preview to load them.)

To run it locally: make playground and open http://localhost:8080.

Quick Start

import "gitlab.com/grepular/htmlpolicy"

// Parse a policy and apply it to an HTML fragment.
policy, err := htmlpolicy.Parse(`
    strip-tag script,style,noscript
    strip-tag iframe,object,embed
    strip-attr * on*
    strip-attr a[href^=javascript:] href
`)
if err != nil {
    log.Fatal(err)
}

output, modified, err := policy.ApplyHTML(fragmentContent)

For full HTML documents, use ApplyDocument instead:

output, modified, err := policy.ApplyDocument(documentContent)

For standalone CSS sanitization:

policy, _ := htmlpolicy.Parse("css-strip *\ncss-allow color,font-size")
out, modified, err := policy.ApplyCSS([]byte(".foo { color: red; margin: 10px; }"))
// out = ".foo {\ncolor: red;\n}"  — margin stripped, color kept

Both HTML and CSS expect UTF-8 input. Invalid UTF-8 is never passed through: each invalid sequence is replaced with U+FFFD exactly as a browser's decoder does (so a UTF-16 byte order mark cannot survive either), and the content is reported as modified. Use ConvertToUTF8 first if the input is in another encoding:

utf8Content, _ := htmlpolicy.ConvertToUTF8(rawContent, contentType)

HTML output is always UTF-8: a <meta charset> (or http-equiv=content-type) declaring another encoding is rewritten to utf-8, and the ESC character (U+001B, the ISO-2022-JP mode switch that can make a browser swallow quotes) is replaced with the equally inert U+007F (as are SO/SI, U+000E/U+000F, which that decoder turns into U+FFFD), so the output cannot be decoded into different markup. A <meta> the browser's encoding prescan would read inside raw text (<xmp>, <style>, …) is removed with that text, as is a comment holding a <meta …charset…>; the rendered output is then checked the way the prescan reads it (it can read a quoted value in raw text across the end tag), and comments or raw text that still expose a declaration are dropped (empty output if that is not enough). ApplyDocument puts <meta charset="utf-8"> first in the head (and starts the output with a UTF-8 BOM when that meta would fall outside the prescan's first 1024 bytes, or the input had one), and re-encoded data:text/html URIs get ;charset=utf-8. Whenever ApplyCSS/ ApplyInlineCSS process CSS, ESC, SO and SI become U+007F there too and a leading @charset naming another encoding is rewritten to utf-8.

Features

  • HTML sanitization — strip, allow, defang, demote, comment-out, placeholder, or unwrap elements and attributes using CSS selectors
  • CSS sanitization — filter properties, values, at-rules, url() schemes, url() data-URI content types, selector pseudo-classes/elements, and @media blocks (by media feature, or by whether they can apply to a medium such as print — css-strip-medium print removes print-only tracking CSS, including <style media>/<link rel=stylesheet media>) in style attributes, <style> elements, and standalone stylesheets
  • URL scheme filtering — allow/strip/defang URLs by scheme (html and css independently) with per-URL evaluation for multi-URL attributes (srcset). Schemeless URLs split into two pseudo-schemes: :relative for URLs resolved against the embedding page (/img/x.png, #section) and :protocol-relative for authority-relative ones (//cdn.example.com/x), which inherit the page's scheme and load remotely — so allowing :relative never silently allows a remote host. A :relative URL is still a real request to the embedding page's own origin, sent with its cookies: allowed on <img src>, user content can make the viewer's browser issue same-origin GETs such as /logout (GET-CSRF). A special scheme written without // (https:/logout, https:x) is relative to a page of the same scheme, so it must pass both its scheme's rules and :relative's. Defanging an <a>/<area> href prefixes the scheme in place (href="htmlpolicy-defanged:bitcoin:...") so non-allowlisted app-protocol links stay hoverable/copyable but inert; other attributes are renamed
  • Content-type filtering — control which MIME types are allowed in data URIs with wildcard patterns
  • Recursive sanitization — data:text/html, srcdoc, and meta refresh URLs are recursively sanitized with depth limiting (every XML data URI — SVG, XHTML, text/xml, *+xml — is always stripped; the HTML5 parser cannot model what the browser's XML parser builds)
  • SVG/MathML namespace validation — prevents mutation XSS from foreign-content elements in the wrong namespace
  • SMIL animation sanitization — applies attribute and scheme rules to animated values, preventing runtime bypasses
  • Parser-differential prevention — always re-serializes through Go's HTML5 parser by default
  • URL rewriting — per-call callback hook (via WithApplyURLRewriter) for rewriting URLs in the final output (HTML attributes, CSS url() values, srcset, SMIL); scoped to a single Apply call so it can safely close over per-call state without concurrency concerns. A companion WithApplyURLPrefetcher hook delivers the full URL set up front (same set, same order) so callers can fetch resources in parallel before the rewriter runs. URLs are presented the way the browser's URL parser reads them — leading/trailing C0-or-space trimmed, tab/LF/CR removed — so a padded src="https://host/logo.png " can be handed straight to net/url. Each URL's context says how a browser uses it (Load: fetched automatically, followed only on a click, an automatic meta refresh, a ping beacon, or never) and, for an automatic fetch, its Fetch request destination (RequestType: image, style, script, font, …) — including SMIL animations, classified by the attribute they animate on their target — so a tracker blocker need not reimplement that mapping. A replacement is written so the browser reads it back as the same URL (percent-encoded or CSS-escaped where its slot — a srcset candidate, an SVG url(), a SMIL keyframe, a meta refresh — needs it), or the entry is removed; the original URL is never left live in its place, so a blocking rewriter does not need to know any slot's separators
  • Original-URL preservation — WithOriginalURLAttr records the pre-rewrite value of every attribute the URL rewriter changed as a companion {prefix}-original-{attr} attribute (e.g. htmlpolicy-original-href), so rewritten URLs stay recoverable from the output; attacker-supplied attributes matching the reserved pattern are stripped first, so a preserved value can't be spoofed (WithKeepOriginalURLAttrs opts out for re-processing trusted prior output)
  • Policy composition — include directives for combining policies
  • Policy linting — Policy.Lint() (and htmlpolicy -lint) flags likely mistakes: URL attributes reopened with no scheme baseline (or with a dangerous scheme re-allowed after an earlier strip — for data:, unless a strip-content-type * ATTR * baseline governs the attribute or the reopen names only image/audio/video elements, whose data: payloads are inert), dangerous non-URL attributes (on*/nonce/is/shadowrootmode/srcdoc) re-allowed after a strip, dangerous elements (script, iframe, object, embed, base, meta, link, template, …) re-allowed after an earlier rule neutralized them, javascript:/data: re-allowed in CSS url() after a css-strip-scheme, allow-content-type rules covering an XML type (always stripped regardless), allow-* rules with no effect, rules made dead by a later rule, and unsafe unwrap-tag * catch-alls
  • Out-of-band URL checks — Policy.URLAction(element, attr, url) reports what Apply would do with a URL that never passes through Apply (a calendar or contact link, say): scheme and content-type rules plus the built-in strips (XML data: types, unreproducible charsets), and whether an allowed URL is kept only in rewritten form (data:text/html is re-encoded after recursive sanitization) and so must not be used as it is; URLScheme(url) returns the scheme a browser reads from a URL
  • Concurrency safe — Policy is safe for concurrent use after creation

Security Guidance

This library is policy-driven — it does not hardcode any element or attribute as "safe" or "dangerous". You must write policies that account for the HTML features your application needs to defend against.

Rather than copy a baseline into your codebase (where it bit-rots as browsers add features), include a maintained preset that ships with the library and improves on go get -u. include builtin:NAME works in any policy with no resolver configured, and later rules override the preset:

// Blocklist: strip active content and dangerous schemes.
policy, _ := htmlpolicy.Parse("include builtin:blocklist")

// Allowlist: start fail-closed, then re-allow exactly what you need.
policy, _ := htmlpolicy.Parse(`
    include builtin:allowlist-base
    allow-tag p,div,span,b,i,em,strong,a,ul,ol,li,br
    allow-attr * class
    allow-tag a
    allow-attr a href
    include builtin:url-safe
`)
Preset Purpose
builtin:blocklist Strong blocklist baseline — strips active-content elements/attributes (including frames), javascript:/vbscript: on every URL attribute, and data: on auto-fetching attributes (XML/SVG data URIs are always stripped, preset or not); everything else passes through
builtin:allowlist-base Fail-closed skeleton — strips all elements, attributes, schemes, content types, and CSS (including CSS url() schemes and data-URI types), so anything you don't explicitly re-allow is dropped. Re-allowing a URL-bearing CSS property therefore also needs a css-allow-scheme
builtin:url-safe Restrict every URL attribute to http, https, mailto, and relative URLs, and CSS url()/@import to the same set minus mailto — pair with rules that reopen URL-bearing attributes. data: is stripped in CSS too; re-allow with css-allow-scheme if you need inline images
builtin:strip-dark-mode Remove dark-mode signals — prefers-color-scheme: dark @media overrides, the color-scheme property and <meta>, and Apple Mail's legacy supported-color-schemes property and <meta>. Strips the dark-mode mechanism, not author-chosen colors, so it does not guarantee a light render for a page that defaults to dark

Presets are ordinary policy text: BuiltinPolicy(name) returns the source, BuiltinPolicyNames() lists them, and Policy.String() shows them inlined.

Allowlist safety: when you reopen a URL-bearing attribute (e.g. allow-attr a href), also constrain its schemes (include builtin:url-safe, or your own allow-scheme rules). Reopening an attribute without a scheme baseline lets javascript: URLs through.

Documents: under ApplyDocument (and inside srcdoc/data:text/html) the document's own <html>, <head> and <body> survive strip-tag * as bare structure (attributes removed) so the allowed content inside them is kept; add allow-tag html,head,body (after the baseline — last match wins) to keep attributes like <html lang>. Any other catch-all selector (strip-tag *,script, strip-tag :not(p)) counts as the baseline too. A rule naming them still applies when it is the last match: strip-tag head empties the head.

strip-comments
strip-tag script,noscript,style,base,link
strip-tag iframe,fencedframe,object,embed,applet
strip-tag frame,frameset,noframes,noembed
strip-tag template,portal
strip-tag form,input,textarea,select,button
strip-tag meta[http-equiv=refresh]
strip-attr * on*
strip-attr * style
strip-attr * is
strip-attr * nonce
strip-attr template shadowrootmode
strip-scheme * * javascript,vbscript
strip-scheme * src data
strip-scheme * srcset data
strip-scheme * imagesrcset data
strip-scheme * poster data
strip-scheme * background data
Key threats to consider
  • Declarative Shadow DOM: <template shadowrootmode="open"> causes template content to become live DOM. Strip <template> or the attribute.
  • <iframe srcdoc>: Contains raw inline HTML. Strip iframes or the srcdoc attribute.
  • <fencedframe>: Chrome-specific iframe-like element. Treat like iframe.
  • is attribute: Triggers custom element constructors, has mXSS edge cases. Strip in security contexts.
  • nonce attribute: CSP bypass if an attacker controls it. Always strip.
  • SVG/XHTML/XML data URIs: Browsers parse these with an XML parser, but this library uses an HTML5 parser, so they cannot be sanitized at all — <td> outside a table, nested <form>, <textarea>/<xmp> content and <body onload> are live to an XML parser and invisible to every rule, and an XHTML-namespaced <script> runs inside any XML document. Every XML media type (image/svg+xml, application/xhtml+xml, text/xml, application/xml, *+xml) in a URL attribute is therefore always stripped, with no opt-in; allow-content-type cannot re-enable them and lint warns if a policy tries. Inline <svg> elements are unaffected, and a url(data:image/svg+xml,…) in CSS is still best-effort recursed (no script runs in an SVG loaded as a CSS image).
  • Weird data-URI charsets: a data URI declaring a charset the library can't reproduce (UTF-7, UTF-16, …) is stripped, since the browser would decode different bytes than the sanitizer saw (e.g. data:text/html;charset=utf-7,+ADw-script+AD4-) — as is one whose body starts with a UTF-16 byte order mark or declares another encoding itself (<meta charset>, @charset, an XML declaration). UTF-8, US-ASCII, and no charset are recursed normally. A data URI that would be sanitized is also stripped when its ;base64 is not the last parameter (Chromium and the WHATWG processor decode the body differently) or its media type is ambiguous (control, non-ASCII or percent-encoded bytes).
  • DOM clobbering via id/name: an allowed id (or name on form, img, embed, object, iframe) lets user content define named properties on the host page — <a id="config"> shadows an undefined window.config, and <form name="x"> adds document.x — which can redirect the page's own scripts. Don't allow id/name on embedded user content unless the host page's scripts are written to tolerate it.
  • unwrap-tag * as an allowlist catch-all: Unwrap removes a wrapper element but keeps its children in flow, mirroring how browsers treat unknown tags (e.g. <dov>hello</dov> renders as hello). It is useful for letting unrecognized structural tags pass through transparently, but it MUST be paired with explicit strip-tag rules for any element whose presence indicates active content or whose textual content should not be visible. At minimum: strip-tag script,style,iframe,object,embed,form,input,textarea,select,button,link,base and strip-tag meta[http-equiv=refresh]. Without those, raw <script>/<style> text would leak into the output as visible text once the wrapper is removed, and unknown-named elements that carry active content would be exposed by name.

CLI Tool

go install gitlab.com/grepular/htmlpolicy/cmd/htmlpolicy@latest
htmlpolicy [flags] <policy-arg>... < input.html > output.html

Each argument is either a path to a policy file or inline policy text. Multiple arguments are concatenated in order (last match wins):

htmlpolicy policy.txt < input.html > output.html
htmlpolicy 'strip-tag script' < input.html > output.html
htmlpolicy base.policy 'strip-tag style' 'allow-scheme * href https'
htmlpolicy base.policy overrides.policy 'strip-tag img'
Flag Description
-fragment Parse input as an HTML fragment instead of a full document
-detect-charset Detect and convert input charset to UTF-8
-content-type Content-Type header for charset detection (only used with -detect-charset)
-prefix Override the prefix for defang/comment-out actions (default: htmlpolicy)
-verbose Log each sanitization action to stderr
-lint Check the policy for likely mistakes and exit (does not read stdin)

-lint exits 1 when the policy produces warnings — the same status as a policy that fails to parse — so a CI check can treat any non-zero exit as "policy needs attention". A clean policy prints a confirmation to stderr and exits 0.

Warnings about rules that came from an included file or preset are labelled with that source, e.g.:

builtin:blocklist:20: tag rule (strip-tag) is overridden by the allow-tag rule on /etc/htmlpolicy/policy.txt:2 (last match wins by order)
Verbose Logging

The -verbose flag (or WithVerboseLog in the library) logs each action to stderr:

strip-tag <script> (/etc/htmlpolicy/policy.txt:1: strip-tag script)
strip-attr <p> onclick (/etc/htmlpolicy/policy.txt:3: strip-attr * on*)
strip-scheme <a> href javascript:alert(1) (/etc/htmlpolicy/policy.txt:5: strip-scheme * href javascript)
css-strip position (/etc/htmlpolicy/policy.txt:7: css-strip position)
css-strip-at @import (/etc/htmlpolicy/css-rules.policy:2: css-strip-at import)

Each rule is identified as source:line, like lint warnings: the policy file (printed as the absolute path of the file argument, as in the samples above), an included file, a builtin: preset, or arg N for the Nth command-line argument when it is inline policy text — arg 2:1 is line 1 of the second argument (parse errors read arg 2: line 1: …). Rules from policy text passed directly to Parse in the library show as line N.

Requirements

The Go version named by the go directive in go.mod, or later.

Testing

make test

100% test coverage is required. Tests fail if coverage drops below 100%.

License

See LICENSE. htmlpolicy is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0).

A separate commercial license is available for those who cannot or do not wish to comply with the AGPL — open an issue or get in touch to arrange one. This is possible because the author holds the copyright to all original code; contributions are therefore accepted only under terms that grant the author the right to relicense them under any license, including a commercial one (see CONTRIBUTING.md). The commercial license covers htmlpolicy's own code only; third-party dependencies remain under their respective (permissive) licenses.

Acknowledgements

This project was developed by Mike Love, with the assistance of Claude Code, Anthropic's AI coding tool.

Support/Appreciate my work

  • Bitcoin: 1PQLtWnjUi1itHLG6QCQeHM3Nxua8pRsq1
  • Paypal

Documentation

Overview

Package htmlpolicy implements a policy-driven HTML, CSS, and SVG sanitizer.

Warning: until this library reaches v1.0.0, the API and policy language are liable to experience breaking changes between releases.

This library was built entirely through vibe coding with Claude Code (https://claude.ai/claude-code).

Unlike sanitizers that hardcode safety rules in library code, htmlpolicy uses declarative policies: plain text files where each line starts with an action verb followed by CSS selectors and optional arguments. Rules are evaluated top-to-bottom with last-match-wins semantics. Anything not matched by a rule passes through unchanged.

Policies can be reviewed, versioned, composed via includes, and swapped without recompiling. This makes htmlpolicy suitable for email rendering pipelines, CMS content filtering, user-generated content sanitization, and any HTML-to-HTML transformation where the rules need to be configurable.

Entry Points

There are five entry points for applying a policy:

All entry points expect UTF-8 input and return UTF-8 output. Input that is not valid UTF-8 is never passed through: every invalid byte sequence is replaced with U+FFFD before anything else reads it, exactly as a browser's UTF-8 decoder does (the WHATWG decoder: one U+FFFD per maximal invalid subpart, so E0 A0 41 becomes U+FFFD "A"), and the call reports the content as modified. This covers embedded data:text/html and data:text/css bodies (whose decoded bytes can be anything) and a URL rewriter's replacements, and it means a UTF-16 byte order mark (FE FF or FF FE, invalid in UTF-8) can never survive to make a browser decode the output as UTF-16; under WithPreserveOriginal such input is always re-serialized. Use ConvertToUTF8 first when the input is in another encoding: the replacement only keeps the sanitizer and the browser reading the same characters, it does not recover the text. Policy.ApplyHTML and Policy.ApplyDocument make sure a browser cannot read that output in another encoding with a different structure, at every depth of embedded content and regardless of policy: every U+001B (ESC — the ISO-2022-JP mode switch, which makes the decoder swallow ASCII quotes and angle brackets) and every U+000E/U+000F (SO/SI, which that decoder turns into U+FFFD, a CSS identifier character) in text, comments and attribute values is replaced with U+007F (DEL, a control character inert in the same places; an attribute whose name holds one is removed) before any rule is evaluated, so selectors, scheme rules and SMIL animation target ids see the DEL the browser will see, and every <meta charset> or <meta http-equiv=content-type> declaring an encoding other than UTF-8 is rewritten to "utf-8". The browser's encoding prescan reads a <meta> anywhere in the bytes, raw text included, and the multi-byte encodings (Shift_JIS, GBK, Big5, …) take ASCII bytes as the second byte of a character, so a <script>, <style>, <xmp>, <iframe> or <plaintext> whose text holds such a <meta> has that text removed, and a comment holding a <meta> that names a charset is dropped. Because the prescan also reads a quoted attribute value inside raw text across the element's end tag (`<style><x y="</style><!--"><meta …>-->`), the rendered output is finally scanned the way the prescan and the tokenizer read it; if a <meta> declaring another encoding is still visible, every comment holding a <meta> and every such raw-text element holding markup is removed, and if that does not help the output is empty. All of these are reported as modifications, and under WithPreserveOriginal input holding a raw ESC, SO or SI byte, or declaring an encoding other than UTF-8 anywhere, is always re-serialized. Policy.ApplyDocument also puts <meta charset="utf-8"> first in the <head> (unless one is already there), whatever the policy says about <meta>, and starts the output with a UTF-8 byte order mark when that <meta> does not end within the first 1024 bytes the encoding prescan reads (a long doctype, leading comments, large <html>/<head> attributes) or the input started with one; a BOM overrides both the transport charset and any <meta>, and a leading BOM in the input is never parsed as body text. A data:text/html URI whose body is re-encoded gets ";charset=utf-8" when it declares no charset (its parameters are replaced by ";charset=utf-8" when they hold quotes, whitespace or empty parts that MIME parsers read differently). (srcdoc needs neither: it is a string, never decoded from bytes.) Policy.ApplyCSS and Policy.ApplyInlineCSS do the same whenever they process the content (the policy has CSS rules or a URL rewriter is set): invalid UTF-8 becomes U+FFFD, ESC, SO and SI become DEL, and a leading @charset declaring an encoding other than UTF-8 is rewritten to @charset "utf-8". (Content they do not process — no CSS rules and no rewriter — is returned unchanged, invalid bytes included.) A Policy is safe for concurrent use by multiple goroutines.

Selectors

Selectors use CSS syntax, compiled via github.com/andybalholm/cascadia. The policy accepts this subset and rejects anything else at parse time:

tag                     match all <tag> elements
*                       match all elements
#tracking               ID selector
.ads                    class selector
tag[attr]               attribute present
tag[attr=value]         exact attribute match
tag[attr~=word]         space-separated word match
tag[attr|=en]           exact match, or prefix followed by "-"
tag[attr^=prefix]       prefix match
tag[attr$=suffix]       suffix match
tag[attr*=substring]    contains match
tag[attr=value i]       i flag (accepted; see below)
"div.ads > a"           child combinator (quote when spaces present)
"svg animate"           descendant: <animate> inside <svg>
"h1 + p"                next-sibling combinator
"h1 ~ p"                subsequent-sibling combinator (see Cost)
:not([href^=https])     negation pseudo-class

The pseudo-classes are :not(), :has() and :haschild() (a selector list; :haschild(x) matches an element with a child matching x), :nth-child(), :nth-last-child(), :nth-of-type() and :nth-last-of-type() (An+B, odd or even), :first-child, :last-child, :first-of-type, :last-of-type, :only-child, :only-of-type, :empty, :root, :lang(), :link, :enabled, :disabled, :checked, :input (input, select, textarea and button), and :hover, :active, :focus, :visited and :target, which never match a static document. Rejected: pseudo-elements (::before), every other pseudo-class (cascadia implements neither :is() nor :where()), namespace prefixes (svg|a, *|*), comments, and cascadia's non-standard extensions — the text matchers :contains(), :containsown(), :matches() and :matchesown(), whose cost grows with the text under every element (div:contains(zzz) over 1 MB of 500-deep <div>s took about 5 seconds), and the attribute operators != (write :not([attr=value])) and #=. A Unicode space that CSS does not treat as whitespace (NBSP, U+2028, …) is an error outside a value: CSS reads it as part of a name, so "td<NBSP>a" would be one never-matching type selector.

Comma-separated selector groups (e.g. "script,style,iframe") compile into a single rule; empty groups are ignored. When a selector contains spaces, quote it so the parser distinguishes it from arguments: strip-tag "svg animate".

Cost: every selector shape is matched in time linear in the document except the subsequent-sibling combinator "~", which cascadia evaluates by scanning all earlier siblings of each element: O(siblings²) per parent (40,000 sibling <p> elements, 440 KB, take about 7 seconds under strip-tag "x ~ p"). Prefer "+", :nth-child or a class; Policy.Lint warns about a "~" combinator.

The parser decodes every CSS escape once and lowercases names and values, and keeps the selector in a canonical form: the text cascadia compiles and Policy.String writes. It has no spaces in lists or around combinators other than the descendant one, every attribute value quoted, and every escape it needs as \HEX followed by a space ("a , b" is written a,b, [title=x\,y] as [title="x,y"], .\31 0 as is). A hex escape of zero, a surrogate or a value past U+10FFFF means U+FFFD, as does a NUL byte or invalid UTF-8 in the selector.

An attribute value need not be quoted ([width=1], [href^=javascript:]). Attribute values follow CSS: whitespace after the operator is ignored ([title= "x y"] is the value "x y"); an unquoted value runs to the first unescaped "]", and in it a backslash escapes the next character ([title=x\,y] and [title=\2c] both mean a comma, [title=x\]] a bracket) and unescaped trailing ASCII whitespace is dropped; only ASCII whitespace separates a trailing i flag — a non-breaking space is part of the value. The i flag adds almost nothing: values are lowercased on both sides, so matching is case-insensitive without it; with it cascadia also folds the few characters lowercasing leaves distinct (ſ matches s). The s flag is rejected, since a case-sensitive match cannot be expressed.

Tag matching is case-insensitive. Attribute value matching normalizes control characters and case internally for security, without modifying the output. This prevents bypasses like " javascript:" evading a [href^=javascript:] prefix check, or JaVaScRiPt: evading case-sensitive matching. The normalization applies to every element a selector inspects — ancestors, siblings and :has() descendants as well as the matched element — so div:has(a[href^=javascript:]) also matches an href of "JAVASCRIPT:…", and div.Ads img matches inside <div class="ads">.

Selectors are matched against the input document as it was before any rule acted on it: an ancestor whose attributes another rule stripped, a preceding sibling another rule removed, or a wrapper that was demoted or unwrapped is still seen as written. With "strip-attr div class", strip-tag "div.ads img" still strips the images, and with "strip-tag script", strip-tag "script + img" still matches the image that followed the script. SMIL animation values are judged against the same view of their target element.

HTML Tag Verbs

Tag verbs determine what happens to matched elements. Each takes a single selector and nothing else — attributes are handled only by the -attr verbs below:

strip-tag SELECTOR[,...]         remove element and all content
comment-out-tag SELECTOR[,...]   wrap in HTML comment
placeholder-tag SELECTOR[,...]   replace with [removed: tag] label
demote-tag SELECTOR[,...]        convert to <div>/<span>
allow-tag SELECTOR[,...]         keep element (attributes still filtered separately)
unwrap-tag SELECTOR[,...]        remove the element's tags but keep its children

Inline elements demote to <span>, all others to <div>. The namespace is cleared, so foreign elements (SVG, MathML) become plain HTML. Inside SVG or MathML content outside an integration point (<foreignObject>, <desc>, <title>, <mi>, <mo>, <mn>, <ms>, <mtext>, HTML <annotation-xml>), where an HTML element cannot appear, demote-tag unwraps the element instead: its children are kept in its place. Demoting an <svg> or <math> root itself does make it a <div>, but its SVG/MathML child elements cannot stay inside an HTML element and are removed by the namespace validation below, so little more than text placed directly in the root survives.

Unwrap removes the wrapper but leaves the children in flow (mirroring how browsers treat unknown elements). The wrapper's attributes are discarded; void/empty elements with no children are stripped. As a catch-all in an allowlist policy, "unwrap-tag *" MUST be paired with explicit strip rules for any element whose presence indicates active content or whose text content should not be visible — at a minimum: script, style, iframe, object, embed, form, input, textarea, select, button, link, base, meta. Without those, raw <script>/<style> text would leak into the output as visible text once the wrapper is removed.

Children of a demoted or unwrapped element are processed before the element is renamed or removed, so selectors match the original structure: with "demote-tag form", strip-tag "form input" still matches the inputs. A selector written against the transformed shape (e.g. "div input" for a demoted form, or "section > b" across an unwrapped wrapper) does not.

A tag verb takes exactly one selector token. A bare second word is an error, because it is almost always a mistake — either an attribute name (use an -attr verb) or an unquoted descendant combinator. To match a descendant combinator, quote the whole selector so the space is unambiguous:

# keep <a>
allow-tag a
# ...and keep its href attribute
allow-attr a href
# strip <a> elements inside <td> (descendant selector)
strip-tag "td a"
# ERROR: unexpected "a" after selector "td"
strip-tag td a

Comments in a policy are full-line only (a line starting with #); a trailing "# …" after a rule is read as part of its arguments.

HTML Attribute Verbs

Attribute verbs filter attributes on matched elements. Attribute name patterns support a trailing * glob: on* matches onclick, onload, etc.

strip-attr SELECTOR ATTR[,...]      strip named attrs
allow-attr SELECTOR ATTR[,...]      allow only named attrs
defang-attr SELECTOR ATTR[,...]     rename to PREFIX-defanged-ATTR

URL Scheme Verbs

Scheme verbs filter URL attributes by the URL's scheme. Use :relative to match document-relative URLs, :protocol-relative to match authority-relative ones, * to match any scheme:

allow-scheme SELECTOR ATTR SCHEME[,...]     allow URLs with listed schemes
strip-scheme SELECTOR ATTR SCHEME[,...]     strip URLs with listed schemes
defang-scheme SELECTOR ATTR SCHEME[,...]    neutralize URLs when scheme matches

For allowlist behavior, pair with a strip-scheme baseline:

strip-scheme * href *
allow-scheme * href https,mailto,:relative

Each scheme must be a valid URL scheme (an ASCII letter followed by letters, digits, "+", "-", or "."), the wildcard *, or one of the two pseudo-schemes :relative and :protocol-relative. Comma-separated lists reject a stray comma between entries (e.g. "http,,https") to catch typos.

A meta refresh URL (<meta http-equiv=refresh content="0;url=...">) is matched as element meta, attribute content, by both scheme and content-type rules. A strip removes the whole content attribute: leaving "0;url=" behind would make the browser reload the current page.

Browsers do not all read a refresh value the same way, so a content value on which they could disagree is removed whatever the policy, before any rule sees it: one whose delay (the text before the first ";", "," or space) is anything but digits and dots — the WHATWG refresh steps ignore such a refresh, but Chromium and WebKit parse the delay as a number, so "0e0;url=…", "+0;url=…" and "-0;url=…" navigate in them — and one whose URL starts or ends with Unicode whitespace (U+3000, U+2003, …), which Chromium and WebKit trim and the URL parser keeps, so that "0;url=\u3000data:text/html,…" is a relative URL to one and a data: document to the other — and one whose quoted URL ends at a different quote for each: the spec ends it at the first matching quote, Chromium and WebKit at the last, so "0;url='data:text/html,hi'<script>…</script>'" carries the script only for them. The removal counts as a change, so such a value is never returned verbatim under WithPreserveOriginal.

SVG presentation attributes that take a CSS image or paint value (fill, stroke, filter, clip-path, mask, marker, marker-start/-mid/-end, cursor) are read as CSS: every url() in the value — including an escape-encoded function name such as \75 rl( — and every string inside image-set(), cross-fade(), image() or src() (vendor-prefixed or not) is a URL matched by scheme rules under the attribute's own name; the most restrictive action wins for the whole attribute. A var(), env() or attr() inside one of those functions, or an attr() anywhere in the value, hides the URL it substitutes, so the attribute is removed whenever the policy has scheme rules. A data: URI in any of those slots is matched by content-type rules under the attribute's name too (most restrictive slot wins: a strip removes the attribute, a defang renames it), and one naming an embedded document — text/html or any XML type — removes the attribute whatever the policy, since such a slot is not recursed. A SMIL animation of one of these attributes is judged keyframe by keyframe the same way.

Relative URLs: :relative vs :protocol-relative

A URL with no scheme is classified as one of two distinct pseudo-schemes, because the two behave nothing alike:

  • :relative — a reference resolved against the current document's URL: "/img/logo.png", "logo.png", "#section", "?q=1". It never leaves the page's origin, but it is still a real request to that origin, sent with its cookies — allowed on an auto-fetching attribute such as <img src>, user content can make the viewer's browser issue a same-origin GET such as "/logout" (GET-CSRF).
  • :protocol-relative — an authority-relative (commonly "protocol-relative") URL such as "//cdn.example.com/x". It has no scheme of its own, but the browser inherits the containing document's scheme and fetches from the host after the slashes, so on an https page "//evil.example/x" is exactly "https://evil.example/x" — a remote load.

Only :protocol-relative matches the second kind. This matters for the standard allowlist shape, where an author writing :relative means "references to my own origin" and would not expect it to permit an arbitrary remote host:

strip-scheme * * *
# no remote via //host:
allow-scheme * * http,https,:relative
# or, to permit //host:
allow-scheme * * http,https,:relative,:protocol-relative

The wildcard * matches both kinds, so any "strip every scheme" baseline still covers authority-relative URLs. Conversely a rule naming only :relative does not act on them — "strip-scheme * href :relative" leaves "//host/x" alone; add :protocol-relative to cover both.

Classification matches what a browser's URL parser does rather than the literal bytes: tab, LF, and CR are ignored wherever they appear (browsers strip them from URLs), a backslash counts as a slash, and CSS escapes in CSS values are decoded first. So "/\/host", "\\host", "/<tab>/host", and the CSS "url(\2f\2f host)" are all :protocol-relative. When a <base href> is present these URLs are resolved against it before rules run, and are then classified by the resulting real scheme.

A URL with a special scheme (http, https, ws, wss, ftp) that is not followed by "//" — "https:logout", "https:/account/delete", "http:x" — is read two ways by the browser: on a page with the same scheme it is a relative reference (a same-origin request, like :relative), on any other page the missing slashes are ignored and it is an absolute URL whose host follows the colon. It is therefore evaluated both as its scheme and as :relative, and the more restrictive outcome wins: the allowlist above permits "https:logout" only when it allows both https and :relative, and a strip of either strips it. With a <base href> it is resolved first, as the browser does ("https:x.png" against an https base is the relative "x.png"; "http:evil.example/x" against it is "http://evil.example/x").

Every URL is judged under two readings, and again the more restrictive outcome wins. Scheme matching first normalizes fail-closed — it removes zero-width and invisible characters and every C0 control, so "\u200bjavascript:x" and "java\x01script:x" are javascript: URLs to the rules — but a browser does not remove those characters: it stops reading the scheme at them or percent-encodes them. So each URL is also judged as the browser reads it (the URL parser's own input cleanup only): "https:/\u200b/../account/delete" is an https URL to the normalization and, to the browser, the same-origin relative reference "https:/%E2%80%8B/../account/delete" (GET /account/delete), and "ht\x01tps://…" is a relative path. A rule against either reading — a strip of javascript, or of :relative (as the allowlist above implies) — therefore strips the URL. This holds for HTML URL attributes, srcset entries, meta refresh, SVG url() values, SMIL values and CSS url().

defang-scheme is attribute-aware. For href on <a> and <area> (including an SVG <a>'s xlink:href) it prefixes the URL's scheme in place with PREFIX-defanged — for example href="bitcoin:..." becomes href="htmlpolicy-defanged:bitcoin:...". The PREFIX-defanged scheme is registered nowhere, so the link is inert, but the URL stays in href and therefore remains hoverable and copyable. This lets a reader copy or inspect app-protocol links (bitcoin:, tel:, slack:, ...) that the policy does not allowlist. The transform is fail-safe (prefixing only ever breaks a URL, never activates one) and idempotent (a value that already carries the PREFIX-defanged scheme passes through unchanged on re-apply). For every other attribute, defang-scheme renames the attribute to PREFIX-defanged-ATTR (so an auto-loaded resource such as <img src> never fires a request) just like defang-attr.

Multi-URL attributes (srcset, imagesrcset, ping, archive, attributionsrc) get per-URL evaluation — each URL is independently matched against the rule chain — and always use the attribute-rename form (they are auto-loaded, not hovered).

Content-Type Verbs

Content-type verbs control what happens to data URIs based on their MIME type. MIME patterns support a single * wildcard: image/*, *+xml, */xml. The type matched is the one a browser uses: lowercased type/subtype without parameters, and text/plain when the data URI names none or an invalid one (data:,… data:;charset=utf-8,… data:foo,…).

allow-content-type SELECTOR ATTR TYPE[,...]     allow data URIs with listed types
strip-content-type SELECTOR ATTR TYPE[,...]     strip data URIs with listed types
defang-content-type SELECTOR ATTR TYPE[,...]    rename attr when type matches

text/html data URIs are recursively sanitized (the HTML5 parser matches the browser), while XML data URIs — any type a browser parses with its XML parser: image/svg+xml, text/xml, application/xml, application/xhtml+xml, and every other */xml or *+xml type (application/xhtml is treated as XHTML too) — are always stripped. There is no allow-content-type opt-in: the HTML5 parser restructures an XML document (it moves a <td> outside a table, drops a nested <form>, reads <textarea>/<xmp>/<iframe> content as inert text, and models neither DTD entities nor namespace prefixes), so no rule can judge what the browser builds, and an XHTML-namespaced <script> runs inside any XML document, SVG included. This covers URL attributes, srcset/ping entries, meta refresh URLs, SVG url() presentation attributes (fill, filter, mask, cursor, …; there text/html is stripped too, as it is not recursed) and SMIL animated values; inline <svg> elements, and SVG in a CSS url() (see css-*-content-type below), are unaffected. Content-type rules apply at all of those sites, matched under the attribute's name. Policy.Lint warns about an allow-content-type rule whose type list names or globs (image/*, *+xml, *) an XML type. Other data URIs pass through unchanged unless a content-type rule matches.

A text/html document (or any XML type) as a form's action or a button's or input's formaction is always removed, whatever the policy, rather than sanitized: a GET submission appends the form's fields to the URL as a query, and the data: URL processor keeps the query in the document's body, so field names and values the recursion never saw (an attacker's <input type=hidden value="<script>…">) become its markup.

Independently, any data URI declaring a charset the library cannot reproduce faithfully (anything other than UTF-8, US-ASCII, or none — e.g. UTF-7 or UTF-16) is stripped, because the content is decoded to raw bytes and handed to the UTF-8/ASCII HTML5 parser; a browser honoring the declared charset would decode different markup (a charset differential). UTF-8's aliases (unicode-1-1-utf-8, unicode11utf8, unicode20utf8, utf8, x-unicode20utf8) are accepted as equivalent to "utf-8". The body counts too, since a browser lets it override the declared charset: a body starting with a UTF-16 byte order mark, or declaring a different encoding itself (a <meta charset>, an @charset rule, an XML declaration), is stripped the same way. For the same reason a data URI whose content would be sanitized (text/html, text/css) is stripped when a "base64" parameter appears anywhere but last (data:text/html;base64;charset=utf-8,…): the WHATWG data: URL processor reads that body as percent-encoded text, Chromium as base64. A data URI's media type is read the way a browser reads it, after the URL parser has percent-encoded control and non-ASCII bytes: "text/html\u00a0" is the inert type text/html%c2%a0, not text/html. Where such bytes (or percent escapes) would make the type text/html, XML or text/css if trimmed or decoded, the data URI is stripped rather than guessed at.

A data: URI whose recursive sanitization is refused — an XML type, a non-reproducible charset (declared, a UTF-16 BOM, or declared in the body), a base64 parameter that is not the last one, an ambiguous media type, or the recursion-depth limit (16 levels) — has its whole attribute removed rather than left as an empty value: an empty src/href is itself a URL (the document's own), not "no URL". In a meta refresh the whole content attribute is removed, since a residual "0;url=" reloads the document; in a srcset/ping list only that entry is dropped; a SMIL animation left with no value is removed; in CSS the declaration or @import carrying it is dropped (an empty url() would fetch the stylesheet's own URL), and a <link href="data:text/css,…"> loses its href. The same applies to srcdoc past the recursion-depth limit, except when the iframe also carries a src attribute: since srcdoc always overrides src when present, removing srcdoc there would newly activate src (already independently scheme/content-type-filtered), so srcdoc is emptied instead of removed.

CSS Verbs

CSS verbs process style attributes, <style> element contents, data:text/css URIs, and SMIL animated style values. They follow the same last-match-wins semantics as HTML rules. Without CSS rules, CSS content passes through unchanged. HTML scheme rules (strip-scheme etc.) do NOT apply to CSS — use css-*-scheme rules instead. An SVG or MathML <style> is not raw text, so it can have element children; a browser's style sheet reads only the element's own text, so when the policy has CSS rules (or a URL rewriter is set) every other child is removed before the CSS is sanitized.

css-strip PROP[,...]                    strip CSS properties (bare name matches vendor-prefixed variants)
css-allow PROP[,...]                    allow CSS properties
css-defang PROP[,...]                   defang CSS properties (prefix name)
css-strip-value PATTERN                 strip properties matching value pattern
css-defang-value PATTERN                defang properties matching value pattern
css-strip-at NAME[,...]                 strip CSS at-rules (e.g. import, * = all)
css-allow-at NAME[,...]                 allow CSS at-rules
css-defang-at NAME[,...]                defang CSS at-rules (prefix name)
css-strip-scheme TARGET SCHEME[,...]         strip CSS url() values by scheme
css-allow-scheme TARGET SCHEME[,...]         allow CSS url() values by scheme
css-defang-scheme TARGET SCHEME[,...]        defang CSS url() values by scheme
css-strip-content-type TARGET TYPE[,...]     strip CSS url() data: URIs by MIME type
css-allow-content-type TARGET TYPE[,...]     allow CSS url() data: URIs by MIME type
css-defang-content-type TARGET TYPE[,...]    defang CSS url() data: URIs by MIME type
css-strip-pseudo NAME[,...]                  strip selectors using a pseudo-class/element
css-allow-pseudo NAME[,...]                  allow pseudo-classes/elements
css-strip-media FEATURE[:VALUE][,...]        strip @media blocks by media feature
css-allow-media FEATURE[:VALUE][,...]        allow @media blocks by media feature
css-strip-medium TYPE[,...]                  strip media-conditional CSS that can apply to a medium
css-allow-medium TYPE[,...]                  allow media-conditional CSS by medium

The css-*-scheme and css-*-content-type TARGET is a comma-separated list of CSS property name patterns or @import (which matches only @import's URL). Use * to match all properties and every at-rule URL (@import, @namespace, block at-rule preludes). Scheme lists support :relative, :protocol-relative, and * with the same meanings as in HTML scheme rules; content-type TYPE lists use the same MIME patterns as the HTML content-type verbs (a single * wildcard, e.g. image/*, *+xml).

A URL in a custom property (--x: url(…)), or in @property's initial-value descriptor, can reach any property through var(), so it is judged under every property TARGET the policy's css-*-scheme (respectively css-*-content-type) rules name, plus its own name and *, and the most restrictive outcome wins: css-strip-scheme background-image * also strips the URL of --x: url(…); background-image: var(--x), and a rule targeting only the custom property cannot re-allow a URL that a rule for another property strips.

css-*-content-type filters data: URIs found in CSS url() values and @import by their MIME type — the CSS counterpart of the HTML content-type verbs (HTML content-type rules do not apply to CSS). It composes with css-*-scheme as AND (the most restrictive of the two decisions wins). When no css-*-content-type rule matches a url() data: URI, the default applies: text/css is recursively sanitized and SVG/XHTML data: URIs are best-effort recursed (a CSS url() loads in the browser's secure mode, with no scripting) — unlike the same URIs in an HTML attribute, which are always stripped. It applies to url() and @import data: URIs and equally to bare-string URL arguments of image-set()/cross-fade()/image()/src(), which browsers load exactly like url() tokens.

The css-*-pseudo verbs filter CSS selectors (in <style> elements, data:text/css, and standalone Policy.ApplyCSS) by pseudo-class or pseudo-element name. Each comma-separated NAME may carry an optional leading "::" to match pseudo-elements only or ":" to match pseudo-classes only; a bare name (or *) matches either kind. Names support a trailing * glob. The four legacy single-colon pseudo-elements (:before, :after, :first-line, :first-letter) are always treated as pseudo-elements.

When a pseudo with action strip matches anywhere within a complex (comma-separated) selector — including nested inside functional pseudo-classes such as :has(), :is(), :not(), :where() — that entire complex selector is dropped; if every complex selector in a ruleset is dropped, the whole ruleset is removed. This only ever narrows which elements a style applies to. As with other CSS verbs, evaluation is last-match-wins per pseudo and unmatched pseudos pass through, so an allowlist is written as "css-strip-pseudo *" then "css-allow-pseudo hover,focus". An @scope prelude's root and limit selector lists ("@scope (.card:hover) to (.content)") are filtered too: a stripped pseudo in either removes the whole @scope rule, since dropping only a limit selector would widen the scope. Pseudo filtering does not apply to inline style attributes (which have no selectors) or to other at-rule preludes such as @page :first or @keyframes stops.

The css-*-media verbs filter CSS @media query blocks (and @import media conditions) by media feature. Each comma-separated entry is a media feature or media type with an optional ":VALUE", e.g. "prefers-color-scheme:dark", "prefers-*", or "print"; both FEATURE and VALUE support a trailing * glob, omitting ":VALUE" matches the feature regardless of value, and a bare * matches any @media block. When a strip rule matches, the whole @media block (or @import) is removed. Matching is feature-presence based: the prelude is scanned through "not"/"only"/"and"/"or" combinators, so "not (prefers-color-scheme: dark)" is matched on presence, not semantically inverted, and modern range syntax such as "(width >= 600px)" is matched only by feature name, not value. An entry prefixed with "!" (e.g. "!prefers-color-scheme:light") matches only a feature written negated: "not (FEATURE…)", or any feature (and the media type) of a query negated as a whole, "not TYPE and (FEATURE…)" — so "not all and (prefers-color-scheme: light)" counts as a negated light check, while a feature negated twice ("not all and not (…)") does not; unprefixed entries keep matching on presence. This is a narrow form of negation, not full boolean evaluation. For @import, the URL and any layer(…)/supports(…) prefix are skipped before the media condition is scanned. css-*-media is more specific than the @media keyword, so a matching media rule overrides any css-*-at decision for that block (e.g. "css-strip-media *" then "css-allow-media screen" keeps only screen queries). There is no defang variant — like css-*-pseudo, media queries gate block applicability rather than carrying a value to neutralize. Like css-*-pseudo, css-*-media does not apply to inline style attributes (a declaration list has no at-rules) — only to <style> elements, stylesheets (Policy.ApplyCSS), and data:text/css. A bare "css-strip-media *" strips every @media block, and every @import that carries a media condition, but leaves a plain @import (no media condition) to the css-*-at rules.

A common use is forcing light mode by stripping dark-mode overrides ("css-strip-media prefers-color-scheme:dark"); see the strip-dark-mode built-in preset.

The css-*-medium verbs filter media-conditional CSS by media type (medium): "css-strip-medium print" removes every @media block, every @import with a media-query-list, and every <style media="…"> and <link rel=stylesheet media="…"> element whose media query list CAN evaluate true when printing. Each comma-separated TYPE is a media type name (print, screen, speech, or any other identifier) or * for any medium. Unlike css-*-media, which matches on the presence of a feature name, a medium rule evaluates each query's media type and "not": a query can apply to medium M unless its media type is a different type ("screen", "only screen and (color)"), or it is exactly "not M" or "not all". Media features are ignored, so over-matching is the deliberate direction: "all and (update: none)", a features-only query such as "(min-width: 0)", "not screen", "not print and (color)" (= not (print and color)), an empty list ("@media {…}", media="") and any query this library cannot read (a malformed query, which a browser evaluates as "not all") all count as able to apply to every medium. A query list matches when any of its comma-separated queries does, so "screen, print" matches print. Media types are compared ASCII-case-insensitively after CSS escape decoding, and a prelude is re-lexed as emitted first, so comment- or escape-split forms are read the way a browser reads the output. css-*-medium does not touch unconditional CSS (no @media, a plain @import, <style> without a media attribute), which applies to every medium alike.

Evaluation is last-match-wins within the family ("css-strip-medium print" then "css-allow-medium screen" keeps "@media screen, print", which can also apply to screen). Like css-*-media, a matching medium rule is more specific than the @media/@import keyword and overrides css-*-at, so "css-strip-at media" then "css-allow-medium screen" keeps screen blocks. css-*-media and css-*-medium are evaluated independently and combined as AND: a block either family strips is removed, and an allow from one family never re-keeps a block the other strips. The HTML side (<style media>, <link rel=stylesheet media>, any rel list containing "stylesheet", in any namespace) is only governed by css-*-medium; the element is removed whole; the media attribute is read as CSS input (preprocessed and escape-decoded like a stylesheet). It is judged after attribute rules, so a policy that strips or defangs the media attribute itself turns the sheet unconditional (applying on every medium) before css-*-medium sees it. Rule order between the families does not matter. There is no defang variant.

The intended use is removing print beacons ("@media print { body { background: url(https://t.example/printed) } }") with "css-strip-medium print". @page rules (and their margin boxes) apply only to paged media and are not media-conditional blocks; add "css-strip-at page" to remove them too.

Property (css-strip/css-allow/css-defang), at-rule (css-*-at), scheme TARGET (css-*-scheme), and pseudo (css-*-pseudo) NAME matching is vendor-prefix-insensitive: a bare pattern matches both the canonical name and every vendor-prefixed variant, because browsers alias -webkit-foo, -moz-foo, -ms-foo, etc. to foo. So css-strip transform also strips -webkit-transform, css-strip-at keyframes also strips @-webkit-keyframes, and css-strip-pseudo scrollbar also drops ::-webkit-scrollbar. An explicitly hyphen-prefixed pattern (e.g. css-strip -webkit-transform) matches only that single variant — the escape hatch for targeting one vendor. Matching is name-only and never rewrites the surviving bytes; custom property names (--x) are not treated as vendor-prefixed.

CSS allowlist example:

css-strip *
css-allow color,background-color,font-size,font-family,font-weight
css-allow text-align,text-decoration,margin,padding,border
css-strip-value expression(*)
css-strip-value url(*)
css-strip-at *

The url(*) value pattern also matches a bare string argument of a URL-accepting function (image-set("x" 1x), cross-fade(), image(), src()), which browsers load as a URL. A var() argument is not a string and is not matched; css-*-scheme rules are the comprehensive URL guard. The string of image-set()'s type("image/avif") or src()'s integrity("…") is not a URL and is neither matched, scheme-checked, nor passed to a URL rewriter.

A css-*-value pattern not ending in "(*)" matches the whole declaration value: case-insensitively, after CSS escape decoding, and ignoring surrounding whitespace and a trailing !important, so css-strip-value fixed strips position:fixed and position:fixed !important alike.

CSS blocklist example:

css-strip -moz-binding,behavior
css-strip-value expression(*)
css-strip-at import
css-strip-scheme * javascript,vbscript,data,blob

Standalone CSS sanitization is available via Policy.ApplyCSS (stylesheets) and Policy.ApplyInlineCSS (declaration lists).

Other Verbs

prefix NAME                  set the prefix for defang/comment-out names (default "htmlpolicy"; lowercased in defanged attribute and CSS names)
placeholder-label LABEL      customize the label for placeholder (default "removed")
strip-comments               remove HTML comments
include NAME-OR-PATH         inline another policy (loaded via [Resolver])
include builtin:NAME         inline a built-in preset (no [Resolver] needed)

prefix, placeholder-label and strip-comments are policy-global: the last occurrence anywhere in the include tree wins, including one inside an included file or a built-in preset (no built-in preset sets prefix or placeholder-label). An include is not a scope — it is inlined where it appears. Includes nest at most WithMaxIncludeDepth levels deep, and one Parse call expands at most 1024 include directives in total (a file included twice counts twice), so a policy tree that re-includes the same files exponentially fails to parse instead of expanding without bound.

Lines starting with # are comments. Only full-line comments are supported.

Linting

Policy.Lint statically analyzes a compiled policy for likely mistakes (without applying it) and returns advisory Warning values: URL attributes reopened with no scheme baseline; allow-scheme rules that reopen a dangerous scheme (javascript/vbscript, or data: unless a content-type baseline denies unlisted types on the attribute or the reopen names only image/media elements) an earlier scheme rule stripped; allow-content-type rules whose type list names or globs an XML type, which is always stripped regardless; allow-attr rules that reopen a dangerous non-URL attribute (on* handlers, nonce, is, shadowrootmode, srcdoc) an earlier strip-attr removed; css-allow-scheme rules that reopen javascript or data (with no css-*-content-type rule) on CSS url() values after an earlier css-strip-scheme; allow-tag rules that reopen a dangerous element (script, iframe, object, embed, base, meta, link, template, fencedframe, portal) an earlier rule neutralized — in any of the shapes it takes (bare, with the attributes that make it active such as meta http-equiv=refresh or iframe srcdoc, in a document body, an SVG <script>); allow rules that are dead because nothing earlier strips what they permit, so they only restate the passthrough default; rules made dead by a later same-family rule (last match wins by order, so an allow placed before a universal strip never fires); and "unwrap-tag *" used as a catch-all without strip rules for active-content elements. The CLI exposes this as "htmlpolicy -lint", which exits non-zero when a policy produces warnings.

Built-in Presets

The library ships a small set of maintained policy presets, includable as "include builtin:NAME" from any policy with no Resolver configured. They are ordinary policy text — fully inlined by Policy.String, and overridable by any later rule under last-match-wins — so they are a safe, reviewable starting point rather than hidden engine behavior. Upgrading the library picks up preset improvements (e.g. coverage for newly recognized HTML features). Available presets:

builtin:blocklist        strong blocklist baseline (strips active-content
                         elements/attributes, dangerous URL schemes, and
                         data: on auto-fetching attributes; everything
                         else passes through)
builtin:allowlist-base   fail-closed skeleton (strips all elements,
                         attributes, schemes, content types, and CSS;
                         follow it with your own allow rules)
builtin:url-safe         restrict every URL attribute to http, https,
                         mailto, and relative URLs (pair with rules that
                         reopen URL-bearing attributes)
builtin:strip-dark-mode  remove dark-mode signals: prefers-color-scheme
                         dark @media overrides, the color-scheme property
                         and <meta>, and Apple Mail's legacy
                         supported-color-schemes property and <meta>

builtin:strip-dark-mode strips the dark-mode mechanism, not author-chosen colors: a page that defaults to light and adds a dark @media override (the common case) renders light afterward, but a page hardcoded dark with no media query is not repainted. It does not guarantee a light result.

Use BuiltinPolicy to fetch a preset's text in Go and BuiltinPolicyNames to list them. Because allowlist-base reopens nothing on its own, building an allowlist on top of it fails safe: forgetting to re-allow a rule family drops content rather than passing it through.

In document mode (Policy.ApplyDocument, and the embedded documents of srcdoc and data:text/html at every depth) a document's own <html>, <head> and <body> are never removed. Last match wins as for any element; only the effect of a non-allow action differs. When the last matching tag rule is a catch-all — the bare "*" (allowlist-base's strip-tag * baseline), or any selector that, like it, matches every element whatever its name, attributes, position or content: *,script or :not(p) — with an action other than allow-tag, they are kept as document structure with their attributes removed, and their children are judged by their own rules, as if unwrapped — even if an earlier rule named them (allow-tag body followed by strip-tag * still removes <body onload>). When the last matching rule names them, strip-tag, comment-out-tag and placeholder-tag keep the bare element but remove its children and attributes (strip-tag head drops the <meta>, <link> and <style> elements in the head; strip-tag html empties the document), demote-tag and unwrap-tag keep it as structure with its attributes removed, and allow-tag keeps it with its attributes filtered by the attribute rules (a later allow-tag * overrides an earlier strip-tag head). Add "allow-tag html,head,body" (plus allow-attr rules) after any strip-tag * baseline to keep attributes such as <html lang>. A selector that depends on anything about the element — its name, an attribute, an ancestor ("html *"), being the root, first, last or only child (:root, :not(:first-child)) or its content (:empty, :has(…)) — is not a catch-all and counts as naming them, so with strip-tag it empties them (fail-safe; follow it with allow-tag html,head,body to keep the document).

URL Rewriting

WithApplyURLRewriter is an ApplyOption that sets a callback that receives every URL in the sanitized output and can replace it. The rewriter is scoped to a single Apply call, so it may safely close over per-call state (caches, counters, accumulators) without concurrency concerns. The rewriter runs after policy evaluation and base URL resolution. It covers HTML URL attributes, CSS url() values, srcset entries, SVG url() attributes, meta refresh URLs, and SMIL animation values. URLs inside recursively-sanitized data:text/html content are also rewritten. Stripped and defanged URLs are excluded. Fragment-only references, empty URLs, and data: URIs themselves are excluded (but URLs inside data URI content are recursed into). The option also works with Policy.ApplyCSS and Policy.ApplyInlineCSS.

WithOriginalURLAttr preserves the pre-rewrite value of every HTML attribute the rewriter changed as a companion "{prefix}-original-{attrname}" attribute on the same element, so the original URL can be recovered from the output. Pre-existing attributes matching that reserved pattern are stripped from the input first (spoof prevention); WithKeepOriginalURLAttrs opts out of the stripping for trusted re-processing of this library's own prior output.

WithApplyURLPrefetcher is a companion ApplyOption that receives the full set of URLs the rewriter would be called with — same set, same resolved form, same dedup, in document order — once per call, before the rewrite pass. It lets a caller warm a cache (e.g. fetch image and font resources in parallel) that the subsequent synchronous rewriter reads from. Each URLRef carries a live URLContext (including GetAttr) so the caller can inspect sibling attributes when deciding what to fetch.

Output Normalization

By default, Policy.ApplyHTML and Policy.ApplyDocument always re-serialize through Go's HTML5 parser, even when no rules modified the content. This prevents parser-differential attacks. Use WithPreserveOriginal to return the original bytes when no rules match.

This applies at every recursion depth: srcdoc values and recursed data:text/html bodies (in URL attributes, srcset/ping entries, meta refresh and SMIL values) are re-serialized and re-encoded even when no rule fired (base64 bodies stay base64); the modified result still reports only rule changes. WithPreserveOriginal leaves unmodified embedded content verbatim at every depth. (XML data: URIs in HTML attributes are always stripped, so are never re-serialized; SVG/XHTML recursed from a CSS url() is left verbatim unless a rule fired, since a browser parses it as XML and an HTML5 re-serialization could change the document.)

Embedded Content Sanitization

HTML embedded inside attribute values is recursively sanitized: data:text/html URIs, srcdoc attributes, and meta refresh URLs. Recursion depth is limited to 16 levels; at the limit, content is stripped entirely. A data:text/html form action or formaction is removed instead, since the submitted fields join the document (see Content-Type Verbs).

XML data: URIs (image/svg+xml, text/xml, application/xml, application/xhtml+xml, any */xml or *+xml) in those positions — URL attributes, srcset/ping entries, meta refresh URLs, SVG url() presentation attributes and SMIL animated values — are always stripped rather than recursed, because the HTML5 parser cannot match a browser's XML parser; no allow-content-type rule re-enables them (see Content-Type Verbs). See Limitations.

The text of <noscript>, <noframes> and <noembed> (raw text to a scripting-enabled browser, markup to a scripting-disabled one) is parsed as HTML, sanitized by the same rules and written back as escaped text. That text never holds the element's own end tag, which would end the element early in a browser and make the rest live: an SVG or MathML element named noscript, noframes or noembed found there is removed, and an element whose sanitized content would still hold its end tag — a nested element of the same name, or a comment-out-tag comment containing one — is emptied.

Namespace Validation (mXSS Prevention)

After applying policy rules, namespace consistency is validated. Elements whose namespace is invalid for their DOM position are stripped. This prevents mutation XSS attacks where foreign-content elements end up in the wrong namespace after policy actions change the tree structure. Directly under an integration point (SVG foreignObject/desc/title, MathML mi/mo/mn/ms/mtext and an HTML-encoded annotation-xml) a browser builds a foreign element only for <svg> or <math> (and MathML <mglyph>/<malignmark> under mi…mtext), so any other SVG or MathML element there — which an unwrap-tag "foreignobject > svg" would leave, and which would re-parse as HTML (an SVG <style> becoming a live HTML one) — is removed with its subtree.

A <form> nested inside another <form> (which the parser builds only after a </form> it could not honour behind an SVG/MathML boundary) is unwrapped, keeping its content: serialized, its start tag would be ignored on re-parse while its end tag left the outer form open to capture the page content that follows the output. Nested forms inside <template> content are kept.

SVG SMIL Animation Sanitization

SMIL animation elements (<animate>, <set>, etc.) are sanitized by applying the same attribute and scheme policy rules to animated values. This prevents runtime bypasses via attributeName="href" values="javascript:alert(1)". CSS in animated style values is sanitized through the CSS engine.

The rules applied are those of the animation's target element: its parent when href/xlink:href is absent or empty, otherwise the element whose id matches the (percent-decoded) fragment. An animation whose href names no element in the sanitized output — including an id that only exists in the page a fragment is embedded into — is removed, and so is one whose target is not an SVG element (browsers animate only SVG elements, so an animated <iframe srcdoc> or HTML <a href> does nothing) or is itself an animation element (which would retarget that animation at runtime). The id must also be unique: an animation whose target id is held by more than one element anywhere in the sanitized document — inside <template> content and <noscript>/<noframes>/<noembed> content included — is removed, since which holder a browser animates depends on the tree it builds from the output (foster parenting out of a table, scripting mode, a holder removed by sanitization), and the sanitizer would otherwise judge the animation against an element the browser does not animate. Ids are indexed after every other sanitization step has removed what it removes, and the element whose id is referenced must sit in the same template scope as the animation: an animation inside <template> content (such as a declarative shadow root) resolves ids within that content only, and one outside it never resolves into it. SVG <discard> elements, which remove elements at runtime, are always removed.

Each animated value is judged twice against its target: as the target is written, and as it would be with the animated attribute holding that value (the browser gives the element that attribute at runtime). A selector that tests the animated attribute itself therefore applies to an animated value just as to a static one: under strip-scheme "a[href]" href javascript, strip-attr "a[href^=javascript:]" href, or strip-tag "a[href^=javascript:]", <a><set attributeName="href" to="javascript:alert(1)"/></a> loses its javascript: value. The more restrictive reading wins; a value under which the target would match a tag rule other than allow-tag removes the animation, and one under which its attribute rule strips or defangs it is dropped or defanged. Only the target is re-judged: a rule on another element that tests the target's attribute (strip-tag "g:has(a[href^=javascript:])") sees the target as written. The second reading is needed only when some tag, attribute, scheme or content-type selector can read the animated attribute (an attribute selector naming it, .class for class, #id for id, :link for href, :lang for lang, :checked for checked, type and selected, :enabled for href and disabled, :disabled for disabled); then at most 16 distinct values per animation are judged this way, and an animation with more is removed, since judging each one re-runs every selector on the target and an unbounded values= list made sanitization quadratic.

Limitations

Embedded documents — <iframe srcdoc> and data:text/html URIs — are parsed and re-serialized as full documents, so their sanitized form carries an <html><head></head><body> wrapper the browser would build anyway.

In fragment mode, input containing a <body>, <html>, <frameset> or <frame> start tag is always re-serialized (which drops those tokens), even under WithPreserveOriginal, and is reported as modified. Likewise, in both modes and at every depth, input that ends inside an unfinished construct — a tag, attribute value, comment or DOCTYPE cut off by the end of input, an open <script>/<style>/<textarea>/<title>/<xmp>/<iframe>/<noembed>/<noframes>/ <noscript>/<plaintext>, a trailing "<", or an open <svg>/<math> — is always re-serialized and reported as modified. So is top-level input that leaves any element open (`<p>hi<form action=…>`, an open <a href>, <select> or <template>) or the parser's form element pointer set (`<div><form action=…></div>`, which makes a page's following <form> start tag be ignored), which would otherwise capture the content a page places after it; an embedded document has nothing after it and is exempt.

CSS Nesting is supported: nested style rules, and conditional at-rules (@media, @supports, @container, …) nested inside style rules, are sanitized like top-level ones. Malformed nesting may be restructured in the output, but is never passed through unsanitized. In a style attribute, at-rule blocks are dropped (browsers ignore them there).

CSS selectors in <style> elements are not filtered. Attribute selectors combined with url() values can exfiltrate data. The url() scheme filtering mitigates this, but full protection requires stripping <style> elements.

CSS var()/env()/attr() substitution happens at browser computed-value time, not parse time. Known attack vectors are handled (url() in custom properties, @import var(), var() fallback values, var()/env()/attr() in URL-accepting functions). When the policy governs url() at all (css-*-scheme or css-*-content-type rules), var()/env()/attr() inside URL-accepting functions (image-set, -webkit-image-set, cross-fade, image, src) is stripped as a precaution. Bare string arguments inside these functions are evaluated as URLs and run through the full url()-token path: css-*-scheme rules, css-*-content-type rules, and recursive sanitization of data: URI content. CSS escape sequences in the url() (or attr()) function name itself (e.g. \75rl(...), \61ttr(...)) bypass the lexer's URLToken recognition, so whenever css-*-scheme or css-*-content-type rules are active any FunctionToken whose escape-decoded name is "url" causes the declaration to be stripped. attr() with a url-typed substitution — "attr(name url)" or "attr(name type(<url>))" — also causes the declaration to be stripped, since the value of the named HTML attribute would be loaded as a URL at computed time.

XML data: URIs cannot be sanitized, so in HTML attributes (URL attributes, srcset/ping entries, meta refresh URLs, SMIL animated values) they are always stripped, with no opt-in — including harmless ones such as an inline <img src="data:image/svg+xml,…"> icon. Browsers parse them with an XML parser; this library uses an HTML5 parser, which cannot model XML custom entity expansion (<!ENTITY x "<script>...">, then &x;), CDATA sections, processing instructions, namespace prefixes, or the elements an HTML5 parse moves or reads as text. The CSS url() path is the exception: SVG/XHTML data URIs there are best-effort recursed (policy rules applied to the HTML5 parse, the XML-only constructs above not modelled) because a CSS url() loads in the browser's secure static mode (no scripting, no external fetches), where none of those constructs can run.

Hardcoded limits: data URI recursion depth 16, HTML nesting depth 510, CSS nesting depth 32, output size 10x input (configurable via WithMaxOutputFactor, minimum 32KB).

Index

Examples

Constants

View Source
const DefaultMaxIncludeDepth = 64

DefaultMaxIncludeDepth is the default maximum nesting depth for includes.

Variables

This section is empty.

Functions

func BuiltinPolicy added in v0.9.0

func BuiltinPolicy(name string) (text string, ok bool)

BuiltinPolicy returns the raw policy text of the named built-in preset, or ok=false if no such preset exists. The name is the bare preset name without the "builtin:" prefix (e.g. "blocklist", "allowlist-base", "url-safe").

Built-in presets are ordinary policy text maintained with the library. They can be applied directly via an include directive — "include builtin:NAME", which works in any policy without configuring a Resolver — or fetched with this function to inspect or compose them in Go. Use BuiltinPolicyNames to list the available presets.

Because presets are plain policy text, later rules in a policy override them under last-match-wins semantics, and the fully resolved policy (with presets inlined) is visible via Policy.String.

func BuiltinPolicyNames added in v0.9.0

func BuiltinPolicyNames() []string

BuiltinPolicyNames returns the names of all built-in presets in sorted order, each usable as "include builtin:NAME" or passed to BuiltinPolicy.

func ConvertToUTF8

func ConvertToUTF8(content []byte, contentType string) ([]byte, bool)

ConvertToUTF8 converts content from the charset specified in contentType to UTF-8. The contentType should be a MIME type with optional charset parameter (e.g. "text/html; charset=iso-8859-1"). If no charset is specified, encoding is determined by inspecting the content.

Returns the UTF-8 content and a boolean indicating whether conversion was performed. When the content is already UTF-8 (or the detected encoding matches UTF-8), the original slice is returned with false; so it is for valid UTF-8 whose encoding is only guessed — no BOM, no charset in contentType and no <meta> charset declaration in the first 1024 bytes — since a guess made from the first 1024 bytes must not mangle it. A declared encoding is always honoured, even when the bytes also happen to be valid UTF-8 (ISO-2022-JP text is pure ASCII).

Limitation: UTF-7 is not supported. The underlying charset detector has no UTF-7 decoder, so content declaring charset=utf-7 is returned unchanged (with false). This matches modern browsers, which no longer sniff or decode UTF-7; the classic "+ADw-script+AD4-" obfuscation is therefore not auto-decoded here. Callers that must handle attacker- controlled UTF-7 should reject or pre-decode such input before sanitizing.

Example
package main

import (
	"fmt"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	content := []byte("<p>caf\xe9</p>")
	utf8, converted := htmlpolicy.ConvertToUTF8(content, "text/html; charset=iso-8859-1")

	fmt.Println("converted:", converted)
	fmt.Println(string(utf8))
}
Output:
converted: true
<p>café</p>

func URLScheme added in v0.30.1

func URLScheme(raw string) string

URLScheme returns the lowercase scheme a browser reads from raw, or "" when raw is a relative or otherwise scheme-less URL. It applies the WHATWG URL parser's input cleanup first — leading and trailing C0 controls and spaces trimmed, ASCII tab, LF and CR removed from anywhere — so " JaVa\tScript:alert(1)" reports "javascript" and "DATA:text/html,x" reports "data".

Only the scheme is read, never the rest of the URL: "https:x" reports "https" (with an https base document a browser resolves it as the relative reference "x"; see the package documentation on relative URLs), and "//host/x" reports "" although it is not a reference into the current document.

URLScheme answers what the browser sees. Policy scheme matching (Policy.URLSchemeAction, and Apply itself) deliberately classifies more broadly and fail-closed: it judges this reading and also one with zero-width runes and every interior C0 control removed, the more restrictive verdict winning, so "\u200bjavascript:x" matches a javascript rule (and a :relative one) while URLScheme reports "" for it — correctly, since a browser reads that value as a relative URL. Use URLScheme to recognise a scheme the way a browser does (for example to detect data: URLs); use Policy.URLAction to decide whether a URL may be kept.

Example
package main

import (
	"fmt"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	for _, u := range []string{
		"https://example.com/",
		" DATA:text/html,<p>hi",
		"java\tscript:alert(1)",
		"https:x",
		"/relative/path",
		"//host/path",
	} {
		fmt.Printf("%q\n", htmlpolicy.URLScheme(u))
	}
}
Output:
"https"
"data"
"javascript"
"https"
""
""

Types

type Action

type Action int

Action determines what happens to a matched element or attribute.

Action is part of the stable API: Policy.URLSchemeAction and Policy.URLAction return it. The rule types that carry an Action field (TagRule, AttrRule, etc.) are exported for documentation only — Policy fields are unexported and no public function accepts or returns those rule types. Policy.URLSchemeAction and Policy.URLAction return only Allow, Strip, or Defang; the remaining constants arise solely from element/attribute verbs applied during sanitization.

const (
	// Strip removes the element and all of its content from the output.
	Strip Action = iota
	// CommentOut wraps the element and its content in an HTML comment
	// (<!-- ... -->), making it invisible to browsers while preserving
	// the content in the source for inspection.
	CommentOut
	// Placeholder replaces the element with a text label such as
	// "[removed: script]". The label word is configurable via the
	// placeholder-label policy directive.
	Placeholder
	// Demote converts the element to a safe generic container: inline
	// elements become <span>, all others become <div>. Allowed
	// attributes are preserved; the namespace is cleared.
	Demote
	// Allow keeps the element in the output unchanged.
	Allow
	// Defang neutralizes content while preserving the value for
	// inspection. It usually renames the attribute by inserting a prefix
	// (e.g. "htmlpolicy-defanged-onclick"). As a scheme rule on href of
	// <a>/<area> it instead prefixes the URL scheme in place with
	// "{prefix}-defanged" (e.g. "htmlpolicy-defanged:bitcoin:...") so the
	// link stays hoverable/copyable but inert — see the defang-scheme
	// documentation in the package overview.
	Defang
	// Unwrap removes the element's start and end tags but keeps its
	// children in place at the position the element occupied. The
	// element's attributes are discarded along with the wrapper. This
	// mirrors how browsers treat unknown elements (HTMLUnknownElement
	// is a transparent wrapper: the tag has no effect and children
	// render in flow). Void/empty elements with no children behave as
	// [Strip] — there is nothing to keep.
	//
	// Unwrap differs from [Demote]: Demote renames the wrapper to a
	// safe generic container (<div>/<span>), preserving the element
	// boundary, while Unwrap removes the wrapper entirely.
	//
	// Unwrap is appropriate as a catch-all for unknown structural
	// elements in an allowlist policy. It MUST be paired with explicit
	// strip rules for any element whose presence indicates active
	// content or whose text content should not be visible. At a
	// minimum, pair "unwrap-tag *" with strip rules for script, style,
	// iframe, object, embed, form, input, textarea, select, button,
	// link, base, and meta. Without those, raw <script>/<style> text
	// would leak into the output as visible text after the wrapper
	// is removed.
	Unwrap
)

type ApplyOption

type ApplyOption func(*applyConfig)

ApplyOption configures a single call to Policy.ApplyHTML, Policy.ApplyDocument, Policy.ApplyCSS, or Policy.ApplyInlineCSS. Unlike ParseOption (which is baked into the compiled Policy and shared across all calls), ApplyOption values are scoped to one call. This allows per-call state — for example, a URL rewriter that closes over per-message caches or budgets, or a verbose log writer for a single request — without recompiling the policy.

func WithApplyURLPrefetcher added in v0.3.0

func WithApplyURLPrefetcher(fn func([]URLRef)) ApplyOption

WithApplyURLPrefetcher registers a callback invoked once per Apply call, after parsing, policy evaluation, and base URL resolution, but before the URL rewrite pass. It receives every URL that a URLRewriter set via WithApplyURLRewriter would be invoked with in this same call — the same set, the same resolved form, the same deduplication semantics, and the same order (document order for top-level URLs; URLs inside recursively sanitized data: content appear at the point their containing attribute is processed). The callback returns nothing; it exists so the caller can warm a cache — for example, fetching image and font resources in parallel — that the subsequent synchronous URLRewriter then reads from.

Each URLRef.Context.GetAttr is live during the callback and returns the element's attributes, so the caller can inspect sibling attributes (width, height, style, type, rel, …) to decide whether to fetch.

The prefetcher and rewriter are independent: either, both, or neither may be set. When both are set the prefetcher runs first, to completion, and then the normal rewrite walk runs. When no rewriter is set the prefetcher is still invoked (with the set the rewriter would have seen). The callback is invoked exactly once per Apply call, even when no URLs are found (with an empty slice) — except when the recording pass exceeds the output size limit (see WithMaxOutputFactor): Apply then returns that error without invoking the callback, since there is no output to prefetch for.

Implementation note: enabling a prefetcher runs the deterministic sanitization pipeline a second time — a recording-only pass that collects the URL set without mutating output — so the prefetch set is guaranteed to match the rewrite pass exactly, including URLs at every recursion depth. This roughly doubles the parse/sanitize CPU for the call (the network fetches it enables run once, in parallel). Callers that do not set a prefetcher pay nothing. The recording pass is silent: it runs with no verbose log writer, so WithVerboseLog never produces duplicate lines.

Like WithApplyURLRewriter, the callback is scoped to a single Apply call and may safely close over per-call state without concurrency concerns. It is invoked synchronously from within Apply; htmlpolicy itself introduces no goroutines (the caller may fan out internally).

Example
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse(`allow-tag *`)
	if err != nil {
		log.Fatal(err)
	}

	// The prefetcher receives every URL up front, so a caller can fetch them
	// in parallel (with its own concurrency limits) and warm a cache that the
	// synchronous rewriter then reads from.
	cache := map[string]string{}
	prefetch := func(refs []htmlpolicy.URLRef) {
		for i, r := range refs {
			// A real implementation would fetch r.URL here, in parallel.
			cache[r.URL] = fmt.Sprintf("cid:image%03d", i+1)
		}
	}
	rewriter := func(ctx htmlpolicy.URLContext, u string) string {
		if cid, ok := cache[u]; ok {
			return cid
		}
		return u
	}

	input := []byte(`<img src="https://example.com/a.jpg"/><img src="https://example.com/b.jpg"/>`)
	output, _, err := policy.ApplyHTML(input,
		htmlpolicy.WithApplyURLPrefetcher(prefetch),
		htmlpolicy.WithApplyURLRewriter(rewriter))
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(string(output))
}
Output:
<img src="cid:image001"/><img src="cid:image002"/>

func WithApplyURLRewriter

func WithApplyURLRewriter(fn URLRewriter) ApplyOption

WithApplyURLRewriter sets a function that rewrites URLs in the sanitized output for this single Apply call. The rewriter runs after policy evaluation and base URL resolution with each URL's final resolved form. It receives a URLContext describing where the URL was found and returns a replacement URL (or the original to keep it unchanged). URLContext.Load and URLContext.RequestType say how a browser uses the slot — fetched automatically (and as what), followed only on a user action, or never — so a rewriter that blocks automatic loads can key on them instead of on element and attribute names.

The rewriter covers HTML URL attributes (href, src, action, etc.), CSS url() values in style attributes and <style> elements (including bare-string URL arguments of image-set()/cross-fade()/image()/src(), which browsers load exactly like url() tokens), @import URLs, srcset entries, SVG url() attributes (url()s and strings in image-set() and the like, as for CSS), meta refresh URLs, and SMIL animation values. URLs inside recursively-sanitized data:text/html content are also rewritten. Stripped and defanged URLs are excluded.

Fragment-only references (#id), empty URLs, and data: URIs are excluded from the callback, judged after the URL parser's input cleanup only (see URLRewriter): a zero-width rune before "#" or "data:" makes a relative URL the browser fetches, which is presented. However, URLs inside data URI content are recursed into — e.g. url(img.png) inside a data:text/css URI is presented to the rewriter even though the data URI itself is not. A relative URL inside a data:text/css stylesheet is presented unresolved, never resolved against the document's <base>: its stylesheet's base is the data: URL, against which a browser cannot resolve it.

CSS whose URL the browser computes but this text does not contain cannot be shown to the rewriter, so it is dropped whenever a rewriter (or prefetcher) is set — even when the policy has no CSS rules: a declaration or @import using an escape-encoded url() name (\75rl(…)), and a declaration with a var()/env()/attr() inside a URL-accepting function (image-set(var(--u) 1x)); a custom property value containing either is emptied. Likewise a data:text/css stylesheet whose URLs cannot be read the way the browser reads them — a charset (declared, BOM or @charset) other than UTF-8/US-ASCII, or nesting deeper than the data: recursion limit — drops its declaration or @import, and empties a <link href>.

The rewriter is per-URL: its output is written into the slot the URL came from and is not re-sanitized (except that an invalid UTF-8 sequence in it becomes U+FFFD, as for input; see the package documentation). A caller does not need to know any slot's separators. Where a slot has structure of its own, a replacement that would break it is written in a form the browser reads back as the same URL — a CSS escape, or a percent-encoding the browser's URL parser itself applies — and where no such form exists the URL is removed. The original URL is never kept in place of a replacement, so a rewriter that blocks or neutralises URLs cannot be defeated by the characters of the URL it replaces:

  • srcset candidates and space-separated URL lists (ping/archive/attributionsrc): the replacement is cleaned as the URL parser cleans its input (leading/trailing C0 controls and spaces trimmed, tab/LF/CR removed) and each remaining space or form feed is percent-encoded (%20, %0C). An empty replacement, one starting or ending with ',' in a srcset, or an opaque-path URL (mailto:a b) that holds a space drops the entry, descriptor included.
  • SVG url() functional attributes: a replacement holding whitespace or another control character, '(' ')', a quote or backslash is written as a CSS string (url(x) becomes url("…")) with CSS escapes, which reads back byte for byte; a NUL is written %00. Only the URL itself is replaced; the rest of the value, CSS escapes included, is kept byte for byte.
  • SMIL animation values lists: a ';' in the replacement's fragment (after its first '#') is percent-encoded (%3B); one before the fragment drops that keyframe, and the animation element when no non-empty keyframe survives. In an animated SVG url() attribute the ';' is CSS-escaped instead. from/to/by hold a single value, where ';' is not a separator.
  • Meta refresh: in a "-quoted URL a '"' is percent-encoded (%22); a replacement holding the ' of a '-quoted URL, a '"' in an opaque-path URL, or no URL at all (which would reload the page itself) empties the content attribute, so nothing refreshes. An unquoted URL the refresh parser would misread (one starting with a quote, the "url=" keyword, or a ';' or ',' separator) is written "-quoted.

Each removal counts as a modification and is logged by WithVerboseLog; WithOriginalURLAttr preserves the attribute's pre-rewrite value as for any other rewrite. A CSS url() value is rebuilt as a CSS token with CSS escapes, so a replacement needs no encoding there (a NUL in it becomes U+FFFD, as CSS reads any NUL). HTML attribute values are always quote-escaped on output, so a replacement cannot inject markup regardless.

Because the rewriter is scoped to a single Apply call, it may safely close over per-call state (caches, counters, accumulators) without concurrency concerns. Two goroutines calling Apply on the same Policy with different rewriters do not share state.

Example
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse(`
		strip-tag script,style
		strip-attr * on*
	`)
	if err != nil {
		log.Fatal(err)
	}

	rewriter := func(ctx htmlpolicy.URLContext, u string) string {
		// Replace external URLs with CID references (for email embedding).
		if ctx.Element == "img" && ctx.Attr == "src" {
			return "cid:image001"
		}
		return u
	}

	input := []byte(`<p>Hello</p><img src="https://example.com/photo.jpg" alt="photo"/>`)
	output, _, err := policy.ApplyHTML(input, htmlpolicy.WithApplyURLRewriter(rewriter))
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(string(output))
}
Output:
<p>Hello</p><img src="cid:image001" alt="photo"/>

func WithKeepOriginalURLAttrs added in v0.20.0

func WithKeepOriginalURLAttrs() ApplyOption

WithKeepOriginalURLAttrs disables the spoof-prevention stripping performed by WithOriginalURLAttr: pre-existing "{prefix}-original-*" attributes in the input are kept, and an attribute that already has a companion annotation is never re-annotated — the earliest preserved value wins across repeated Apply passes.

Set this only when re-processing this library's own prior output (or input that is otherwise trusted): it is an explicit trust assertion, and with it set an attacker-supplied {prefix}-original-href in the input survives as if it were a preserved value.

It has no effect unless WithOriginalURLAttr is also set.

func WithMaxOutputFactor

func WithMaxOutputFactor(factor float64) ApplyOption

WithMaxOutputFactor sets the maximum allowed output size for this Apply call as a multiplier of the input size. For example, a factor of 10.0 means the output may be at most 10x the input size (with a minimum of 32KB). This guards against amplification attacks such as a long <base href> resolved into many short relative URLs. The default is 10.0. Set to 0 to disable the limit; +Inf means the same (no limit). A factor that is NaN, -Inf or negative is a caller mistake and is ignored: the default 10.0 applies (a NaN used to fall through to the 32KB floor, and a negative factor to "unlimited").

The limit is enforced while the work happens, not only on the final render: base-URL resolution (attributes, srcset, SVG url() attributes, SMIL animation values, meta refresh, and — when a URL rewriter or prefetcher is set — CSS url()s) and the growth from re-encoding nested data: URIs in CSS (including in ApplyCSS/ApplyInlineCSS) draw on a running budget with this same limit, shared across recursively sanitized embedded documents, so the call fails fast instead of building the full amplified output first.

Example
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse(`
		strip-tag script
	`)
	if err != nil {
		log.Fatal(err)
	}

	// Small input is always allowed (32KB minimum).
	output, _, err := policy.ApplyHTML([]byte("<p>Hello</p>"), htmlpolicy.WithMaxOutputFactor(2.0))
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(output))
}
Output:
<p>Hello</p>

func WithOriginalURLAttr added in v0.20.0

func WithOriginalURLAttr(filter func(URLContext) bool) ApplyOption

WithOriginalURLAttr preserves, for this single Apply call, the pre-rewrite value of every HTML element attribute that a URLRewriter (set via WithApplyURLRewriter) changed. After the rewrite pass, each changed attribute gains a companion attribute on the same element:

{prefix}-original-{attrname}="{pre-rewrite value}"

For example, with the default prefix, rewriting <a href="https://x/page"> to a proxy URL produces:

<a href="https://proxy/abc" htmlpolicy-original-href="https://x/page">

so the original URL can be recovered from the output. The annotation is per node: two identical elements are annotated independently, even though rewriter calls for identical URL+context pairs are deduplicated.

filter, if non-nil, restricts which attributes are annotated. It receives a URLContext with Element, Attr, Parent, and GetAttr populated and CSSProperty always empty — annotation is attribute-granular, not URL-granular. Return true to annotate. A nil filter annotates every changed attribute.

Granularity and coverage:

  • The whole pre-rewrite attribute value is preserved. For multi-URL attributes (srcset, ping) that is the entire original list; for style attributes whose CSS url() values were rewritten it is the entire original declaration list.
  • The preserved value is the post-sanitization, post-base-resolution value — exactly what the rewrite pass saw, never the raw input — so annotations cannot resurrect content the policy stripped or defanged.
  • URLs with no attribute to annotate are not preserved: <style> element contents, standalone Policy.ApplyCSS / Policy.ApplyInlineCSS (where this option is a no-op), and URLs inside data:text/css content (though the containing attribute, whose data: URI value changed, is itself annotated).
  • Annotation runs at all recursion depths, so elements inside recursively sanitized data:text/html content are annotated within that content.
  • A namespaced attribute contributes its namespace to the companion name: an SVG xlink:href is preserved as {prefix}-original-xlink-href.

Spoof prevention: whenever this option is set, attributes whose name matches the reserved "{prefix}-original-" pattern (case-insensitive) are stripped from the input before annotation, so an attacker-supplied {prefix}-original-href can never masquerade as a value this library preserved. See WithKeepOriginalURLAttrs to opt out when re-processing trusted prior output. Without this option, such attributes are ordinary attributes governed by normal policy rules.

Annotations count toward the output size limit (WithMaxOutputFactor).

Example
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse(`strip-tag script`)
	if err != nil {
		log.Fatal(err)
	}

	// Route link targets through a redirector, but keep the original URL
	// recoverable from the output.
	rewriter := func(ctx htmlpolicy.URLContext, u string) string {
		return "https://redirect.example/?1"
	}
	// Only annotate anchor hrefs; other rewritten attributes are not preserved.
	filter := func(ctx htmlpolicy.URLContext) bool {
		return ctx.Element == "a" && ctx.Attr == "href"
	}

	input := []byte(`<a href="https://example.com/page">link</a>`)
	output, _, err := policy.ApplyHTML(input,
		htmlpolicy.WithApplyURLRewriter(rewriter),
		htmlpolicy.WithOriginalURLAttr(filter))
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(string(output))
}
Output:
<a href="https://redirect.example/?1" htmlpolicy-original-href="https://example.com/page">link</a>

func WithPreserveOriginal

func WithPreserveOriginal() ApplyOption

WithPreserveOriginal configures this Apply call to return the original input bytes when no policy rules modify the content, instead of re-serializing through the HTML parser.

By default, ApplyHTML/ApplyDocument always re-serialize through Go's HTML5 parser, which normalizes malformed HTML. This is the safer default because it ensures the browser sees exactly the same structure the sanitizer saw.

Warning: enabling this option means that when no rules match, the original bytes are returned unmodified. If the input contains malformed HTML that Go's parser and the browser parse differently, this creates a parser-differential attack surface. Only enable this option if you trust the input to be well-formed HTML, or if you need byte-for-byte preservation of unmodified content (e.g., to avoid altering whitespace or attribute quoting in content that was not changed by policy rules).

One well-formed input is never returned verbatim by Policy.ApplyHTML: a fragment containing a <body>, <html>, <frameset> or <frame> start tag. Fragment parsing discards those tags without building an element, so no rule can examine their attributes (a <body onload=…> is invisible to strip-attr); the fragment is re-serialized without them and reported as modified. Policy.ApplyDocument keeps the elements and matches rules against them, so it is unaffected. Embedded documents (srcdoc, data:text/html) are parsed as documents at every depth for the same reason, so an unmodified one is still returned verbatim.

Nor is input, in either mode and at any depth, that ends inside an unfinished construct: a tag, attribute value, comment or DOCTYPE cut off by the end of input (`<img src=x onerror=alert(1) `), an open raw-text or RCDATA element (<script>, <style>, <textarea>, <title>, <xmp>, <iframe>, <noembed>, <noframes>, <noscript>, <plaintext>), a trailing "<", or an open <svg>/<math>. The parser drops or neutralizes the unfinished part, so no rule sees it, but a page that concatenates the output completes it with its own bytes. Top-level input that leaves any element open is re-serialized too: `<p>hi<form action="https://evil.example/">` would otherwise put the page's following login fields in the attacker's form, and so is input that closes a <form> the parser still remembers as the current form (`<div><form action="https://evil.example/"></div>`), which makes the page's own following <form> start tag be ignored. Such input is re-serialized and reported as modified, so "modified is false" still means the output is exactly the input.

Example
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse(`
		strip-tag script
	`)
	if err != nil {
		log.Fatal(err)
	}

	// Unmodified content is returned as-is (byte-for-byte).
	input := []byte("<p>Hello</p>")
	output, modified, err := policy.ApplyHTML(input, htmlpolicy.WithPreserveOriginal())
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("modified:", modified)
	fmt.Println(string(output))
}
Output:
modified: false
<p>Hello</p>

func WithVerboseLog

func WithVerboseLog(w io.Writer) ApplyOption

WithVerboseLog enables verbose logging of policy rule matches for this single Apply call. When set, every sanitization action (strip, defang, demote, comment-out, placeholder) writes a one-line description to w. Elements and attributes that pass through unchanged are not logged. A line names the rule that fired by its location, as "line N" for the top-level policy text and "SOURCE:N" for a rule from an included file or a builtin: preset (the same form Warning.String uses).

Because the option is scoped to one call, the writer need not be safe for concurrent use unless the same writer is passed to concurrent Apply calls.

Log lines routinely quote attacker-controlled content (tag names, attribute values, URLs, CSS text) verbatim from the input being sanitized. Before writing, each line is escaped via [sanitizeLogLine]: ASCII C0/C1 control characters, DEL, and Unicode bidi override/isolate characters are rewritten as \xHH/\uHHHH so the raw bytes/runes never reach w — this matters when w is (or forwards to) a terminal, where they could otherwise be interpreted as escape sequences (retitling a window, moving the cursor, hiding or reordering text) rather than displayed as data. This applies to every writer passed to WithVerboseLog, including one supplied by a library caller, not only the CLI's own use of this option. Newlines are escaped for the same reason and so that one log line always corresponds to one output line.

type AttrRule

type AttrRule struct {
	Selector string           // CSS selector text
	Matcher  cascadia.Matcher // compiled selector
	AttrName string           // attribute name pattern (may have trailing "*" glob)
	Action   Action           // Allow, Strip, or Defang
	Line     int              // source line number
	Source   string           // canonical include name the rule came from, or "" for the top-level policy text (see Resolver.Resolve)
}

AttrRule defines filtering for a specific attribute on matching elements.

AttrRule is exported to document the compiled rule shape; no public function returns or accepts it, so it is not reachable through the public API.

type CSSAtRule

type CSSAtRule struct {
	Name   string // e.g. "import", "font-face"
	Action Action // Allow, Strip, or Defang
	Line   int    // source line number
	Source string // canonical include name the rule came from, or "" for the top-level policy text (see Resolver.Resolve)
}

CSSAtRule filters CSS at-rules by name.

CSSAtRule is exported to document the compiled rule shape; no public function returns or accepts it, so it is not reachable through the public API.

type CSSContentTypeRule added in v0.9.0

type CSSContentTypeRule struct {
	Properties []string // CSS property name patterns (trailing "*" glob ok, "*" = all, "@import" = import URLs)
	Types      []string // MIME type patterns (lowercase), e.g. "image/*", "image/svg+xml"
	Action     Action   // Allow, Strip, or Defang
	Line       int      // source line number
	Source     string   // canonical include name the rule came from, or "" for the top-level policy text (see Resolver.Resolve)
}

CSSContentTypeRule filters data: URIs in CSS url() values (and @import) by MIME type. It mirrors CSSSchemeRule but matches the data: URI's media type instead of the URL scheme. The Properties target is a CSS property name pattern list (or "@import"); the Types are MIME patterns (a single "*" wildcard allowed, e.g. "image/*", "*+xml").

CSSContentTypeRule is exported to document the compiled rule shape; no public function returns or accepts it, so it is not reachable through the public API.

type CSSMediaRule added in v0.14.0

type CSSMediaRule struct {
	Feature string // lowercase glob, e.g. "prefers-color-scheme", "prefers-*", "print", "*"
	Value   string // lowercase glob, e.g. "dark"; "" matches any value
	Negated bool   // true to match only a negated occurrence ("not (FEATURE: VALUE)" or inside a "not TYPE and …" query) instead of any occurrence (see doc above)
	Action  Action // Allow or Strip
	Line    int    // source line number
	Source  string // canonical include name the rule came from, or "" for the top-level policy text (see Resolver.Resolve)
}

CSSMediaRule filters CSS @media query blocks (and @import media conditions) by media feature. A rule matches an @media block when any media feature or media type in its prelude matches Feature (and, if Value is non-empty, the feature's value). When a rule with Action Strip matches, the whole @media block (or @import) is removed. Matching is feature-presence based: it scans through "only"/"and"/"or" wrappers, and — for a plain, un-negated pattern — through "not" too, so "not (prefers-color-scheme: dark)" is matched on presence, not semantically inverted, exactly as if "not" were not there. Negated is additive, not a restriction on the plain form: a feature directly wrapped as "not (FEATURE: VALUE)", or any feature (and the media type) of a query negated as a whole ("not TYPE and (FEATURE: VALUE)"), is separately recorded as negated — a feature negated both ways is not — and a rule with Negated set matches ONLY that negated spelling — letting a second, "!"-prefixed rule (e.g. "!prefers-color-scheme:light") specifically target a dark override spelled as a negated light check, without changing what the unprefixed rule already matches. Like CSSPseudoRule, there is no Defang variant — media queries gate block applicability, they do not carry a value to neutralize.

CSSMediaRule is exported to document the compiled rule shape; no public function returns or accepts it, so it is not reachable through the public API.

type CSSMediumRule added in v0.30.1

type CSSMediumRule struct {
	Medium string // lowercase media type, e.g. "print", or "*" for any medium
	Action Action // Allow or Strip
	Line   int    // source line number
	Source string // canonical include name the rule came from, or "" for the top-level policy text (see Resolver.Resolve)
}

CSSMediumRule filters media-conditional CSS by media type: @media blocks, @import rules that carry a media-query-list, and <style>/<link rel=stylesheet> elements with a media attribute. A rule matches when the media query list can evaluate true for Medium (see the css-*-medium section of the package documentation for the exact evaluation); a matching Strip rule removes the whole block, @import or element. There is no Defang variant.

CSSMediumRule is exported to document the compiled rule shape; no public function returns or accepts it, so it is not reachable through the public API.

type CSSPropertyRule

type CSSPropertyRule struct {
	Properties []string // property names (trailing "*" glob ok)
	Action     Action   // Allow, Strip, or Defang
	Line       int      // source line number
	Source     string   // canonical include name the rule came from, or "" for the top-level policy text (see Resolver.Resolve)
}

CSSPropertyRule filters CSS properties by name.

CSSPropertyRule is exported to document the compiled rule shape; no public function returns or accepts it, so it is not reachable through the public API.

type CSSPseudoRule added in v0.4.0

type CSSPseudoRule struct {
	Kind    pseudoKind // any / class / element
	Pattern string     // lowercase name, optional trailing "*" glob
	Action  Action     // Allow or Strip
	Line    int        // source line number
	Source  string     // canonical include name the rule came from, or "" for the top-level policy text (see Resolver.Resolve)
}

CSSPseudoRule filters CSS selectors by pseudo-class / pseudo-element name. When a rule with Action Strip matches a pseudo anywhere within a complex (comma-separated) selector — including pseudos nested inside functional pseudo-classes such as :has(), :is(), :not(), :where() — that entire complex selector is dropped. If every complex selector in a ruleset's prelude is dropped, the whole ruleset is removed.

CSSPseudoRule is exported to document the compiled rule shape; no public function returns or accepts it, so it is not reachable through the public API.

type CSSSchemeRule

type CSSSchemeRule struct {
	Properties []string // CSS property name patterns (trailing "*" glob ok, "*" = all, "@import" = import URLs)
	Schemes    []string // e.g. ["javascript", "data", ":relative", ":protocol-relative", "*"]
	Action     Action   // Allow, Strip, or Defang
	Line       int      // source line number
	Source     string   // canonical include name the rule came from, or "" for the top-level policy text (see Resolver.Resolve)
}

CSSSchemeRule filters URLs in CSS url() values by scheme.

CSSSchemeRule is exported to document the compiled rule shape; no public function returns or accepts it, so it is not reachable through the public API.

type CSSValueRule

type CSSValueRule struct {
	Pattern string // e.g. "url(*)", "expression(*)"
	Action  Action // Strip or Defang
	Line    int    // source line number
	Source  string // canonical include name the rule came from, or "" for the top-level policy text (see Resolver.Resolve)
}

CSSValueRule filters CSS properties whose values match a pattern. Patterns ending in "(*)" match any value containing a call to that CSS function (e.g. "expression(*)" matches values containing "expression(...)"). Other patterns match the full value literally (case-insensitive, CSS-escape-decoded, ignoring a trailing !important).

CSSValueRule is exported to document the compiled rule shape; no public function returns or accepts it, so it is not reachable through the public API.

type ContentTypeRule

type ContentTypeRule struct {
	Selector string           // CSS selector text
	Matcher  cascadia.Matcher // compiled selector
	Attr     string           // attribute name pattern (e.g. "src", "*", "href")
	Types    []string         // MIME type patterns (lowercase), e.g. "image/*", "text/html"
	Action   Action           // Allow, Strip, or Defang
	Line     int
	Source   string // canonical include name the rule came from, or "" for the top-level policy text (see Resolver.Resolve)
}

ContentTypeRule restricts data URI MIME types for an attribute on matching elements. The Action field determines what happens to data URIs whose MIME type matches the rule's pattern list: Allow recursively sanitizes them, Strip removes the URI value, and Defang renames the attribute to make it inert.

ContentTypeRule is exported to document the compiled rule shape; no public function returns or accepts it, so it is not reachable through the public API.

type FileResolver added in v0.9.0

type FileResolver struct {
	// BaseDir is the directory that top-level relative include names resolve
	// against. An empty BaseDir resolves relative names against the process's
	// current working directory.
	BaseDir string
}

FileResolver is a Resolver that loads include directives by reading policy files from disk. Supply it via WithResolver:

policy, err := htmlpolicy.Parse(text, htmlpolicy.WithResolver(
	htmlpolicy.FileResolver{BaseDir: "/etc/policies"}))

Relative include names in a nested include resolve against the directory of the including policy file (filepath.Dir(from)); for top-level includes (from == "") they resolve against BaseDir. The canonical name is the path the file was loaded from, so nested includes resolve against the right directory and circular includes are detected reliably. That path is absolute only when BaseDir is — it is used purely as an identity, so a relative BaseDir works the same way.

FileResolver reads any path the policy text names, including absolute paths and ones escaping BaseDir via "..". It performs no confinement, so treat policy text passed to it as trusted input.

func (FileResolver) Resolve added in v0.9.0

func (r FileResolver) Resolve(from, name string) (canonical, text string, err error)

Resolve implements Resolver.

type ParseOption

type ParseOption func(*parseConfig)

ParseOption configures the behavior of Parse.

func WithMaxIncludeDepth

func WithMaxIncludeDepth(n int) ParseOption

WithMaxIncludeDepth sets the maximum nesting depth for include directives. The default is DefaultMaxIncludeDepth (64). A value of zero or less disables includes entirely: any include directive then fails to parse.

Example
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse(`
		strip-tag script,style
	`, htmlpolicy.WithMaxIncludeDepth(4))
	if err != nil {
		log.Fatal(err)
	}

	output, _, err := policy.ApplyHTML([]byte("<p>Hello</p><script>evil</script>"))
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(output))
}
Output:
<p>Hello</p>

func WithPrefix added in v0.9.0

func WithPrefix(prefix string) ParseOption

WithPrefix sets the base prefix used when generating names for the CommentOut action (e.g. "PREFIX-commented-out ...") and every defang action (defang-attr/defang-scheme/defang-content-type, the CSS defang verbs, and SVG animated-value defang, e.g. "PREFIX-defanged-onclick"). The default is "htmlpolicy".

The prefix must be non-empty, start with an ASCII letter, and contain only ASCII letters, digits, or hyphens thereafter; an invalid prefix is reported as an error from Parse. A "prefix" directive in the policy text sets the same value and, being applied during parsing, takes precedence over WithPrefix.

Example
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse(`
		defang-attr * onclick
		comment-out-tag script
	`, htmlpolicy.WithPrefix("myapp"))
	if err != nil {
		log.Fatal(err)
	}

	output, _, err := policy.ApplyHTML([]byte(`<p onclick="track()">Hello</p><script>evil</script>`))
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(output))
}
Output:
<p myapp-defanged-onclick="track()">Hello</p><!--myapp-commented-out <script>evil</script>-->
Example (Error)
package main

import (
	"fmt"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	_, err := htmlpolicy.Parse(`defang-attr * on*`, htmlpolicy.WithPrefix(""))
	fmt.Println(err)
}
Output:
prefix must not be empty

func WithResolver added in v0.9.0

func WithResolver(r Resolver) ParseOption

WithResolver sets the Resolver used to load policies referenced by include directives (by name or path). It is only needed when the policy contains include directives that are not built-in presets ("include builtin:NAME" works without a resolver).

type Policy

type Policy struct {
	// contains filtered or unexported fields
}

Policy is a compiled set of rules for sanitizing HTML, CSS, and SVG content.

A Policy is created by Parse and is immutable afterward.

A Policy is safe for concurrent use by multiple goroutines. Policy.ApplyHTML does not mutate the Policy.

Security note: this library is policy-driven and does not hardcode any element or attribute as safe or dangerous. Policy authors must account for modern HTML features including Declarative Shadow DOM (<template shadowrootmode>), iframe srcdoc, fencedframe, custom elements (the is attribute), and CSP nonce attributes. See the project README for recommended baselines and security guidance.

func Parse

func Parse(input string, opts ...ParseOption) (*Policy, error)

Parse parses policy text into a Policy. Include directives that are not built-in presets require a Resolver supplied via WithResolver.

Example
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse(`
		strip-tag script,style
		strip-attr * on*
		strip-attr a[href^=javascript:] href
	`)
	if err != nil {
		log.Fatal(err)
	}

	input := []byte(`<p onclick="track()">Hello</p><script>alert(1)</script>`)
	output, modified, err := policy.ApplyHTML(input)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("modified:", modified)
	fmt.Println("output:", string(output))
}
Output:
modified: true
output: <p>Hello</p>
Example (Allowlist)
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	// A safe allowlist needs a baseline in every rule family it reopens.
	// Reopening an attribute with "allow-attr a href" also needs a scheme baseline,
	// or javascript: URLs pass through. Here strip-scheme/allow-scheme drop the
	// javascript: href while keeping the https one.
	policy, err := htmlpolicy.Parse(`
		strip-tag *
		strip-attr * *
		allow-tag p,div,b,i,em,strong
		allow-tag a
		allow-attr a href
		allow-attr * class
		strip-attr * on*
		strip-scheme * href *
		allow-scheme * href https,mailto
	`)
	if err != nil {
		log.Fatal(err)
	}

	input := []byte(`<div><p class="text"><a href="https://example.com" onclick="x">Link</a> <a href="javascript:alert(1)">evil</a> and <b>bold</b></p><script>evil</script></div>`)
	output, _, err := policy.ApplyHTML(input)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(string(output))
}
Output:
<div><p class="text"><a href="https://example.com">Link</a> <a>evil</a> and <b>bold</b></p></div>
Example (BuiltinPreset)

ExampleParse_builtinPreset shows applying a maintained built-in preset via an include directive. "include builtin:NAME" works in any policy with no resolver configured; later rules override the preset under last-match-wins.

package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse(`
		include builtin:blocklist
	`)
	if err != nil {
		log.Fatal(err)
	}

	input := []byte(`<p>Hi</p><a href="javascript:alert(1)">x</a><script>evil</script>`)
	output, _, err := policy.ApplyHTML(input)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(string(output))
}
Output:
<p>Hi</p><a>x</a>
Example (CommentOut)
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse(`
		comment-out-tag script
	`)
	if err != nil {
		log.Fatal(err)
	}

	input := []byte(`<p>safe</p><script>evil()</script>`)
	output, _, err := policy.ApplyHTML(input)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(output))
}
Output:
<p>safe</p><!--htmlpolicy-commented-out <script>evil()</script>-->
Example (ContentTypeFiltering)
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse(`
		strip-content-type * src *
		allow-content-type * src image/*
	`)
	if err != nil {
		log.Fatal(err)
	}

	input := []byte(`<img src="data:image/png;base64,iVBOR"/><img src="data:text/html,<script>evil</script>"/>`)
	output, _, err := policy.ApplyHTML(input)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(output))
}
Output:
<img src="data:image/png;base64,iVBOR"/><img/>
Example (CssAtRules)
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse(`
		css-strip-at import
		css-defang-at media
		css-allow-at keyframes
	`)
	if err != nil {
		log.Fatal(err)
	}

	input := []byte("@import \"evil.css\";\n@media screen { .x { color: red; } }\n@keyframes fade { from { opacity: 0; } }")
	output, _, err := policy.ApplyCSS(input)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(output))
}
Output:
@htmlpolicy-defanged-media screen {
.x {
color: red;
}
}
@keyframes fade {
from {
opacity: 0;
}
}
Example (CssMedia)
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	// Force light mode by stripping prefers-color-scheme:dark @media overrides.
	// See also the builtin:strip-dark-mode preset.
	policy, err := htmlpolicy.Parse(`css-strip-media prefers-color-scheme:dark`)
	if err != nil {
		log.Fatal(err)
	}

	input := []byte("body { background: white; }\n@media (prefers-color-scheme: dark) { body { background: black; } }")
	output, _, err := policy.ApplyCSS(input)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(output))
}
Output:
body {
background: white;
}
Example (DefangContentType)
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse(`
		defang-content-type * src image/svg+xml
	`)
	if err != nil {
		log.Fatal(err)
	}

	input := []byte(`<img src="data:image/svg+xml,<svg/>"/><img src="data:image/png;base64,iVBOR"/>`)
	output, _, err := policy.ApplyHTML(input)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(output))
}
Output:
<img htmlpolicy-defanged-src="data:image/svg+xml,&lt;svg/&gt;"/><img src="data:image/png;base64,iVBOR"/>
Example (DefangScheme)
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse(`
		defang-scheme * href *
		allow-scheme * href https,:relative
	`)
	if err != nil {
		log.Fatal(err)
	}

	input := []byte(`<a href="javascript:alert(1)">evil</a><a href="https://example.com">safe</a>`)
	output, _, err := policy.ApplyHTML(input)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(output))
	// A navigable anchor href is defanged in place: the scheme is prefixed
	// with htmlpolicy-defanged (inert) so the link stays visible/copyable.
	
Example (Demote)
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse(`
		demote-tag form
		allow-attr form class
		demote-tag marquee
	`)
	if err != nil {
		log.Fatal(err)
	}

	input := []byte(`<form class="contact"><input type="text"/></form><marquee>hello</marquee>`)
	output, _, err := policy.ApplyHTML(input)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(output))
}
Output:
<div class="contact"><input type="text"/></div><div>hello</div>
Example (Error)
package main

import (
	"fmt"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	_, err := htmlpolicy.Parse(`badverb script`)
	fmt.Println(err)
}
Output:
line 1: unknown verb "badverb"
Example (Placeholder)
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse(`
		placeholder-tag script,iframe
		placeholder-label blocked
	`)
	if err != nil {
		log.Fatal(err)
	}

	input := []byte(`<p>safe</p><script>evil</script><iframe src="x">frame</iframe>`)
	output, _, err := policy.ApplyHTML(input)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(output))
}
Output:
<p>safe</p><strong title="&lt;script&gt;evil&lt;/script&gt;">[blocked: script]</strong><strong title="&lt;iframe src=&#34;x&#34;&gt;frame&lt;/iframe&gt;">[blocked: iframe]</strong>
Example (SchemeFiltering)
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse(`
		strip-scheme * href *
		allow-scheme * href https,mailto,:relative
		strip-scheme * src *
		allow-scheme * src https,:relative
	`)
	if err != nil {
		log.Fatal(err)
	}

	input := []byte(`<a href="https://example.com">safe</a><a href="javascript:alert(1)">evil</a><img src="photo.jpg"/>`)
	output, _, err := policy.ApplyHTML(input)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(string(output))
}
Output:
<a href="https://example.com">safe</a><a>evil</a><img src="photo.jpg"/>

func (*Policy) ApplyCSS

func (p *Policy) ApplyCSS(content []byte, opts ...ApplyOption) ([]byte, bool, error)

ApplyCSS sanitizes a standalone CSS stylesheet using the policy's CSS rules. It applies property, value, and at-rule filtering, and filters url() schemes. Use this to sanitize raw CSS content that is not embedded in HTML.

The second return value reports whether CSS rules or the rewriter changed the content; unlike Policy.ApplyHTML, when it is false the input bytes are returned unchanged (CSS is not re-serialized when nothing matched). "Changed" includes dropping an invalid or dead construct, recovering from a parse error, removing comments, and the CSS input preprocessing a browser applies before tokenizing (CR LF, CR and form feed become LF; NUL becomes U+FFFD; an invalid UTF-8 sequence becomes U+FFFD as a browser's decoder makes it), not only a rule match. It also includes the encoding guard (see the package documentation): every U+001B (ESC) is replaced with U+007F and a leading @charset naming an encoding other than UTF-8 is rewritten to "utf-8", so the output cannot be decoded as ISO-2022-JP into different CSS.

If the policy has no CSS rules and no URL rewriter is supplied via WithApplyURLRewriter, the content is returned unchanged. When a rewriter is supplied, url() values are rewritten even without CSS sanitization rules.

ApplyCSS is safe for concurrent use on the same Policy.

Returns an error if the output size exceeds the configured limit (see WithMaxOutputFactor).

Example
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse(`
		css-strip *
		css-allow color,font-size
		css-strip-value expression(*)
	`)
	if err != nil {
		log.Fatal(err)
	}

	input := []byte(`.header { color: red; margin: 10px; font-size: 14px; }`)
	output, modified, err := policy.ApplyCSS(input)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("modified:", modified)
	fmt.Println(string(output))
}
Output:
modified: true
.header {
color: red;
font-size: 14px;
}
Example (Pseudo)

ExamplePolicy_ApplyCSS_pseudo shows stripping selectors that use a pseudo-class. The whole complex selector is dropped (never just the pseudo), so an interaction-gated style cannot become an always-on one.

package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse(`css-strip-pseudo hover`)
	if err != nil {
		log.Fatal(err)
	}

	input := []byte(`.menu, .item:hover > .sub { color: red; } a:hover { color: blue; }`)
	output, _, err := policy.ApplyCSS(input)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(string(output))
}
Output:
.menu {
color: red;
}

func (*Policy) ApplyDocument

func (p *Policy) ApplyDocument(content []byte, opts ...ApplyOption) ([]byte, bool, error)

ApplyDocument applies the policy to a full HTML document. It returns the sanitized document, whether it was modified, and any error. The second return value reports whether policy rules changed the content, not whether the output bytes differ from the input — see Policy.ApplyHTML.

Unlike [ApplyHTML] which parses input as a fragment (suitable for user content embedded in a page), ApplyDocument parses input as a complete document, preserving the <!DOCTYPE>, <html>, <head>, and <body> structure. If the input is missing these elements, the HTML5 parser adds them.

Policy rules apply to all elements in the document, including those in <head> (e.g., <title>, <meta>, <link>, <style>, <script>).

The document's own <html>, <head> and <body> elements are the exception: when the last tag rule matching them is a catch-all such as "*" with an action other than allow-tag (the strip-tag * of an allowlist), their attributes are removed but the elements are kept and their children are still judged by their own rules — removing them would drop the whole document, and a browser recreates them anyway. A last-matching rule that names them (strip-tag head) empties them instead; see the package documentation. The same applies to srcdoc and data:text/html documents embedded at any depth.

Use ApplyHTML for user-generated content fragments (comments, emails, forum posts). Use ApplyDocument for sanitizing complete HTML pages.

ApplyDocument is safe for concurrent use on the same Policy.

Any <base> elements in the input are always stripped, and relative URLs are resolved against the first base href before policy rules run. This prevents <base> injection attacks and ensures scheme rules see resolved URLs. CSS URLs (style attributes, <style> text, animated style values) are resolved too, except a fragment-only url(#id) and anything inside a data: URI; only those url() and string tokens are rewritten. A fragment-only href or xlink:href on an SVG element other than <a> (<use href="#r">, an animation's target, an animated href keyframe) is a same-document reference a browser does not resolve against <base>, so it is left as written and never passed to a URL rewriter or prefetcher.

Returns an error if the output size exceeds the configured limit (see WithMaxOutputFactor).

Pass WithApplyURLRewriter to rewrite URLs in the sanitized output for this call only — see WithApplyURLRewriter for details.

Example
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse("strip-tag script")
	if err != nil {
		log.Fatal(err)
	}

	input := []byte(`<!doctype html><html><head><title>Page</title></head><body><p>Hello</p><script>evil</script></body></html>`)
	output, modified, err := policy.ApplyDocument(input)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("modified:", modified)
	fmt.Println(string(output))
}
Output:
modified: true
<!DOCTYPE html><html><head><meta charset="utf-8"/><title>Page</title></head><body><p>Hello</p></body></html>

func (*Policy) ApplyHTML

func (p *Policy) ApplyHTML(content []byte, opts ...ApplyOption) ([]byte, bool, error)

ApplyHTML applies the policy to an HTML fragment. It returns the sanitized content, whether it was modified, and any error.

The second return value reports whether policy rules changed the content (an element or attribute was stripped, defanged, demoted, etc.) — not whether the output bytes differ from the input. Because the default behavior re-serializes through Go's HTML5 parser, the output bytes can differ from the input (whitespace, attribute quoting, tag normalization) even when this is false. Use WithPreserveOriginal to return the original bytes verbatim when no rules matched.

Input must be UTF-8. Use ConvertToUTF8 first if the charset is unknown. Output is always UTF-8: an invalid UTF-8 sequence in the input is replaced with U+FFFD as a browser's decoder replaces it, and the content is then reported as modified (see the package documentation).

Content is parsed as a fragment in a body context — it is never wrapped in <html>/<head>/<body> tags. If the input contains those tags (or <frameset>/<frame>), they are discarded and their content is kept, exactly as a browser's fragment parsing algorithm (innerHTML) does. Because the discarded tags are never built as elements, no policy rule can match them or their attributes: under WithPreserveOriginal such input is therefore always re-serialized (dropping the tags) and reported as modified, rather than returned verbatim with an unexamined <body onload=…>. Use Policy.ApplyDocument to sanitize complete HTML documents with preserved document structure — or whenever the output will be concatenated server-side into a page, where a browser would merge <body>/<html> attributes onto the host document.

By default, output is always re-serialized through Go's HTML5 parser, which normalizes malformed HTML. This ensures the browser sees exactly the same structure the sanitizer saw, preventing parser-differential attacks. Use WithPreserveOriginal to return the original bytes when no policy rules modify the content.

ApplyHTML is safe for concurrent use on the same Policy.

Any <base> elements in the input are always stripped, and relative URLs are resolved against the first base href before policy rules run. This prevents <base> injection attacks and ensures scheme rules see resolved URLs. CSS URLs (style attributes, <style> text, animated style values) are resolved too, except a fragment-only url(#id) and anything inside a data: URI; only those url() and string tokens are rewritten. A fragment-only href or xlink:href on an SVG element other than <a> (<use href="#r">, an animation's target, an animated href keyframe) is a same-document reference a browser does not resolve against <base>, so it is left as written and never passed to a URL rewriter or prefetcher.

Returns an error if the output size exceeds the configured limit (see WithMaxOutputFactor).

Pass WithApplyURLRewriter to rewrite URLs in the sanitized output for this call only — see WithApplyURLRewriter for details.

Example
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse("strip-tag script,style")
	if err != nil {
		log.Fatal(err)
	}

	output, modified, err := policy.ApplyHTML([]byte("<p>Hello</p><script>alert(1)</script>"))
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("modified:", modified)
	fmt.Println(string(output))
}
Output:
modified: true
<p>Hello</p>

func (*Policy) ApplyInlineCSS

func (p *Policy) ApplyInlineCSS(content []byte, opts ...ApplyOption) ([]byte, bool, error)

ApplyInlineCSS sanitizes a standalone inline CSS declaration list (the content of a style attribute, without surrounding HTML). It applies property, value, and URL scheme filtering.

The second return value reports whether CSS rules or the rewriter changed the content (including the ESC replacement described on Policy.ApplyCSS); when it is false the input bytes are returned unchanged.

If the policy has no CSS rules and no URL rewriter is supplied via WithApplyURLRewriter, the content is returned unchanged. When a rewriter is supplied, url() values are rewritten even without CSS sanitization rules.

ApplyInlineCSS is safe for concurrent use on the same Policy.

Returns an error if the output size exceeds the configured limit (see WithMaxOutputFactor).

Example
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse(`
		css-strip *
		css-allow color,font-size
	`)
	if err != nil {
		log.Fatal(err)
	}

	input := []byte(`color: red; margin: 10px; font-size: 14px`)
	output, modified, err := policy.ApplyInlineCSS(input)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("modified:", modified)
	fmt.Println(string(output))
}
Output:
modified: true
color: red; font-size: 14px

func (*Policy) Lint added in v0.9.0

func (p *Policy) Lint() []Warning

Lint reports likely mistakes in the policy without applying it. It is a static analysis of the compiled rules and never reads HTML input. An empty result means no issues were found; a non-empty result is advisory only (the policy is still valid and applied normally).

The checks are conservative — they aim for few false positives — and cover:

  • URL-bearing attributes that are explicitly allowed but left with no scheme rule constraining them, so javascript:/vbscript:/data: URLs may pass through (the most common fail-open mistake).
  • allow-scheme rules that reopen a dangerous scheme (javascript, vbscript, data) on an explicitly allowed URL attribute after an earlier scheme rule stripped or defanged it (last match wins by order). This check compares rules by attribute name only, not selector, so a strip and an allow on disjoint selectors can still warn; a later strip takes the reopen back only when it applies to every element the allow does (a catch-all selector such as `*`, or the same selector), so a narrower later strip (`strip-scheme area href javascript` after `allow-scheme a href javascript`) does not hide it, nor does a strip-attr of the attribute scoped to other elements. For data: only, the warning is suppressed when the content-type rules on the attribute deny unlisted types (a `strip-content-type * ATTR *` baseline before the allow-content-type list) — an allowlist that permits inline data: images must take the strip-then-allow shape, and such a baseline is how an author governs what an allowed data: URI may carry; merely having a content-type rule there is not enough. It is also suppressed when every reopening allow-scheme names only elements where the attribute's data: payload is inert or stripped — an image, audio, video or track subresource, or a slot never fetched (see URLContext.Load): e.g. `allow-scheme img src data`, `allow-scheme video poster data`. A selector whose subject is not a plain element name (`*`, `.cls`) keeps the warning.
  • allow-content-type rules whose type list names or globs an XML media type (image/svg+xml, text/xml, application/xml, application/xhtml+xml, any */xml or *+xml — so also image/*, application/* or a bare *): these data: URIs are always stripped regardless of any content-type rule, since the HTML5 parser cannot model an XML document closely enough to judge one safely.
  • allow-attr rules that reopen a dangerous non-URL attribute (an on* event handler, nonce, is, shadowrootmode, or srcdoc) after an earlier strip-attr removed it (last match wins by order) — the attribute analogue of the reopened-scheme check. A targeted allow such as `allow-attr img *` re-permits onerror even though it is not a universal rule the shadow check would catch. Compared by attribute name only, not selector; as for schemes, a later strip-attr takes the reopen back only when its selector is a catch-all or the allow's own and its attribute pattern names every dangerous attribute the allow reopens.
  • css-allow-scheme rules that reopen javascript or data on CSS url() values after an earlier css-strip-scheme stripped or defanged it (last match wins by order) — the CSS analogue of the reopened-scheme check, property-agnostic (any css-*-scheme rule counts, regardless of which properties it names). data is exempted when any css-*-content-type rule exists, mirroring the HTML data: exemption.
  • allow-tag rules that reopen a dangerous element (script, iframe, object, embed, base, meta, link, template, fencedframe, portal) after an earlier rule neutralized it (strip/comment-out/placeholder/demote/ unwrap), resolved against each rule's compiled selector matcher — so a comma-list reopen such as `allow-tag script,iframe` is caught even though its selector text differs from the rules it overrides. Each element is tested in the shapes it really takes — bare, with the attributes that make it active (meta http-equiv=refresh or charset, script/iframe/embed/fencedframe/portal src, iframe srcdoc, object data, link rel=stylesheet href, base href, template shadowrootmode), inside a document's body, and for script also an SVG <script> under <svg> — and reported when any of them is neutralized by an earlier rule and allowed by the last: `include builtin:blocklist` + `allow-tag meta` reopens the meta refresh the preset strips. A universal `allow-tag *` after the neutralizing rule is left to the shadow check below to avoid double-reporting; one before it does not suppress the warning. A reopened template is not reported when the attr rules that apply to template strip shadowrootmode: its content is an inert, separately sanitized fragment, and declarative shadow DOM is its only way to take over the document.
  • allow-* rules with no effect: an allow whose target nothing earlier strips or defangs only re-affirms the default (unmatched content already passes through), so the rule is dead. Comparison is by name/target and is deliberately over-broad about overlap, so a "dead" verdict is definite (an allowlist with a strip baseline before the allow is never flagged).
  • Rules made dead by a later rule in the same family: an identical rule, or a universal rule (a "*" selector, or a list with a bare "*" group such as "div, *") that overrides everything earlier. Because the engine is last-match-wins by order (not by specificity), an allow placed before a universal strip never takes effect.
  • "unwrap-tag *" (or any catch-all selector, such as "*, html") used as an allowlist catch-all without strip rules for active-content elements (script, style, iframe, …), which would leak their text content into the output.
  • A tag, attribute, scheme or content-type rule whose selector uses the `~` (subsequent-sibling) combinator, which costs O(siblings²) per parent at top level or inside a functional pseudo-class such as :not(x ~ p) (a `~` inside an attribute selector, or an escaped one, is not a combinator).

Warnings are returned sorted by source (top-level warnings, whose Source is "", sort first), then by line within that source.

Example
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	// This allowlist reopens <a href> but never constrains its scheme, so
	// javascript: URLs would pass through. Lint catches it statically.
	policy, err := htmlpolicy.Parse(`
		strip-tag *
		strip-attr * *
		allow-tag a
		allow-attr a href
	`)
	if err != nil {
		log.Fatal(err)
	}

	for _, w := range policy.Lint() {
		fmt.Println(w)
	}
}
Output:
line 5: URL attribute "href" is allowed but no scheme rule constrains javascript:/vbscript:/data:; such URLs may pass through (add a strip-scheme/allow-scheme baseline or `include builtin:url-safe`)

func (*Policy) String

func (p *Policy) String() string

String returns the policy as flattened policy text. The output is valid policy syntax that can be parsed back to produce an equivalent policy. Includes are fully resolved (inlined).

func (*Policy) URLAction added in v0.30.1

func (p *Policy) URLAction(element, attr, rawURL string) (action Action, rewritten bool)

URLAction reports what Policy.ApplyHTML would do with rawURL as the value of the named attribute of the named element, for a caller that holds a URL outside any document and must decide whether it may be used as it is — a link property of a calendar invitation, say, which nothing will ever sanitize.

Unlike Policy.URLSchemeAction, which consults scheme rules only, URLAction runs the whole per-URL decision Apply makes, in Apply's order:

  1. scheme rules (allow-scheme, strip-scheme, defang-scheme), exactly as URLSchemeAction evaluates them;
  2. for a URL a browser reads as a data: URL (URLScheme reports "data"), content-type rules on its media type (allow-content-type, strip-content-type, defang-content-type);
  3. the unconditional strips Apply applies to embedded documents whatever the policy says: every XML media type (image/svg+xml, application/xhtml+xml, text/xml, any *+xml, …), and a text/html body — or a text/css one when the policy has css-* rules — whose charset the library cannot reproduce (a declared charset other than UTF-8/US-ASCII, a UTF-16 BOM, an in-content declaration such as <meta charset>), whose base64 parameter is not the last one, or whose media type is ambiguous (raw controls, non-ASCII or percent-encoded bytes in the header); and any text/html or XML document as an action or formaction, since a form submission appends its fields to it.

action is Strip or Defang when any step would remove or neutralize the URL, and Allow otherwise.

rewritten qualifies an Allow: it is true when Apply keeps the URL only in a rewritten form, so the original rawURL is NOT what was approved. That is the case for a data:text/html URL, whose document Apply sanitizes recursively and re-encodes (always, even when no rule fires); for a data:text/css URL when the policy has any css-* rule, whose stylesheet Apply sanitizes recursively; for a URL holding an ESC, SO or SI byte, which Apply replaces with DEL wherever it occurs; and for a URL holding invalid UTF-8, which Apply replaces with U+FFFD as a browser's decoder does. A caller that cannot run the URL through Apply must treat (Allow, true) as not allowed: only (Allow, false) means the URL may be used unchanged. rewritten is always false when action is not Allow.

The same caveats as URLSchemeAction apply. Rules are matched against a detached element carrying only the named attribute, so a rule whose selector depends on document position, ancestors, siblings or other attributes may not match as it would in a document; tag and attribute rules (strip-tag, strip-attr, …) are not consulted, so Allow does not mean the attribute survives Apply, only that its URL does not stop it. element and attr are matched case-insensitively; "xlink:href" is judged as "href", the name the HTML parser gives it inside SVG. A <base> href is always Strip: Apply removes every <base> element. rawURL is one URL: for a multi-URL attribute (srcset, ping, …) pass each entry's URL, without its descriptor; for a meta refresh pass the URL after "url=" with element "meta" and attr "content" (one padded with Unicode whitespace, which Chromium and WebKit trim, is Strip: Apply removes such a refresh). attr should name a URL attribute (href, src, action, data, …); any other name is judged as though it were one, with the URL-attribute normalization, and the attributes whose values are not URLs — srcdoc, style, SMIL values — are out of scope. A rawURL holding a NUL or CR byte, which the HTML parser replaces (with U+FFFD, and LF), is reported rewritten. Base URL resolution and a URL rewriter are not involved.

Example
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	// URLAction answers "may this URL be used as it is?" for a URL that
	// will never pass through Apply, such as a link in a calendar
	// invitation. This policy re-allows data: URLs, which is safe inside
	// Apply because Apply sanitizes or strips their content.
	policy, err := htmlpolicy.Parse(`
		strip-scheme * * *
		allow-scheme * * http,https,mailto,data
	`)
	if err != nil {
		log.Fatal(err)
	}

	for _, u := range []string{
		"https://example.com/meeting",
		"javascript:alert(1)",
		"data:image/png;base64,iVBORw0KGgo=",
		"data:text/html,<script>alert(1)</script>",
		"data:image/svg+xml,<svg onload=alert(1)>",
	} {
		action, rewritten := policy.URLAction("a", "href", u)
		switch {
		case action == htmlpolicy.Allow && !rewritten:
			fmt.Printf("use as is:      %s\n", u)
		case action == htmlpolicy.Allow:
			fmt.Printf("rewritten:      %s\n", u)
		default:
			fmt.Printf("removed:        %s\n", u)
		}
	}
}
Output:
use as is:      https://example.com/meeting
removed:        javascript:alert(1)
use as is:      data:image/png;base64,iVBORw0KGgo=
rewritten:      data:text/html,<script>alert(1)</script>
removed:        data:image/svg+xml,<svg onload=alert(1)>

func (*Policy) URLSchemeAction added in v0.7.0

func (p *Policy) URLSchemeAction(element, attr, rawURL string) Action

URLSchemeAction reports the action the policy's scheme rules (allow-scheme, strip-scheme, defang-scheme) would take for a URL used in the named attribute of the named element. It lets a caller validate a URL out of band — without building and re-parsing HTML — answering the single question "is this URL's scheme permitted here?".

The return value is Allow if the scheme is permitted, or Strip / Defang if a scheme rule would neutralize it. These are the only actions scheme rules produce. element and attr are matched case-insensitively (e.g. "a", "href"); "xlink:href" is judged as "href", the name the HTML parser gives it. rawURL is judged exactly as Policy.ApplyHTML judges a single-URL attribute value: after Apply's replacement of invalid UTF-8 (U+FFFD) and of ESC, SO and SI (DEL), under its normalized reading — control and zero-width characters stripped, also for an attr that is not a URL attribute (a meta refresh "content", say) — and under the browser's reading (the URL parser's input cleanup only, see URLScheme), the more restrictive verdict winning, so the result matches what document sanitization would decide for that scheme. A usemap value starting with "#" is a same-document map name, never scheme-checked. Last-match-wins. A document-relative URL is matched by a rule listing ":relative" or "*"; an authority-relative one such as "//host/x" is matched by ":protocol-relative" or "*" but not by ":relative"; "https:x" is evaluated as both https and :relative (see the package documentation on relative URLs).

Scope, and what Allow does NOT mean:

  • URLSchemeAction evaluates scheme rules ONLY. It does not consider whether the element or attribute would itself be removed by other verbs (e.g. "strip a" or "strip-attr a href"), nor content-type rules that filter data: URIs by MIME type. A return of Allow therefore means "no scheme rule objects to this scheme" — not "this attribute survives sanitization". When the policy has no scheme rules at all, the result is always Allow.
  • In particular, Allow for a data: URL means "allowed given what Apply does to its content", not "safe to use as it is". Apply recursively sanitizes and re-encodes a data:text/html document (and a data:text/css stylesheet when the policy has css-* rules), and always strips XML data: types (image/svg+xml, application/xhtml+xml, …) and bodies in a charset it cannot reproduce — none of which this method sees. A caller that uses a URL outside Apply (out of band, never sanitized) must use Policy.URLAction instead, which reports those outcomes and whether Apply keeps the URL only in a rewritten form.
  • Selectors are evaluated against a detached element carrying only the named attribute. Scheme rules whose selectors depend on document position, ancestors, siblings, or other attributes may not match the way full-document Apply would; for such policies URLSchemeAction may report Allow for a URL that the document sanitizer would strip. For the common scheme policies (selectors of the form "*", a tag name, or an attribute selector on the URL attribute itself) the result is exact.
Example
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	// URLSchemeAction validates a URL's scheme against the policy without
	// building or re-parsing HTML — useful when an application has a URL in
	// hand and wants to decide whether to keep, drop, or defang it.
	policy, err := htmlpolicy.Parse(`
		strip-scheme * href *
		allow-scheme a href https,mailto,:relative
		defang-scheme a href http
	`)
	if err != nil {
		log.Fatal(err)
	}

	for _, u := range []string{
		"https://example.com",
		"http://example.com",
		"javascript:alert(1)",
		"/relative/path",
	} {
		switch policy.URLSchemeAction("a", "href", u) {
		case htmlpolicy.Allow:
			fmt.Printf("allow:  %s\n", u)
		case htmlpolicy.Defang:
			fmt.Printf("defang: %s\n", u)
		default:
			fmt.Printf("strip:  %s\n", u)
		}
	}
}
Output:
allow:  https://example.com
defang: http://example.com
strip:  javascript:alert(1)
allow:  /relative/path

type Resolver

type Resolver interface {
	Resolve(from, name string) (canonical, text string, err error)
}

Resolver loads policy text for include directives.

name is whatever appears after "include " in the policy file. The Resolver implementation decides how to interpret it (file path, database key, embedded resource, etc.).

from identifies the policy that contains the include directive, allowing the resolver to interpret name relative to the including policy's location. It is the canonical name returned by an earlier Resolve call (the include that loaded the parent), or the empty string for include directives in the top-level policy text passed to Parse. A file-based resolver typically treats from as a file path and joins relative name values against filepath.Dir(from); resolvers without a notion of location can ignore from entirely.

Resolve returns the canonical name of the loaded resource along with its text. The parser forwards canonical as from on nested include calls and uses it as the key for circular-include detection. Implementations should normalize the canonical form (e.g. resolve relative paths to absolute, strip "./" prefixes, normalize separators) so the same underlying resource always yields the same canonical string; otherwise a cycle through e.g. "foo.policy" and "./foo.policy" will only be caught by the depth limit (with a misleading "exceeds maximum depth" error rather than "circular include"). Resolvers without a notion of canonicalization may return name unchanged.

type SchemeRule

type SchemeRule struct {
	Selector string           // CSS selector text
	Matcher  cascadia.Matcher // compiled selector
	Attr     string           // attribute name pattern (e.g. "href", "*", "on*")
	Schemes  []string         // schemes (lowercase), ":relative" for document-relative URLs, ":protocol-relative" for "//host" URLs, or "*" for any scheme
	Action   Action           // Allow, Strip, or Defang
	Line     int
	Source   string // canonical include name the rule came from, or "" for the top-level policy text (see Resolver.Resolve)
}

SchemeRule restricts URL schemes for an attribute on matching elements. The Action field determines what happens to URLs with matching schemes: Allow passes them through, Strip removes them, and Defang renames the attribute to make it inert.

SchemeRule is exported to document the compiled rule shape; no public function returns or accepts it, so it is not reachable through the public API.

type TagRule

type TagRule struct {
	Selector string           // CSS selector text (for String())
	Matcher  cascadia.Matcher // compiled selector
	Action   Action           // what to do with matching elements
	Line     int              // source line number (for diagnostics)
	Source   string           // canonical include name the rule came from, or "" for the top-level policy text (see Resolver.Resolve)
}

TagRule defines an action for matching elements.

TagRule is exported to document the compiled rule shape; no public function returns or accepts it, so it is not reachable through the public API.

type URLContext

type URLContext struct {
	// Element is the lowercase HTML element name (e.g., "img", "a", "style").
	// Empty for standalone CSS sanitization ([Policy.ApplyCSS], [Policy.ApplyInlineCSS]).
	Element string

	// Attr is the HTML attribute name (e.g., "href", "src", "style").
	// Empty for URLs inside <style> elements or standalone CSS.
	Attr string

	// CSSProperty is the CSS property name containing the url() value
	// (e.g., "background-image", "background"), or "@import" for @import URLs.
	// Empty for non-CSS URLs (HTML attributes).
	CSSProperty string

	// Parent is the lowercase name of the URL's element's parent in the
	// HTML tree (e.g., "picture", "audio", "video", "head", "body"). Empty
	// when the element has no element parent — top-level fragment nodes,
	// document-root children whose parent is the document node, and
	// standalone CSS sanitization.
	//
	// Useful for elements whose semantics depend on ancestry, e.g.
	// <source src> is an image candidate inside <picture> but a media file
	// inside <audio>/<video>.
	Parent string

	// GetAttr returns the value of the named attribute on the URL's
	// element, or "" if the attribute is absent. Lookups are
	// case-insensitive. GetAttr is always non-nil; for orphan contexts
	// (standalone ApplyCSS / ApplyInlineCSS) it returns "" for any name.
	//
	// Useful for elements whose semantics depend on a sibling attribute on
	// the same element, e.g. <input type=image>, <link rel=stylesheet>,
	// <object type=...>. GetAttr reflects the live attribute slice on the
	// element at the time of the call, so mutations applied by earlier
	// rewriter calls on the same element are visible.
	//
	// GetAttr is not considered part of the deduplication key — the
	// cached rewriter result for a given {url, element, attr, parent,
	// cssProperty} tuple comes from the first call, so if you discriminate
	// on GetAttr across same-key elements only the first element's attrs
	// influence the cached value.
	GetAttr func(name string) string

	// Load says how a browser uses the URL in this slot: fetched
	// automatically as the content renders ([LoadSubresource]), only on a
	// user action ([LoadNavigation]), as a navigation without one
	// ([LoadAutoNavigation], <meta http-equiv=refresh>), as a request sent
	// when the user follows a link ([LoadBeacon], ping), never
	// ([LoadNone], e.g. cite), or [LoadUnknown] for a slot the library
	// cannot classify (src on an unrecognised element, say). A caller
	// blocking automatic loads should treat LoadUnknown like
	// LoadSubresource.
	//
	// The classification uses the element's namespace, its parent
	// (<source> under <picture> vs <audio>/<video>) and sibling attributes
	// (<link rel>/as, <input type>). Every CSS url() and @import is a
	// subresource. A URL in a SMIL animation's values/from/to/by reports
	// the slot of the animated attribute on the animation's target, while
	// Element and Attr still name the animation element and its value
	// attribute: <set attributeName="href" to="…"> targeting an SVG
	// <image> is a subresource image, targeting an SVG <a> a navigation.
	Load URLLoad

	// RequestType is the Fetch standard request destination of a
	// [LoadSubresource] URL: "image", "style", "script", "font", "audio",
	// "video", "track", "iframe", "frame", "object", "embed", "manifest",
	// "document", "json", "worker", or "" when the request has no
	// destination (rel=prefetch/preconnect/next, attributionsrc on <img>) or it
	// is not known. Always "" for any other Load.
	//
	// CSS: @import is "style", the @font-face src descriptor "font", every
	// other property "image" (external SVG paint, filter, mask and
	// clip-path references are fetched as images too, as are SVG
	// fill/stroke/filter/… url() presentation attributes). SVG href is
	// "image" on every referencing element except <script> ("script") —
	// including <use>, and gradients/patterns where only some engines fetch
	// an external reference. <link href> follows rel: stylesheet → "style",
	// icon variants → "image", manifest → "manifest", preload →
	// its as attribute, modulepreload → "script" (or as=json/style).
	//
	// Load and RequestType are part of the rewriter's deduplication key, so
	// the same URL as a <link rel=icon> and a <link rel=stylesheet> is
	// presented twice.
	RequestType string
}

URLContext describes where a URL was found in the sanitized output. It is passed to URLRewriter callbacks (configured per call via WithApplyURLRewriter) to provide context about each URL.

Example (Load)
package main

import (
	"fmt"
	"log"

	"gitlab.com/grepular/htmlpolicy"
)

func main() {
	policy, err := htmlpolicy.Parse(`allow-tag *`)
	if err != nil {
		log.Fatal(err)
	}

	// A tracker blocker: neutralise every URL the browser would fetch on
	// its own (and any it cannot be sure about), but leave links the user
	// has to click alone. RequestType says what kind of resource it is.
	blocked := map[string]int{}
	rewriter := func(ctx htmlpolicy.URLContext, u string) string {
		switch ctx.Load {
		case htmlpolicy.LoadSubresource, htmlpolicy.LoadUnknown, htmlpolicy.LoadAutoNavigation, htmlpolicy.LoadBeacon:
			blocked[ctx.RequestType]++
			return "#blocked"
		}
		return u
	}

	input := []byte(`<a href="https://example.com/" ping="https://t.example/ping">link</a>` +
		`<img src="https://t.example/pixel.gif">` +
		`<p style="background:url(https://t.example/bg.png)">text</p>` +
		`<svg><image href="https://t.example/i.svg"/></svg>`)
	output, _, err := policy.ApplyHTML(input, htmlpolicy.WithApplyURLRewriter(rewriter))
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(string(output))
	fmt.Println("images blocked:", blocked["image"])
}
Output:
<a href="https://example.com/" ping="#blocked">link</a><img src="#blocked"/><p style="background: url(&#34;#blocked&#34;)">text</p><svg><image href="#blocked"></image></svg>
images blocked: 3

type URLLoad added in v0.30.1

type URLLoad int

URLLoad says how a browser uses the URL in a slot: whether it is fetched automatically as the page renders, only when the user acts on it, or never. See URLContext.Load.

const (
	// LoadUnknown is reported for a slot this library cannot classify: an
	// (element, attribute) pair no specification it models gives a meaning
	// to, such as src on an unrecognised element, or a SMIL animation whose
	// target could not be determined. A caller that must fail closed (a
	// tracker blocker) should treat it like [LoadSubresource].
	LoadUnknown URLLoad = iota

	// LoadSubresource is a URL the browser fetches automatically while it
	// renders the content, with no user action: <img src>, <script src>,
	// <iframe src>, <link rel=stylesheet href>, CSS url() and @import, an
	// SVG <image href>, an SVG url() paint/filter/mask reference, and so on.
	// [URLContext.RequestType] names the fetch's destination.
	LoadSubresource

	// LoadNavigation is a URL the browser goes to only when the user acts:
	// a hyperlink (<a>/<area> href, an SVG or MathML link), a form
	// submission target (<form action>, formaction), or a longdesc. (A
	// browser may still resolve a link's host name ahead of time — DNS
	// prefetching — which reveals the host but requests no URL.)
	LoadNavigation

	// LoadAutoNavigation is a navigation the browser performs without any
	// user action: the URL of a <meta http-equiv=refresh> (reported with
	// [URLContext.Attr] "content").
	LoadAutoNavigation

	// LoadBeacon is a fire-and-forget request whose response the page never
	// uses, sent when the user follows the link that carries it: <a>/<area>
	// ping and attributionsrc. (attributionsrc on <img>/<script> is sent
	// automatically, so it is reported as [LoadSubresource].)
	LoadBeacon

	// LoadNone is a URL no browser fetches or navigates to: an identifier or
	// a reference to something already in the document — cite, usemap,
	// <link> with only non-fetching rel values (canonical, alternate, …),
	// <source src> inside <picture>, <input src> on a non-image input, an
	// SVG presentation attribute name on an HTML element.
	LoadNone
)

func (URLLoad) String added in v0.30.1

func (l URLLoad) String() string

String returns the constant's name without the "Load" prefix ("subresource", "navigation", …), or "unknown".

type URLRef added in v0.3.0

type URLRef struct {
	// Context describes where the URL was found: the element, attribute, CSS
	// property, parent element name, and a live GetAttr accessor. It is the
	// exact same URLContext value the [URLRewriter] would receive for this
	// URL. Context.GetAttr is valid for the duration of the prefetch callback
	// (the underlying nodes are still alive); do not retain it past the call.
	Context URLContext

	// URL is the discovered URL in its final resolved form — identical to the
	// value that would be passed to a [URLRewriter] for this same context.
	URL string
}

URLRef pairs a URL discovered during sanitization with the URLContext describing where it was found. A slice of URLRef is passed to the callback registered by WithApplyURLPrefetcher, allowing the caller to warm a cache (e.g. fetch resources in parallel) before the synchronous URLRewriter runs.

type URLRewriter

type URLRewriter func(ctx URLContext, url string) string

URLRewriter is a callback that receives each URL in the sanitized output and returns a replacement. Return the original url unchanged to keep it. Supply a rewriter to one Apply call via WithApplyURLRewriter. Because the rewriter is scoped to that single call, it does not need to be safe for concurrent use; two goroutines calling Apply on the same Policy pass independent rewriters and never share state.

URLs are presented after policy evaluation and base URL resolution, and after the WHATWG URL parser's own input cleanup: leading and trailing C0 controls and spaces are trimmed, and ASCII tab/LF/CR are removed from anywhere in the value. A browser applies exactly this before fetching, so <img src="https://host/logo.png "> is presented as "https://host/logo.png" and can be handed to net/url without the trailing space being escaped to %20. Other invisible characters (zero-width runes, U+00A0, DEL) are NOT removed: a browser does not remove them either, so a URL containing one is presented as written rather than turned into a URL nothing would request.

The attribute in the output is only ever changed by what the rewriter returns. Returning the presented URL unchanged leaves the original bytes — padding included — exactly as they were. A different URL is written into the slot so that the browser reads it back as that URL, encoded where the slot needs it; where no encoding can do that the URL is removed, never left as the original (see WithApplyURLRewriter).

Fragment-only references (#id), empty URLs, and data: URIs are excluded, judged on that same cleaned value: "\u200b#id" and "\u200bdata:x" are relative URLs a browser fetches, and are presented. URLs inside data URI content are recursed into (e.g. url(img.png) inside data:text/css is presented even though the data URI is not).

Calls are deduplicated within a single top-level applyHTMLAt / applyDocumentAt / applyCSS / applyInlineCSS invocation: each unique combination of {url, element, attr, parent, cssProperty} is presented to the callback exactly once for that invocation. The same URL in different contexts (e.g. <img src> vs <video poster>, or the same URL on a <source> inside <picture> vs inside <video>) produces separate calls. Recursive invocations triggered by embedded data URI content each have their own deduplication cache.

type Warning added in v0.9.0

type Warning struct {
	Line    int
	Message string
	Source  string
}

Warning describes a likely policy mistake found by Policy.Lint. Warnings are advisory: a policy that produces warnings is still valid and applied normally. Line is the 1-based source line the warning relates to, or 0 when it is not tied to a specific line. Source is the canonical name of the include the rule came from (see Resolver.Resolve), or "" when the rule is in the top-level policy text passed to Parse — a rule pulled in via "include builtin:NAME" reports Source as "builtin:NAME". Line is always relative to Source: two warnings can carry the same Line with different Source values when they come from different included files.

func (Warning) String added in v0.9.0

func (w Warning) String() string

String renders the warning as "source:line: message" when Source is set, "line N: message" when only Line is set, or just the message when neither is. Source is escaped the same way WithVerboseLog escapes attacker- controlled bytes, since Source is an opaque string a Resolver implementation derived from policy text (an include name or path) and may contain control characters or line-breaking bytes.

Directories

Path Synopsis
cmd
htmlpolicy command
Command htmlpolicy applies an HTML sanitization policy to HTML content.
Command htmlpolicy applies an HTML sanitization policy to HTML content.
Command playground is the WebAssembly entry point for the browser playground.
Command playground is the WebAssembly entry point for the browser playground.
engine
Package engine is the platform-independent core of the WebAssembly playground.
Package engine is the platform-independent core of the WebAssembly playground.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL