…) (#4394)
## Summary
The v2 (ontology) HTML path emitted untrusted document markup with **no
output encoding**, so attacker-controlled content in a parsed document
survived into `elements_to_html()` / `metadata.text_as_html` and
executed when the output was viewed in a browser (stored XSS,
[GHSA-v5mq-3xhg-98m9](GHSA-v5mq-3xhg-98m9)).
All four reported vectors — `<img onerror>`, `<svg onload>`
(attribute-value breakout), `<a href="javascript:">`, and `on*` handlers
— are now neutralized.
The fix layers output-encoding at the emitter, filtering at ingest, and
a sanitizer sweep at the assembly boundary.
## Changes
- **New `unstructured/documents/html_sanitization.py`** — single source
of truth for the policy: tag allowlist, attribute allowlist (drops all
`on*` handlers), URL-scheme filter (`is_safe_url`) rejecting
`javascript:`/`vbscript:`/non-image `data:` while preserving
`http`/`https`/`mailto`/`tel`/relative and `data:image/*`, plus an
`nh3`-backed `sanitize_html_fragment`.
- **`ontology.py` (`OntologyElement.to_html`)** — root-cause fix:
HTML-escape element text and attribute values (`quote=True` closes the
attribute-value breakout), drop unsafe attributes, and validate the tag
name against the allowlist (non-allowlisted tags like `<script>` fall
back to inert `<span>`). This makes `text_as_html` safe on its own.
`to_text` now strips markup from the raw text rather than the
newly-escaped HTML, preserving text extraction.
- **`transformations.py`** — attribute handling at ingest now *filters*
(drops `on*`/unsafe schemes) instead of escaping, so escaping happens
exactly once at emit (no double-encoding). Backwards-compatible alias
retained.
- **`convert.py` (`elements_to_html`)** — runs assembled output through
`nh3` as defense-in-depth, covering attributes injected outside the
emitter (e.g. `href` from `metadata.url`). Also fixes a node-skipping
bug when reinserting sanitized content.
- Adds `nh3` dependency; version bump to `0.24.1` with CHANGELOG entry.
## Tests
- New `test_html_sanitization.py` (unit) and `test_xss_sanitization.py`
(end-to-end PoC from the advisory), asserting all four vectors render
inert in both `elements_to_html` output and `text_as_html`, plus
preservation of tables, headings, safe links, and base64 images.
- Updated two existing ontology tests whose expectations encoded the
pre-fix behavior — notably `test_malformed_html`, which previously
asserted a **live `<script>` tag** in the output.
## Acceptance criteria
- [x] PoC renders inertly — none of the four vectors execute
- [x] `on*` attributes stripped/neutralized
- [x] `javascript:`/`data:`/`vbscript:` schemes dropped;
`http`/`https`/`mailto`/relative + `data:image/*` preserved
- [x] Element text and attribute values HTML-escaped
- [x] Attribute-value breakout impossible
- [x] Regression tests for all four vectors + legitimate-formatting
preservation
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated description by cubic. -->
<a
href="proxy.php?url=https%3A%2F%2Fgithub.com%2FUnstructured-IO%2Funstructured%2Fcompare%2F%3Ca+href%3D"https://cubic.dev/pr/Unstructured-IO/unstructured/pull/4394?utm_source=github" rel="nofollow">https://cubic.dev/pr/Unstructured-IO/unstructured/pull/4394?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="proxy.php?url=https%3A%2F%2Fgithub.com%2FUnstructured-IO%2Funstructured%2Fcompare%2F%3Ca+href%3D"https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a" rel="nofollow">https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>