Files
wehub-resource-sync 461bf6fd40
CI / lint (3.11) (push) Blocked by required conditions
CI / lint (3.12) (push) Blocked by required conditions
CI / lint (3.13) (push) Blocked by required conditions
CI / shellcheck (push) Waiting to run
CI / shfmt (push) Waiting to run
CI / setup (3.11) (push) Waiting to run
CI / setup (3.12) (push) Waiting to run
CI / setup (3.13) (push) Waiting to run
CI / check-licenses (3.12) (push) Blocked by required conditions
CI / test_unit (3.11) (push) Blocked by required conditions
CI / test_unit (3.12) (push) Blocked by required conditions
CI / test_unit (3.13) (push) Blocked by required conditions
CI / test_unit_no_extras (3.11) (push) Blocked by required conditions
CI / test_unit_no_extras (3.12) (push) Blocked by required conditions
CI / test_json_to_html (3.12) (push) Blocked by required conditions
CI / test_unit_no_extras (3.13) (push) Blocked by required conditions
CI / test_unit_dependency_extras (csv, 3.11, --extra csv) (push) Blocked by required conditions
CI / test_unit_dependency_extras (csv, 3.12, --extra csv) (push) Blocked by required conditions
CI / test_unit_dependency_extras (csv, 3.13, --extra csv) (push) Blocked by required conditions
CI / test_unit_dependency_extras (docx, 3.11, --extra docx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (docx, 3.12, --extra docx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (docx, 3.13, --extra docx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (markdown, 3.11, --extra md) (push) Blocked by required conditions
CI / test_unit_dependency_extras (markdown, 3.12, --extra md) (push) Blocked by required conditions
CI / test_unit_dependency_extras (markdown, 3.13, --extra md) (push) Blocked by required conditions
CI / test_unit_dependency_extras (odt, 3.11, --extra odt) (push) Blocked by required conditions
CI / test_unit_dependency_extras (odt, 3.12, --extra odt) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pdf-image, 3.12, --extra pdf --extra image --extra paddleocr) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pdf-image, 3.13, --extra pdf --extra image --extra paddleocr) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pptx, 3.13, --extra pptx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (odt, 3.13, --extra odt) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pdf-image, 3.11, --extra pdf --extra image --extra paddleocr) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pptx, 3.11, --extra pptx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pptx, 3.12, --extra pptx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pypandoc, 3.11, --extra epub --extra org --extra rtf --extra rst) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pypandoc, 3.12, --extra epub --extra org --extra rtf --extra rst) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pypandoc, 3.13, --extra epub --extra org --extra rtf --extra rst) (push) Blocked by required conditions
CI / test_unit_dependency_extras (xlsx, 3.11, --extra xlsx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (xlsx, 3.12, --extra xlsx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (xlsx, 3.13, --extra xlsx) (push) Blocked by required conditions
CI / test_ingest_src (3.12) (push) Blocked by required conditions
CI / test_json_to_markdown (3.12) (push) Blocked by required conditions
CI / changelog (push) Waiting to run
CI / test_dockerfile (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Build And Push Docker Image / set-short-sha (push) Waiting to run
Build And Push Docker Image / build-images (linux/amd64, opensource-linux-8core) (push) Blocked by required conditions
Build And Push Docker Image / build-images (linux/arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
Build And Push Docker Image / publish-images (push) Blocked by required conditions
Partition Benchmark / setup (push) Waiting to run
Partition Benchmark / Measure and compare partition() runtime (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:33:56 +08:00

428 lines
15 KiB
Python

"""Central HTML output-sanitization policy for the ontology (v2) HTML path.
`unstructured` renders untrusted document content into HTML in two places:
* ``OntologyElement.to_html`` (``documents/ontology.py``), which fills
``ElementMetadata.text_as_html``. Some callers return this value to clients
verbatim, so it must be safe on its own.
* ``elements_to_html`` (``partition/html/convert.py``), which assembles a full
HTML document from a list of elements.
Both used to interpolate attacker-controlled text, attribute names, attribute
values, and URL schemes with no output encoding, allowing stored XSS
(GHSA-v5mq-3xhg-98m9). This module is the single source of truth for the
sanitization policy shared by both paths:
* an allowlist of HTML tags we ever legitimately emit,
* an allowlist of attribute names (event-handler ``on*`` attributes are never
allowed, killing ``onerror``/``onload``/``onmouseover``),
* a URL-scheme allowlist for URL-bearing attributes (``href``/``src``/...),
which drops ``javascript:`` / ``vbscript:`` and permits ``data:`` only for
raster image MIME types on ``img[src]``.
The emitter (``ontology.py``) uses the lightweight filters here plus
``html.escape`` to make ``text_as_html`` safe on its own; ``elements_to_html``
additionally runs the assembled document through :func:`sanitize_html_fragment`
(``nh3``) as defense-in-depth that also covers attributes it injects itself
(e.g. ``href`` from ``metadata.url``).
"""
from __future__ import annotations
import re
import nh3
# -- Tags the ontology / convert paths legitimately emit. Anything outside this
# -- set (``<script>``, ``<iframe>``, ...) is dropped/neutralized. --
ALLOWED_TAGS: frozenset[str] = frozenset(
{
# layout / structural
"body",
"div",
"section",
"header",
"footer",
"aside",
"nav",
"figure",
"figcaption",
"hr",
"br",
# text
"span",
"p",
"blockquote",
"pre",
"address",
"time",
"mark",
"ins",
"del",
"cite",
"sub",
"sup",
"b",
"i",
"s",
"code",
# headings
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
# lists
"ul",
"ol",
"li",
"dl",
# tables
"table",
"thead",
"tbody",
"tr",
"td",
"th",
# links / media
"a",
"img",
"svg",
"audio",
"video",
# forms
"form",
"input",
"label",
"button",
# misc content
"math",
"meta",
}
)
# -- Attribute names carrying a URL; their values are scheme-filtered. Keep this
# -- list broader than the attributes we currently allow so newly-allowed URL
# -- attributes are scheme-checked by default. --
URL_ATTRIBUTES: frozenset[str] = frozenset(
{
"action",
"cite",
"data-src",
"formaction",
"href",
"poster",
"src",
"srcset",
"xlink:href",
}
)
# -- Data URLs are only needed for embedded image bytes. SVG is intentionally
# -- excluded because SVG documents can carry active content in some render
# -- contexts. --
ALLOWED_DATA_IMAGE_MIME_TYPES: frozenset[str] = frozenset(
{
"image/avif",
"image/bmp",
"image/gif",
"image/jpeg",
"image/jpg",
"image/png",
"image/webp",
"image/x-icon",
}
)
# -- Inline CSS is untrusted too. Keep only inert presentation properties that
# -- preserve existing table/background formatting and reject layout/overlay
# -- controls like position/inset/z-index.
ALLOWED_CSS_PROPERTIES: frozenset[str] = frozenset(
{
"background-color",
"border",
"border-bottom",
"border-collapse",
"border-color",
"border-left",
"border-right",
"border-style",
"border-top",
"border-width",
"color",
"font-style",
"font-weight",
"text-align",
"text-decoration",
"vertical-align",
"white-space",
}
)
# -- Attributes allowed on every tag. --
_GLOBAL_ATTRIBUTES: frozenset[str] = frozenset(
{"class", "id", "style", "title", "dir", "lang", "role", "name", "align"}
)
# -- Per-tag attributes in addition to the global set. --
_TAG_ATTRIBUTES: dict[str, frozenset[str]] = {
"a": frozenset({"href", "target", "rel"}),
"img": frozenset({"src", "alt", "width", "height"}),
"svg": frozenset({"src", "alt", "width", "height", "xlink:href"}),
"audio": frozenset({"src", "controls"}),
"video": frozenset({"src", "controls", "poster", "width", "height"}),
"input": frozenset({"type", "checked", "value", "placeholder"}),
"td": frozenset({"colspan", "rowspan", "headers", "scope"}),
"th": frozenset({"colspan", "rowspan", "headers", "scope"}),
"ol": frozenset({"start", "type"}),
"label": frozenset({"for"}),
"meta": frozenset({"charset", "content"}),
"time": frozenset({"datetime"}),
}
# -- Attribute-name prefixes allowed on any tag (data-page-number, aria-*, ...). --
_GENERIC_ATTRIBUTE_PREFIXES: frozenset[str] = frozenset({"data-", "aria-"})
# -- URL schemes permitted on URL-bearing attributes. ``data`` is permitted here
# -- but further restricted to raster image MIME types on ``img[src]`` by
# -- :func:`is_safe_url` / the nh3 attribute filter; relative URLs (no scheme)
# -- are always allowed. --
ALLOWED_URL_SCHEMES: frozenset[str] = frozenset({"http", "https", "mailto", "tel", "data"})
# -- Valid HTML/XML attribute name (prevents attribute-name breakout on emit). --
_ATTRIBUTE_NAME_RE = re.compile(r"^[a-zA-Z_:][-a-zA-Z0-9_:.]*$")
# -- Matches a leading ``scheme:`` ignoring surrounding whitespace and embedded
# -- control chars that browsers strip (e.g. ``java\tscript:``). --
_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.\-]*):")
_REQUIRED_BLANK_TARGET_REL_VALUES: frozenset[str] = frozenset({"noopener", "noreferrer"})
# -- Match an anchor start tag and detect `target="_blank"` / an existing `rel`
# -- so we can add reverse-tabnabbing protection to new-tab links only. nh3's
# -- output is well-formed (attribute values are escaped, so `>` never appears
# -- inside one), which makes matching whole start tags with a regex safe. --
_ANCHOR_START_TAG_RE = re.compile(r"<a\b[^>]*>", flags=re.IGNORECASE)
_BLANK_TARGET_RE = re.compile(r"""\btarget\s*=\s*["']?_blank\b""", flags=re.IGNORECASE)
_REL_ATTRIBUTE_RE = re.compile(r"""\brel\s*=""", flags=re.IGNORECASE)
_CSS_COMMENT_RE = re.compile(r"/\*.*?\*/", flags=re.DOTALL)
_CSS_UNSAFE_VALUE_RE = re.compile(
r"(@import|expression\s*\(|url\s*\(|javascript:|vbscript:|data:|-moz-binding|behavior\s*:)",
flags=re.IGNORECASE,
)
def _normalize_url(value: str) -> str:
"""Lower-case and strip whitespace/control chars a browser would ignore."""
return re.sub(r"[\x00-\x20]+", "", value).lower()
def _normalized_tag_and_attribute(
tag_name: str | None = None,
attribute_name: str | None = None,
) -> tuple[str | None, str | None]:
tag = tag_name.strip().lower() if tag_name else None
attribute = attribute_name.strip().lower() if attribute_name else None
return tag, attribute
def _is_allowed_data_image_url(
normalized_value: str,
*,
tag_name: str | None = None,
attribute_name: str | None = None,
) -> bool:
tag, attribute = _normalized_tag_and_attribute(tag_name, attribute_name)
if tag != "img" or attribute != "src":
return False
if not normalized_value.startswith("data:") or "," not in normalized_value:
return False
media_type = normalized_value[5:].split(",", 1)[0].split(";", 1)[0]
return media_type in ALLOWED_DATA_IMAGE_MIME_TYPES
def is_safe_url(
value: str,
*,
tag_name: str | None = None,
attribute_name: str | None = None,
) -> bool:
"""True if ``value`` is safe to keep in a URL-bearing attribute.
Relative URLs (no scheme) are allowed. Absolute URLs are allowed only for
:data:`ALLOWED_URL_SCHEMES`, and ``data:`` is further narrowed to raster
image MIME types on ``img[src]`` so embedded base64 images survive while
SVG / HTML / JavaScript data documents are rejected.
"""
normalized = _normalize_url(value)
match = _SCHEME_RE.match(normalized)
if match is None:
# -- no scheme -> relative URL (or a fragment/anchor); safe --
return True
scheme = match.group(1)
if scheme not in ALLOWED_URL_SCHEMES:
return False
if scheme == "data":
return _is_allowed_data_image_url(
normalized,
tag_name=tag_name,
attribute_name=attribute_name,
)
return True
def is_event_handler_attribute(name: str) -> bool:
"""True for ``on*`` event-handler attribute names (onerror, onload, ...)."""
return name.strip().lower().startswith("on")
def _is_allowed_attribute_for_tag(name: str, tag_name: str | None) -> bool:
lowered = name.lower()
if any(lowered.startswith(prefix) for prefix in _GENERIC_ATTRIBUTE_PREFIXES):
return True
allowed_attributes = set(_GLOBAL_ATTRIBUTES)
if tag_name:
allowed_attributes.update(_TAG_ATTRIBUTES.get(tag_name.strip().lower(), frozenset()))
return lowered in allowed_attributes
def _link_rel_with_required_values(value: object | None) -> str:
existing_values = str(value or "").split()
existing_lowered = {rel.lower() for rel in existing_values}
missing_values = [
rel for rel in sorted(_REQUIRED_BLANK_TARGET_REL_VALUES) if rel not in existing_lowered
]
return " ".join([*existing_values, *missing_values]).strip()
def sanitize_style_attribute(value: object) -> str:
"""Return a style attribute containing only safe presentation declarations."""
style = _CSS_COMMENT_RE.sub("", str(value))
safe_declarations: list[str] = []
for declaration in style.split(";"):
if ":" not in declaration:
continue
raw_property, raw_value = declaration.split(":", 1)
property_name = raw_property.strip().lower()
property_value = " ".join(raw_value.strip().split())
if property_name not in ALLOWED_CSS_PROPERTIES:
continue
if not property_value or _CSS_UNSAFE_VALUE_RE.search(property_value):
continue
safe_declarations.append(f"{property_name}: {property_value}")
return "; ".join(safe_declarations)
def sanitize_attributes(
attributes: dict[str, object],
tag_name: str | None = None,
) -> dict[str, object]:
"""Filter an attribute mapping for safe emission (does NOT html-escape).
Drops event-handler (``on*``) attributes, attribute names that aren't valid
HTML attribute names, attributes not allowed for ``tag_name``, and URL-bearing
attributes whose value uses an unsafe scheme. Values are returned unchanged;
the emitter is responsible for ``html.escape``-ing them so escaping happens
exactly once.
"""
safe: dict[str, object] = {}
for key, value in attributes.items():
name = str(key).strip()
lowered = name.lower()
if is_event_handler_attribute(lowered):
continue
if not _ATTRIBUTE_NAME_RE.match(name):
continue
if not _is_allowed_attribute_for_tag(name, tag_name):
continue
if lowered == "style":
sanitized_style = sanitize_style_attribute(value)
if not sanitized_style:
continue
safe[name] = sanitized_style
continue
if lowered in URL_ATTRIBUTES and value is not None:
candidate = value[0] if isinstance(value, list) and value else value
if isinstance(candidate, str) and not is_safe_url(
candidate,
tag_name=tag_name,
attribute_name=lowered,
):
continue
safe[name] = value
if (tag_name or "").strip().lower() == "a" and str(safe.get("target", "")).lower() == "_blank":
safe["rel"] = _link_rel_with_required_values(safe.get("rel"))
return safe
def is_safe_tag(tag_name: str | None) -> bool:
"""True if ``tag_name`` is in the emit allowlist."""
return bool(tag_name) and tag_name.strip().lower() in ALLOWED_TAGS
def _nh3_attribute_filter(tag: str, attribute: str, value: str) -> str | None:
"""nh3 per-attribute hook: drop event handlers and unsafe URL values."""
if is_event_handler_attribute(attribute):
return None
if attribute.lower() == "style":
return sanitize_style_attribute(value) or None
if attribute.lower() in URL_ATTRIBUTES and not is_safe_url(
value,
tag_name=tag,
attribute_name=attribute,
):
return None
return value
def _add_rel_to_blank_target_links(html_fragment: str) -> str:
"""Add reverse-tabnabbing ``rel`` tokens to ``target="_blank"`` anchors only.
nh3's ``link_rel`` would stamp ``rel`` onto *every* link, stripping the
``Referer`` header from ordinary same-tab navigation. Reverse tabnabbing is
only a concern for new-tab links, so we scope the tokens to anchors that
actually open a new browsing context and leave same-tab links untouched.
"""
def add_rel(match: re.Match[str]) -> str:
tag = match.group(0)
if not _BLANK_TARGET_RE.search(tag) or _REL_ATTRIBUTE_RE.search(tag):
return tag
rel = " ".join(sorted(_REQUIRED_BLANK_TARGET_REL_VALUES))
return f'{tag[:-1].rstrip()} rel="{rel}">'
return _ANCHOR_START_TAG_RE.sub(add_rel, html_fragment)
def sanitize_html_fragment(html_fragment: str) -> str:
"""Sanitize an assembled HTML fragment with ``nh3`` (defense-in-depth).
Applies the shared tag/attribute/URL-scheme allowlists. Used on the final
output of ``elements_to_html`` so that attributes injected outside the
ontology emitter (e.g. an ``href`` built from ``metadata.url``) are also
neutralized.
"""
attributes: dict[str, set[str]] = {"*": set(_GLOBAL_ATTRIBUTES)}
for tag, attrs in _TAG_ATTRIBUTES.items():
attributes[tag] = set(attrs)
cleaned = nh3.clean(
html_fragment,
tags=set(ALLOWED_TAGS),
attributes=attributes,
url_schemes=set(ALLOWED_URL_SCHEMES),
generic_attribute_prefixes=set(_GENERIC_ATTRIBUTE_PREFIXES),
filter_style_properties=set(ALLOWED_CSS_PROPERTIES),
attribute_filter=_nh3_attribute_filter,
# -- Scope reverse-tabnabbing `rel` tokens to `_blank` anchors ourselves
# -- rather than letting nh3 add them to every link (which would strip
# -- the Referer header from same-tab navigation). --
link_rel=None,
strip_comments=True,
)
return _add_rel_to_blank_target_links(cleaned)