Skip to content

Commit fe6216d

Browse files
fix(html): make huge_tree opt-in via UNSTRUCTURED_HTML_HUGE_TREE env var
Per @cragwolfe review: huge_tree=True disables libxml2's safety guards against malicious inputs (https://lxml.de/FAQ.html), so it must remain opt-in. Default stays huge_tree=False; set UNSTRUCTURED_HTML_HUGE_TREE to 1/true/yes to enable for trusted inputs. - Add test confirming default behavior drops nodes silently (matches prior behavior — no regression for existing users). - Test for the opt-in path patches the parser since it's built at module import time. - Updated changelog to document the env var and the security tradeoff.
1 parent 91f9431 commit fe6216d

3 files changed

Lines changed: 61 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
### Fixes
44

5-
- **Parse large/deeply nested HTML documents**: `partition_html` previously returned an empty element list for HTML with deep subtree nesting because the module-level `etree.HTMLParser` used lxml's default `huge_tree=False`, which silently drops nodes past the default depth limit. Enable `huge_tree=True` on the parser so deep documents round-trip correctly (#4289).
5+
- **Parse large/deeply nested HTML documents (opt-in)**: `partition_html` previously returned an empty element list for HTML with deep subtree nesting because the module-level `etree.HTMLParser` used lxml's default `huge_tree=False`, which silently drops nodes past the default depth limit. Set the `UNSTRUCTURED_HTML_HUGE_TREE` environment variable to `1`/`true`/`yes` to enable `huge_tree=True` and parse deeply nested documents. The default remains `False` because `huge_tree=True` disables libxml2's safety guards against malicious inputs (see https://lxml.de/FAQ.html) (#4289).
66

77
## 0.22.21
88

test_unstructured/partition/html/test_partition.py

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -108,26 +108,70 @@ def test_partition_html_accepts_an_html_str():
108108
assert len(elements) > 0
109109

110110

111-
def test_partition_html_parses_deeply_nested_html():
112-
"""Regression for #4289: large/deeply-nested HTML must not silently yield zero elements.
111+
def test_partition_html_huge_tree_defaults_to_disabled():
112+
"""`UNSTRUCTURED_HTML_HUGE_TREE` defaults to off so libxml2 safety guards stay on.
113113
114-
lxml's ``HTMLParser`` defaults to ``huge_tree=False``, which causes subtrees beyond its
115-
depth limit (~256) to be dropped silently. We enable ``huge_tree=True`` on the module-level
116-
parser so ``partition_html`` returns the inner text instead of an empty list.
114+
`huge_tree=True` disables protections against malicious inputs (see
115+
https://lxml.de/FAQ.html), so it must remain opt-in. This test asserts the
116+
module-level parser is constructed with `huge_tree=False` when the env var is unset.
117117
"""
118+
# The libxml2 parser doesn't expose `huge_tree` directly, so assert by behavior:
119+
# parsing a deeply-nested document without the env var should silently return [].
118120
depth = 260
119121
html = (
120122
"<html><body>"
121123
+ "<div>" * depth
122-
+ "<p>deeply nested paragraph</p>"
124+
+ "<p>deep</p>"
123125
+ "</div>" * depth
124126
+ "</body></html>"
125127
)
126128

127129
elements = partition_html(text=html)
128130

129-
assert len(elements) == 1
130-
assert elements[0].text == "deeply nested paragraph"
131+
# With huge_tree disabled, lxml drops nodes past the depth limit.
132+
assert elements == []
133+
134+
135+
def test_partition_html_parses_deeply_nested_html_when_huge_tree_enabled(monkeypatch):
136+
"""Regression for #4289: large/deeply-nested HTML must not silently yield zero elements.
137+
138+
lxml's ``HTMLParser`` defaults to ``huge_tree=False``, which causes subtrees beyond its
139+
depth limit (~256) to be dropped silently. Setting ``UNSTRUCTURED_HTML_HUGE_TREE=1`` opts
140+
into ``huge_tree=True`` on the module-level parser so ``partition_html`` returns the inner
141+
text instead of an empty list. The opt-in is required because ``huge_tree=True`` disables
142+
libxml2's safety guards (see https://lxml.de/FAQ.html).
143+
"""
144+
from unstructured.partition.html import parser as html_parser_module
145+
146+
monkeypatch.setenv("UNSTRUCTURED_HTML_HUGE_TREE", "1")
147+
# The parser is built at module import time, so swap it in directly for the test.
148+
original_parser = html_parser_module.html_parser
149+
fresh_parser = etree.HTMLParser(remove_comments=True, huge_tree=True)
150+
fresh_parser.set_element_class_lookup(html_parser_module.element_class_lookup)
151+
monkeypatch.setattr(html_parser_module, "html_parser", fresh_parser)
152+
# `partition.py` imported `html_parser` directly into its namespace, so patch that too.
153+
from unstructured.partition.html import partition as partition_module
154+
155+
monkeypatch.setattr(partition_module, "html_parser", fresh_parser)
156+
157+
try:
158+
depth = 260
159+
html = (
160+
"<html><body>"
161+
+ "<div>" * depth
162+
+ "<p>deeply nested paragraph</p>"
163+
+ "</div>" * depth
164+
+ "</body></html>"
165+
)
166+
167+
elements = partition_html(text=html)
168+
169+
assert len(elements) == 1
170+
assert elements[0].text == "deeply nested paragraph"
171+
finally:
172+
# Restore for any subsequent tests in this process.
173+
monkeypatch.setattr(html_parser_module, "html_parser", original_parser)
174+
monkeypatch.setattr(partition_module, "html_parser", original_parser)
131175

132176

133177
def test_partition_html_accepts_a_url_to_an_HTML_document(requests_get_: Mock):

unstructured/partition/html/parser.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575

7676
from __future__ import annotations
7777

78+
import os
7879
import re
7980
from collections import defaultdict, deque
8081
from functools import cached_property
@@ -945,7 +946,13 @@ def derive_element_type_from_text(text: str) -> type[Text] | None:
945946
# ------------------------------------------------------------------------------------------------
946947

947948

948-
html_parser = etree.HTMLParser(remove_comments=True, huge_tree=True)
949+
# `huge_tree=True` allows lxml to parse deeply nested HTML (>256 levels) but
950+
# disables libxml2's safety guards against malicious inputs. Default to off and
951+
# require explicit opt-in via the `UNSTRUCTURED_HTML_HUGE_TREE` env var.
952+
# See https://lxml.de/FAQ.html for the security tradeoffs.
953+
_HUGE_TREE = os.environ.get("UNSTRUCTURED_HTML_HUGE_TREE", "").lower() in ("1", "true", "yes")
954+
955+
html_parser = etree.HTMLParser(remove_comments=True, huge_tree=_HUGE_TREE)
949956
# -- elements that don't have a registered class get DefaultElement --
950957
fallback = etree.ElementDefaultClassLookup(element=DefaultElement)
951958
# -- elements that do have a registered class are assigned that class via lookup --

0 commit comments

Comments
 (0)