From html-sanitizer¶
html-sanitizer is an allowlist HTML sanitizer built on lxml, from the
author of FeinCMS and django-content-editor. You configure it with a settings dict: tags (a set of allowed
elements), attributes (allowed attribute names keyed by tag), empty and separate (tags that may stay empty
or must not merge with an adjacent twin), a sanitize_href scheme check, add_nofollow, autolink, and
element_preprocessors/element_postprocessors hooks. Beyond dropping disallowed markup it normalizes the tree:
collapsing whitespace, merging adjacent identical tags, and dropping empty ones. It is the sanitizer behind Django CMS
and rich-text-editor stacks that store user-authored HTML.
turbohtml covers the same allowlist job from its turbohtml.clean module. The move is a
settings-to-Policy translation rather than a rethink, and turbohtml runs the filtering in C
over its own WHATWG tree builder instead of an lxml parse.
turbohtml vs html-sanitizer¶
Dimension |
turbohtml |
html-sanitizer |
|---|---|---|
Scope |
Full WHATWG parser, serializer, sanitizer, linkifier, minifier, selectors |
Allowlist sanitize with whitespace and typographic normalization, over lxml |
Feature breadth |
Escape/strip/remove per tag, value-rewriting attribute filter, forced attributes, CSS-property scrubbing, fixed safety baseline |
Tag/attribute allowlist, tag merging, whitespace and typographic cleanup, autolink, pre/post-processor hooks |
Performance |
Filtering in C on a native tree |
Pure Python over an lxml parse |
Typing |
Fully annotated, |
Untyped |
Dependencies |
None (self-contained C extension) |
lxml |
Maintenance |
Active |
Active, single maintainer |
Feature overlap¶
The shared allowlist surface ports one-to-one:
Sanitizer(settings).sanitize(text)->turbohtml.clean.sanitize()with aPolicy, or a reusableSanitizer.settings["tags"](a set) ->Policy.tags(afrozenset).settings["attributes"](per-tag dict) ->Policy.attributes(per-tagfrozenset;"*"as key matches every tag).sanitize_hrefscheme check ->Policy.url_schemesplusPolicy.allow_relative_urls.add_nofollow=True->Policy.add_link_rel = frozenset({"nofollow"}).
What turbohtml adds¶
A frozen, thread-safe
Policyyou build once and reuse across threads.An
OnDisallowedenum that names three outcomes for a disallowed tag:ESCAPE(render as text),STRIP(unwrap, keep children), andREMOVE(drop the subtree). html-sanitizer only unwraps.Policy.remove_with_contentand a fixed safety baseline that drops<script>,on*event handlers, andjavascript:URLs by construction, so no allowlist can re-admit them and script text never leaks into the output.Policy.css_properties: whenstyleis allowed, its declarations are scrubbed against a safe property set.Policy.attribute_filterto rewrite or drop any surviving attribute value, andPolicy.set_attributesto force attribute values onto every kept instance of a tag.Policy.add_link_relgeneralizesadd_nofollowto anyreltoken set (for examplenoopener,noreferrer).Ready-made
Policy.strict(),basic(), andrelaxed()presets.
What html-sanitizer has that turbohtml does not¶
Whitespace collapsing, adjacent-tag merging, and empty-tag dropping (the
whitespace,separate, andemptysettings) run as part of everysanitizecall. turbohtml has no direct port; do the normalization with a walk over the returned tree.keep_typographicrewrites quotes and dashes to typographic characters. No equivalent.element_preprocessorsandelement_postprocessorsinsert arbitrary structural transforms into the pass. turbohtml’sattribute_filtercovers value-level rewriting, but structural post-processing is left to a walk over the tree.sanitize_hrefis an arbitrary callable, so it can rewrite or reject a URL on any rule. turbohtml checks a scheme allowlist (url_schemesplusallow_relative_urls); custom per-URL logic goes throughattribute_filterinstead.autolinkfuses URL detection into the sanitize call. turbohtml linkifies with a separateturbohtml.clean.linkify()pass rather than a sanitizer setting.
Performance¶
turbohtml leads html-sanitizer by one to two orders of magnitude, the WHATWG tree builder in C standing in for the lxml parse:
sanitize |
turbohtml |
|
|---|---|---|
comment |
1.57 µs |
49.2 µs (31.4x) |
post 4 KiB |
42.5 µs |
1.69 ms (39.8x) |
How to migrate¶
Swap the import and translate the settings dict into a Policy:
# html-sanitizer
from html_sanitizer import Sanitizer
Sanitizer({"tags": {"a", "p"}, "attributes": {"a": {"href"}}, "add_nofollow": True}).sanitize(text)
# turbohtml
from turbohtml.clean import sanitize, Policy
sanitize(
text,
Policy(
tags=frozenset({"a", "p"}),
attributes={"a": frozenset({"href"})},
add_link_rel=frozenset({"nofollow"}),
),
)
turbohtml |
|
|---|---|
|
|
|
|
|
|
|
|
|
|
from turbohtml.clean import sanitize, Policy
print(
sanitize(
'<p>Hi <a href="http://x">l</a></p>',
Policy(
tags=frozenset({"p", "a"}),
attributes={"a": frozenset({"href"})},
add_link_rel=frozenset({"nofollow"}),
),
)
)
<p>Hi <a href="http://x" rel="nofollow">l</a></p>
Gotchas and pitfalls¶
Disallowed-tag default differs. html-sanitizer unwraps a tag outside the allowlist and keeps its children; turbohtml escapes it to visible text. For parity, set
on_disallowed_tag=OnDisallowed.STRIPon the policy.No whitespace or tag-merging normalization. html-sanitizer collapses whitespace, merges adjacent identical tags, and drops empty ones; turbohtml preserves the tree it parsed. Add a post-sanitize walk if you rely on that cleanup.
The
element_preprocessors/element_postprocessorshooks have no direct port.attribute_filterhandles value-level rewriting; structural changes need a walk over the returned tree.turbohtml’s safety baseline is fixed. It removes
<script>,on*handlers, andjavascript:URLs even when a policy would admit them, so a policy that namesscriptintagsstill cannot keep it.