Layered sanitizing

Sanitizing is subtractive and layered: each configurable allowlist can only remove more than the layer below it, and under all of them sits a baseline no policy can reach. The order matters, so read a kept style attribute as the worked example.

A style declaration passes through three gates in turn. The non-configurable safety baseline drops expression(), url(javascript:...), behavior, and -moz-binding. It decodes CSS escapes before matching tokens, while text inside strings and comments stays inert. Then css_properties drops names outside the property allowlist. A declaration that clears both reaches allowed_styles, which checks its value against the patterns for the element’s tag or "*".

The layering is deliberately one-directional. allowed_styles narrows – it can reject a value the earlier layers would have kept, but it can never re-admit one they dropped. A caller who writes {"color": [r".*"]} has not opened a hole: .* matches expression(alert(1)) as a string, but the baseline already discarded that declaration two gates earlier, so the pattern never sees it. This is why the value patterns are a validation step, not an authorization one: they answer “is this known-good?”, and the answer only ever shrinks what survives.

Keeping the dangerous-value baseline unconditional – rather than folding it into css_properties or allowed_styles – is what makes a permissive policy safe by construction. A caller tuning an allowlist is reasoning about which benign values to accept; they are never one regex away from re-enabling script execution, because that decision was taken out of the policy’s hands entirely. The same shape governs the rest of the sanitizer: on* event handlers, <script>, and javascript: URLs are dropped below the allowlists, so no combination of tags, attributes, or attribute_filter settings can bring them back.

attribute_filter and set_attributes write after the first checks over parsed attributes. The sanitizer therefore checks each rewritten value again. This pass covers handlers, URL and srcset schemes, CSS, template markers, configured value sets, media hosts, and named-property isolation. The serializer receives the checked result. Callbacks can narrow or rewrite policy output without gaining a route around the baseline. The extra scan runs only when a callback or matching set_attributes rule wrote a value, so configuring one tag does not rescan unrelated elements.

transform_tags is the one step that adds rather than removes – it renames an element and can inject attributes – so its placement is what keeps the model intact. The rename runs at the very top, before the allowlist reads the tag, and then the walk continues on the renamed element as if the author had written the target: the allowlist decides its disposition, the unsafe-tag baseline still escapes a script or iframe target, and every injected attribute joins the element’s own to be scrubbed by the same gates below. A transform therefore chooses an element’s name while every gate underneath still governs its safety. Putting the additive step above the subtractive stack, instead of letting it write past the allowlist, is why {"b": "script"} cannot smuggle a live <script> and an injected href cannot carry a javascript: URL – the transform hands its output back to the pipeline rather than around it. HTML target and injected attribute names are ASCII-lowercased before those checks, matching browser name handling and preventing a mixed-case policy value from bypassing a lowercase safety rule.

isolate_named_props is the other rewriting step, and its design turns on a constraint the layered model does not: turbohtml has no live DOM. DOM clobbering exploits named access – an id or name whose value matches a built-in property makes that property resolve to the attacker’s element (<input name="attributes"> shadows form.attributes, <img name="body"> shadows document.body). Nothing about such an attribute is malformed, so the allowlist keeps it; only a defense aimed at the collision itself removes it. DOMPurify offers two: SANITIZE_DOM tests value in document and drops a real collision, and SANITIZE_NAMED_PROPS prefixes every id/name value with user-content-. The first needs the running engine’s property set, which only a live DOM can enumerate; the second is a pure string transform. turbohtml sanitizes a parsed tree with no DOM to probe, so it takes the second design: prefixing is unconditional and complete, where a static reimplementation of the in document check would be a hand-maintained name list that silently misses whatever property a future engine adds. Applying the prefix after attribute_filter – the last configurable gate – rather than before, means no filter can hand back a clobbering value the isolation then fails to namespace; like the dangerous-value baseline, the guarantee sits below the caller’s reach. Idempotence closes the loop: a value already carrying the prefix is left untouched, so sanitizing sanitized output is a fixpoint rather than a growing stack of user-content-user-content- markers.

