Skip to content

✨ feat(clean): add XML/XHTML output to the sanitizer#598

Merged
gaborbernat merged 1 commit into
tox-dev:mainfrom
gaborbernat:feat/565-sanitize-xml
Jul 8, 2026
Merged

✨ feat(clean): add XML/XHTML output to the sanitizer#598
gaborbernat merged 1 commit into
tox-dev:mainfrom
gaborbernat:feat/565-sanitize-xml

Conversation

@gaborbernat

@gaborbernat gaborbernat commented Jul 8, 2026

Copy link
Copy Markdown
Member

Sanitizing is not unique to HTML: an XHTML dialect such as Reportlab's RML or an ePub content document rejects HTML's bare <br>, so a bleach-based cleaner has to patch its output with a brittle .replace("<br>", "<br/>") or reparse and reserialize the whole tree a second time. Policy now takes an xml flag that removes that step: the sanitizer serializes its cleaned tree as well-formed XML/XHTML instead of HTML, so sanitize("<p>one<br>two</p>", Policy(tags=frozenset({"p", "br"}), xml=True)) returns <p>one<br/>two</p>. This closes #565 and clones DOMPurify's PARSER_MEDIA_TYPE: 'application/xhtml+xml'.

The allowlist walk and the non-configurable safety baseline are untouched, so an XML-mode policy is exactly as safe as its HTML-mode twin, and only the final serialization changes. 🔒 Because the sanitizer keeps its parse-once, serialize-once shape with no reparse round trip, there is no new opening for a mutation-XSS vector to re-enter. The well-formedness pass is opt-in on the serializer, carried by a well_formed serialize option that only the sanitizer's inner_xml turns on: on that path a comment's -- and trailing - gain a space, a character outside XML's Char production (a C0 control, a lone surrogate, U+FFFE/U+FFFF) is dropped, a stored xmlns the serializer already declares for a foreign root is not duplicated, and an attribute whose name is not a valid XML name is skipped. A plain Node.serialize(Html(xml=True)) stays on the raw XML path, byte- and instruction-identical to before, so the serialize benchmarks do not move. The new Node.inner_xml property exposes the well-formed children serialization the sanitizer routes through.

Every vector in DOMPurify's own XSS corpus (its test/fixtures/expect.mjs, vendored as a pinned submodule) is run through the XML sanitizer to confirm each one serializes to output that is both inert and reparses through turbohtml.parse_xml, and an lxml differential checks the same well-formedness against an independent parser. The xml flag is off by default, so existing callers are unaffected.

@gaborbernat gaborbernat added the enhancement New feature or request label Jul 8, 2026
@codspeed-hq

codspeed-hq Bot commented Jul 8, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 86 untouched benchmarks
🆕 1 new benchmark
⏩ 18 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
🆕 test_feature[sanitize-xml-spec] N/A 12.2 ms N/A

Comparing gaborbernat:feat/565-sanitize-xml (c082f67) with main (f73cae2)

Open in CodSpeed

Footnotes

  1. 18 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@gaborbernat gaborbernat force-pushed the feat/565-sanitize-xml branch 3 times, most recently from a6202dd to 585e62e Compare July 8, 2026 03:20
@gaborbernat gaborbernat marked this pull request as draft July 8, 2026 03:37
@gaborbernat gaborbernat marked this pull request as ready for review July 8, 2026 04:21
@gaborbernat gaborbernat force-pushed the feat/565-sanitize-xml branch from 585e62e to c04e7cd Compare July 8, 2026 04:21
Policy gained an xml flag: with it on, the sanitizer serializes the
cleaned tree as well-formed XML/XHTML instead of HTML. The allowlist walk
and the non-configurable safety baseline are untouched, so an XML-mode
policy is exactly as safe as its HTML-mode twin; only the final
serialization differs. Every kept empty element self-closes, foreign SVG
and MathML subtrees declare their namespace, and text and attribute values
follow the XML escaping rules.

The well-formedness pass is opt-in on the serializer, carried by a new
well_formed serialize option that only the sanitizer's inner_xml turns on.
On that path a comment's -- and trailing - gain a space, a character
outside XML's Char production (a C0 control, a lone surrogate,
U+FFFE/U+FFFF) is dropped, a stored xmlns the serializer already declares
is not duplicated, and an attribute whose name is not a valid XML name is
skipped, so a cleaned fragment always reparses through parse_xml. A plain
Node.serialize(Html(xml=True)) stays on the raw XML path, byte- and
instruction-identical to before, so the serialize benchmarks do not move.
The new Node.inner_xml property exposes the well-formed children
serialization the sanitizer routes through.

This clones DOMPurify's application/xhtml+xml parser mode and replaces the
brittle .replace("<br>", "<br/>") a bleach-based cleaner needs to feed a
strict XHTML consumer such as Reportlab's RML. The DOMPurify XSS corpus is
run through the XML sanitizer to prove every vector serializes to inert,
well-formed XML.

closes tox-dev#565
@gaborbernat gaborbernat force-pushed the feat/565-sanitize-xml branch from c04e7cd to c082f67 Compare July 8, 2026 04:24
@gaborbernat gaborbernat merged commit 917a854 into tox-dev:main Jul 8, 2026
49 checks passed
@gaborbernat gaborbernat deleted the feat/565-sanitize-xml branch July 10, 2026 17:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sanitizer: Support XML/XHTML output

1 participant