| """ |
| HTML 片段白名单净化器(防 XSS) |
| =============================== |
| |
| 报告正文的 explain 段落来自 LLM 输出,而 LLM 的输入又包含**用户上传文件的内容**, |
| 因此 LLM 段落属于**不可信内容**。若把它当作 HTML 片段原样嵌入报告(尤其是可被浏览器 |
| 直接打开的独立 HTML 下载件),攻击者可经"提示词注入"诱导模型输出 |
| ``<script>`` / ``<img onerror=...>`` / ``<iframe>`` 等,实现存储型 XSS。 |
| |
| 本模块用标准库 :class:`html.parser.HTMLParser` 实现一个**白名单**净化器: |
| |
| - 仅保留排版所需的安全标签(段落 / 强调 / 列表 / 表格 / 标题 / 图片等)。 |
| - 丢弃一切不在白名单内的标签(``script`` / ``style`` / ``iframe`` / ``object`` 等), |
| 且 ``script`` / ``style`` 的**文本内容也一并丢弃**。 |
| - 删除所有事件处理器属性(``on*``)与危险属性。 |
| - ``href`` / ``src`` 仅允许安全协议:``http`` / ``https`` / ``mailto``,``img`` 的 |
| ``src`` 仅允许 ``data:image/`` 内联图(报告注入的 base64 图表),从而阻断 |
| ``javascript:`` 伪协议与远程追踪 / 数据外发像素。 |
| - 所有文本与属性值均做 HTML 转义后再输出。 |
| |
| 仅依赖标准库,不触网、无外部依赖,便于稳定测试。 |
| """ |
|
|
| from __future__ import annotations |
|
|
| import html as _html |
| import logging |
| from html.parser import HTMLParser |
| from typing import Optional |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| _ALLOWED_TAGS: frozenset[str] = frozenset({ |
| "p", "br", "hr", "span", "div", |
| "strong", "b", "em", "i", "u", "s", "small", "sup", "sub", "mark", |
| "ul", "ol", "li", "dl", "dt", "dd", |
| "h1", "h2", "h3", "h4", "h5", "h6", |
| "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col", |
| "figure", "figcaption", "img", "blockquote", "code", "pre", |
| "a", |
| }) |
|
|
| |
| _VOID_TAGS: frozenset[str] = frozenset({"br", "hr", "img", "col"}) |
|
|
| |
| _DROP_CONTENT_TAGS: frozenset[str] = frozenset({"script", "style", "template", "noscript"}) |
|
|
| |
| _ALLOWED_ATTRS: dict[str, frozenset[str]] = { |
| "a": frozenset({"href", "title", "class"}), |
| "img": frozenset({"src", "alt", "title", "width", "height", "class"}), |
| "td": frozenset({"class", "colspan", "rowspan"}), |
| "th": frozenset({"class", "colspan", "rowspan", "scope"}), |
| "col": frozenset({"span", "class"}), |
| "colgroup": frozenset({"span", "class"}), |
| } |
|
|
| |
| _DEFAULT_ATTRS: frozenset[str] = frozenset({"class"}) |
|
|
| |
| _SAFE_URL_SCHEMES: tuple[str, ...] = ("http://", "https://", "mailto:", "#", "/") |
|
|
|
|
| def _is_safe_href(value: str) -> bool: |
| v = (value or "").strip().lower() |
| if not v: |
| return False |
| |
| if v.startswith(_SAFE_URL_SCHEMES): |
| return True |
| |
| if ":" not in v.split("/", 1)[0]: |
| return True |
| return False |
|
|
|
|
| def _is_safe_img_src(value: str) -> bool: |
| v = (value or "").strip().lower() |
| if not v: |
| return False |
| |
| return v.startswith("data:image/") |
|
|
|
|
| class _SanitizingParser(HTMLParser): |
| """解析输入 HTML,仅重建白名单内的标签与属性,其余丢弃。""" |
|
|
| def __init__(self) -> None: |
| super().__init__(convert_charrefs=True) |
| self._out: list[str] = [] |
| |
| self._drop_depth = 0 |
|
|
| |
| def handle_starttag(self, tag: str, attrs) -> None: |
| tag = tag.lower() |
| if tag in _DROP_CONTENT_TAGS: |
| self._drop_depth += 1 |
| return |
| if self._drop_depth: |
| return |
| if tag not in _ALLOWED_TAGS: |
| return |
| self._out.append(self._render_starttag(tag, attrs, self_closing=False)) |
|
|
| def handle_startendtag(self, tag: str, attrs) -> None: |
| tag = tag.lower() |
| if self._drop_depth or tag in _DROP_CONTENT_TAGS: |
| return |
| if tag not in _ALLOWED_TAGS: |
| return |
| self._out.append(self._render_starttag(tag, attrs, self_closing=True)) |
|
|
| def handle_endtag(self, tag: str) -> None: |
| tag = tag.lower() |
| if tag in _DROP_CONTENT_TAGS: |
| if self._drop_depth: |
| self._drop_depth -= 1 |
| return |
| if self._drop_depth: |
| return |
| if tag not in _ALLOWED_TAGS or tag in _VOID_TAGS: |
| return |
| self._out.append(f"</{tag}>") |
|
|
| def handle_data(self, data: str) -> None: |
| if self._drop_depth: |
| return |
| self._out.append(_html.escape(data)) |
|
|
| |
| def _render_starttag(self, tag: str, attrs, *, self_closing: bool) -> str: |
| allowed = _ALLOWED_ATTRS.get(tag, _DEFAULT_ATTRS) |
| parts = [tag] |
| for name, value in attrs: |
| name = (name or "").lower() |
| if name.startswith("on"): |
| continue |
| if name not in allowed: |
| continue |
| value = value or "" |
| if name == "href" and not _is_safe_href(value): |
| continue |
| if name == "src": |
| if tag != "img" or not _is_safe_img_src(value): |
| continue |
| parts.append(f'{name}="{_html.escape(value, quote=True)}"') |
| inner = " ".join(parts) |
| if tag in _VOID_TAGS: |
| return f"<{inner}>" |
| return f"<{inner}>" |
|
|
| def result(self) -> str: |
| return "".join(self._out) |
|
|
|
|
| def sanitize_html(fragment: Optional[str]) -> str: |
| """净化不可信 HTML 片段,返回仅含白名单标签 / 属性的安全 HTML。 |
| |
| 解析异常时**保守降级为纯文本转义**(绝不返回未净化内容)。 |
| """ |
| if not fragment: |
| return "" |
| try: |
| parser = _SanitizingParser() |
| parser.feed(str(fragment)) |
| parser.close() |
| return parser.result() |
| except Exception as exc: |
| logger.warning("HTML 净化失败,降级为纯文本转义:%s", exc) |
| return _html.escape(str(fragment)) |
|
|
|
|
| __all__ = ["sanitize_html"] |
|
|