Document ingestion is the front door of most RAG and AI search systems. Files arrive from customers, partners and the open web, a parser turns them into elements, and somewhere a person reviews the result in a web page. In unstructured, a widely used open-source document parsing library, the HTML output of that step didn't escape anything. Event handlers and javascript: links in a submitted document came out the other side intact, and ran in the browser of whoever opened the parsed result (CVE-2026-104250).
It's a medium-severity bug, fixed in unstructured 0.24.1 in July. This teardown is for teams that build document pipelines, RAG apps and review tools on top of parsers like it. It covers what went wrong, the fix, a small model of the bug class you can run, a script that finds where parsed document HTML reaches a browser in your own code, and a short quiz at the end.
The short answer
unstructured 0.24.0 and earlier kept the tags, attributes and text of parsed HTML documents in metadata.text_as_html and elements_to_html() output without escaping them, when using partition_html(html_parser_version="v2") or the VLM partitioner. Attribute-based script vectors such as onerror, onload and onmouseover, and javascript: links, survived, so rendering that HTML ran an attacker's script in the viewer's session. Upgrade to 0.24.1, which escapes text and attributes, drops event handlers, allowlists tags and filters URL schemes. Then find every place your application renders parsed document HTML, and treat it as untrusted.
What went wrong
| CVE | CVE-2026-104250 (GHSA-v5mq-3xhg-98m9) |
| Severity | Medium, CVSS 3.1 score 6.1 |
| Affected | unstructured 0.24.0 and earlier |
| Fixed | 0.24.1, released 11 July 2026 |
| Repository advisory | 27 July |
| GitHub advisory database | 15 October |
| Reported by | hayato1121 |
The v2 HTML parser builds an "ontology" of elements, each carrying the original tag name, attributes and text. When the library turns those elements back into HTML, the advisory says the function responsible did no escaping at all. The parser stripped literal script tags, which can look like safety, but it kept everything else. The advisory lists four working vectors:
| Vector | Fires on | Why it survived |
|---|---|---|
img with onerror | Opening the page | Element text wasn't escaped |
svg with onload, smuggled through a title attribute | Opening the page | Attribute values weren't escaped, so a quote broke out |
a with a javascript: URL | A click | Link schemes weren't filtered |
onmouseover on a paragraph | Hovering | Source attributes were kept as they were |
The impact described in the advisory is the realistic one. An ingestion pipeline accepts uploads from outside, parses them, and a staff member reviews the result in a web interface. The submitter's markup runs in the reviewer's session, with the reviewer's access to every other record that interface can see.
The fix: escape, drop, allowlist, filter
Pull request 4394 adds a dedicated sanitization module and changes the emitter. Per the 0.24.1 changelog, output is now sanitized in four ways:
- Text and attribute values are HTML-escaped, so a quote can't end an attribute and start a new tag.
- Event-handler attributes are dropped. Nothing starting with
onreaches the output. - Tags and attributes are allowlisted. Tags outside the list become a plain
span. - URL schemes are filtered.
http,https,mailto,teland relative links pass.data:URLs are allowed only for raster images in an image source.
The fix also changed how plain text is derived, so that escaped HTML isn't re-parsed into literal markup, and it updated dozens of expected test outputs across the ingest connectors. That last detail is worth noting if you snapshot parser output in your own tests: expect diffs after upgrading, and read them rather than re-recording blindly.
A model of the bug class
This is not unstructured code. It's a toy partitioner that keeps tags, attributes and text, plus two emitters: one that rebuilds HTML the way the vulnerable path did, and one that applies the same four rules as the fix. The payloads only set a harmless flag:
#!/usr/bin/env python3
"""Rebuilding HTML from a parsed document: the unsafe way and a safer way.
A toy model of the bug class in CVE-2026-104250, not unstructured's code. Payloads
set a harmless flag (window.__xss_test) instead of doing anything.
"""
import html
from html.parser import HTMLParser
DOC = ('<p onmouseover="window.__xss_test=1">Quarterly summary'
'<img src=x onerror="window.__xss_test=2"></p>'
'<p title=\'"><svg onload=window.__xss_test=3>\'>Notes</p>'
'<a href="javascript:window.__xss_test=4">details</a>'
'<a href="https://example.com/report">report</a>')
class Parse(HTMLParser):
"""Collects (tag, attrs, text) the way a document partitioner keeps them."""
def __init__(self):
super().__init__(); self.out = []
def handle_starttag(self, tag, attrs):
self.out.append([tag, dict(attrs), ""])
def handle_data(self, data):
if self.out: self.out[-1][2] += data
SAFE_TAGS = {"p", "a", "img", "span", "table", "tr", "td", "th", "ul", "ol", "li", "h1", "h2", "h3"}
SAFE_ATTRS = {"href", "src", "alt", "title", "colspan", "rowspan"}
SAFE_SCHEMES = ("http://", "https://", "mailto:", "tel:")
def unsafe(tag, attrs, text):
a = " ".join(f'{k}="{v}"' for k, v in attrs.items()) # no escaping, nothing dropped
return f"<{tag} {a}>{text}</{tag}>"
def safer(tag, attrs, text):
tag = tag if tag in SAFE_TAGS else "span" # allowlist tags
kept = []
for k, v in attrs.items():
v = v or ""
if k not in SAFE_ATTRS: # drops on* handlers too
continue
if k in ("href", "src") and ":" in v.split("/")[0] and not v.lower().startswith(SAFE_SCHEMES):
continue # javascript:, data:, vbscript:
kept.append(f'{k}="{html.escape(v, quote=True)}"') # no breaking out with ">
return f"<{tag} {' '.join(kept)}>{html.escape(text)}</{tag}>".replace(" >", ">")
def risky(fragment):
"""Re-parse the output and report anything that would run script in a browser."""
found = []
class Check(HTMLParser):
def handle_starttag(self, tag, attrs):
for k, v in attrs:
if k.startswith("on"): found.append(f"{tag} {k}")
if k in ("href", "src") and (v or "").strip().lower().startswith("javascript:"): found.append(f"{tag} {k}=javascript:")
if tag in ("svg", "script"): found.append(f"<{tag}>")
Check().feed(fragment)
return found
parser = Parse(); parser.feed(DOC)
for name, emit in (("unsafe emitter", unsafe), ("safer emitter", safer)):
out = "".join(emit(*el) for el in parser.out)
hits = risky(out)
print(f"{name}: {len(hits)} script vector(s) in output {hits}")
print(" " + out[:150] + ("..." if len(out) > 150 else ""))
$ python3 emit_lab.py
unsafe emitter: 5 script vector(s) in output ['p onmouseover', 'img onerror', 'svg onload', '<svg>', 'a href=javascript:']
<p onmouseover="window.__xss_test=1">Quarterly summary</p><img src="x" onerror="window.__xss_test=2"></img><p title=""><svg onload=window.__xss_test=3...
safer emitter: 0 script vector(s) in output []
<p>Quarterly summary</p><img src="x"></img><p title=""><svg onload=window.__xss_test=3>">Notes</p><a>details</a><a href="https://example...
The unsafe emitter reproduces all four vector types. The safer one keeps the text and the legitimate link, and turns the attribute breakout into inert, escaped text. The rule generalizes to any parser that round-trips markup: escaping has to happen where the HTML is written, and an allowlist has to decide what is written at all.
Find where document HTML reaches a browser
Upgrading fixes the library. It doesn't tell you where your own application renders parsed documents as raw HTML, which is where the next bug of this kind will land. This script lists unstructured pins older than 0.24.1, code that produces document HTML, and code that renders raw HTML in Streamlit, Gradio, Jinja, React, the DOM or Vue. A file with both is the place to look first:
#!/usr/bin/env python3
"""Find where HTML parsed from documents can reach a browser, and old unstructured pins.
doc_html_sinks.py REPO_DIR
Reports (1) unstructured versions before 0.24.1 in requirements files and lock files,
(2) code that produces document HTML, and (3) code that renders raw HTML. A file with
both (2) and (3) is the place to look first. Standard library only; reads files.
"""
import pathlib, re, sys
SOURCES = re.compile(r"text_as_html|elements_to_html\(|partition_html\(")
SINKS = {
"Streamlit raw HTML": re.compile(r"unsafe_allow_html\s*=\s*True|components\.html\("),
"Gradio HTML": re.compile(r"gr\.HTML\("),
"Jinja |safe or Markup()": re.compile(r"\|\s*safe\b|Markup\("),
"React dangerouslySetInnerHTML": re.compile(r"dangerouslySetInnerHTML"),
"DOM innerHTML": re.compile(r"\.innerHTML\s*="),
"Vue v-html": re.compile(r"v-html="),
}
PIN = re.compile(r'(?im)^\s*unstructured(?:\[[^\]]*\])?\s*==\s*([\d.]+)|^name = "unstructured"\s*\nversion = "([\d.]+)"')
def old(v):
return tuple(int(x) for x in v.split(".")[:3]) < (0, 24, 1)
def main(root):
root = pathlib.Path(root)
for f in sorted(root.rglob("*")):
if not f.is_file() or ".git" in f.parts or "node_modules" in f.parts:
continue
rel = f.relative_to(root)
text = f.read_text(errors="replace") if f.stat().st_size < 2_000_000 else ""
if f.name.startswith("requirements") or f.name in ("uv.lock", "poetry.lock", "pylock.toml"):
for m in PIN.finditer(text):
v = m.group(1) or m.group(2)
print(f"{'UPGRADE' if old(v) else 'ok '} {rel}: unstructured {v}" + (" (fixed in 0.24.1)" if old(v) else ""))
if f.suffix not in (".py", ".js", ".jsx", ".ts", ".tsx", ".vue", ".html", ".j2", ".jinja"):
continue
src = bool(SOURCES.search(text))
sinks = [name for name, rx in SINKS.items() if rx.search(text)]
if src and sinks:
print(f"REVIEW {rel}: document HTML and a raw-HTML sink ({', '.join(sinks)})")
elif sinks:
print(f"note {rel}: raw-HTML sink ({', '.join(sinks)}); check what feeds it")
if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit(__doc__)
main(sys.argv[1])
On a small review app:
$ python3 doc_html_sinks.py fx/rag-review
REVIEW app/review.py: document HTML and a raw-HTML sink (Streamlit raw HTML)
UPGRADE requirements.txt: unstructured 0.23.1 (fixed in 0.24.1)
note web/Preview.tsx: raw-HTML sink (React dangerouslySetInnerHTML); check what feeds it
The review page is the vulnerable pattern: a submitted document is parsed and its HTML rendered with unsafe_allow_html=True. The indexing code only uses plain text, so it's fine. The React component isn't flagged as a definite problem, because the script can't see what feeds it, which is exactly the question to answer by hand.
To see where a parser like this enters your dependency tree, including transitively through an ingestion framework, an SCA inventory view helps. Here's Safeguard's interactive walkthrough of one:
What to do
- Upgrade
unstructuredto 0.24.1 or later, including in container images and hosted ingestion workers. - Find every raw-HTML render of parsed content, with the script above or a search for
text_as_htmlandelements_to_html, and decide whether reviewers need HTML at all. Plain text is often enough. - Sanitize at render time too. Even with the fix, pass document HTML through a maintained sanitizer before display, and serve review pages with a Content Security Policy that blocks inline script.
- Isolate the viewer. Render untrusted documents in a sandboxed iframe on a separate origin, so a missed vector can't reach the reviewer's session.
- Treat ingestion as an attack surface. Our RAG security practices cover the wider set of risks, and our XSS prevention guide covers output encoding in depth.
The fair view
The unstructured maintainers fixed this thoroughly. Rather than patching the four reported strings, they added layered sanitization at the emitter, tests for each vector and a clear changelog entry, and shipped it in 0.24.1. The advisory is precise about which code paths are affected, so teams can check exposure quickly.
The underlying lesson is about where responsibility sits. A parser's job is to preserve content, and HTML output that preserves content faithfully will also preserve attacks. Libraries can and should make their HTML output safe by default, as this one now does. But the application that puts parsed documents in front of a privileged user is the last line, and it should never assume the HTML it was handed is clean.
Check yourself
The parser stripped script tags. Why wasn't that enough?
Script also runs from attributes and URLs. An image's error handler, an SVG load handler, a hover handler and a link to a script URL all execute without any script tag. Stripping one tag type while keeping attributes leaves most of the attack surface in place.
Why does escaping attribute values matter if event handlers are dropped?
Because a quote inside an allowed attribute, such as a title, can end that attribute and open a new tag with its own handler. Escaping turns the quote into an entity, so the value stays a value. The svg vector in the advisory used exactly this breakout.
Your review page shows parsed documents as HTML. You've upgraded. What else should you do?
Ask whether reviewers need HTML at all, sanitize again at render time, serve the page with a Content Security Policy that blocks inline script, and render untrusted documents in a sandboxed iframe on a separate origin. Each layer covers a different way a future parser bug could get through.