custom_element_check extends the allowlist without weakening the model. An allowlist answers “is this name known good?”; a matcher answers the same question for a family of names the caller cannot enumerate ahead of time – their own x-* or my-* elements. It is still a membership test, only computed rather than looked up, so it can only admit names, never bypass what sits below: the unsafe-tag baseline escapes a scriptable target before the matcher is ever consulted, and custom_attribute_check widens which attribute names survive on a kept custom element while the on*, URL-scheme, and style gates still scrub their values. This is a deliberate divergence from DOMPurify, whose attributeNameCheck can readmit an on* handler if the caller’s pattern matches it: turbohtml keeps the event-handler and URL baseline unconditional, so a custom-element policy is safe by construction the same way a style policy is. Only basic custom-element names reach the matcher – a hyphenated name clear of the reserved annotation-xml/font-face set – so a matcher cannot be tricked into keeping a real foreign element by its name.

SVG animation needs an element rule in addition to direct attribute checks. animate, set, animateMotion, animateTransform, and animateColor can assign the attribute named by attributeName at runtime. A value in from, to, or values can write a script URL or event handler without placing that value in a direct URL or on* attribute. The baseline blocks these animation elements under custom SVG allowlists.

The string API parses into the private tree that the native walk mutates. The internal Element entrypoint snapshots its input under the tree’s critical section, then releases the shared tree before invoking a policy callback. Callback code may retain or mutate the original tree without racing the sanitizer’s private copy.

The allow_html/allow_svg/allow_mathml profiles are the coarsest subtractive layer of all: each drops a whole content language above the allowlist, so a namespace a policy disables is gone no matter which of its tags appear in tags. They compose with the per-node namespace-reachability check rather than replacing it – the reachability check is a structural defense against a namespace-confused node the parser never produced, while the profiles are a policy choice about which content languages an application accepts at all. Keeping the two separate means enabling MathML is a statement about intent, not a relaxation of the mutation-XSS defense that still governs how a MathML node may be reached.

The policy walk uses an explicit checked stack rather than C recursion. It reaches the final safety pass at all depths that fits in memory; nesting cannot truncate the sanitized result or skip a descendant’s checks.

The xml flag sits outside the subtractive stack entirely: it changes how the surviving tree is serialized, not what survives. The walk is identical, and the safety baseline is unchanged, so an XML-mode policy is exactly as safe as its HTML-mode twin – serializing more strictly cannot make a safe tree unsafe. What the XML serializer adds is well-formedness, which HTML does not require and XML does: an empty element self-closes, text and attribute values follow the XML escaping rules, a foreign root declares its namespace, and the two constructs HTML tolerates but XML forbids are neutralized in the serializer, not the walk – a comment’s -- and trailing - gain a space, and a character outside XML’s Char production (a C0 control, a lone surrogate) is dropped, along with an attribute whose name is not a valid XML name. Because this is a property of the one serialization pass rather than a second reparse, the sanitizer keeps its parse-once-serialize-once shape: the tree the walk cleared is emitted directly as XML, with no round trip for a mutation-XSS vector to re-enter through. Every DOMPurify corpus vector, serialized this way, reparses through turbohtml.parse_xml() without error.

Validated against DOMPurify. tests/conformance/test_sanitizer_dompurify_conformance.py runs DOMPurify’s own corpus (its test/fixtures/expect.mjs, ~219 XSS vectors, vendored as a pinned submodule) through the sanitizer under every config, and diffs against a live DOMPurify Node build. The absolute result holds across the whole corpus and every config: turbohtml’s output is inert, and never keeps an executable construct DOMPurify strips. On curated inputs the two allowlists share, strip_template_markers, isolate_named_props, custom_element_check, and the namespace profiles match DOMPurify’s output exactly. Two divergences are on record, both cases where turbohtml is stricter: its template stripper collapses only marker runs that can open a template evaluation, leaving a bare unmatched close delimiter as inert text; and stripping a disallowed HTML wrapper drops a nested foreign (SVG/MathML) subtree rather than hoisting it.