Preformu / utils /html_sanitizer.py
Kevinshh's picture
feat: 意图保真(intent-fidelity) + 描述性梳理技能 + 相容性引擎升级; 修复转置宽表解析/CQA对账/澄清交互/功能切换串显; .gitignore 排除专利与机密Demo数据
0e6887b
Raw
History Blame Contribute Delete
6.93 kB
"""
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)标签,不输出结束标签。
_VOID_TAGS: frozenset[str] = frozenset({"br", "hr", "img", "col"})
#: 内容也需丢弃的危险标签(即便被丢弃,其文本也不得泄入输出)。
_DROP_CONTENT_TAGS: frozenset[str] = frozenset({"script", "style", "template", "noscript"})
#: 各标签允许保留的属性白名单(统一附加 ``class``)。
_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"}),
}
#: 默认仅允许 ``class`` 属性的通用标签。
_DEFAULT_ATTRS: frozenset[str] = frozenset({"class"})
#: ``href`` 允许的协议前缀(小写、去空白后判定)。
_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
# 相对路径 / 锚点 / 安全协议放行;其余(javascript: / data: / vbscript: 等)拒绝。
if v.startswith(_SAFE_URL_SCHEMES):
return True
# 无协议的相对引用(不含冒号 in scheme 位置)视为安全。
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
# 仅允许内联图(报告注入的 base64 图表)与 http(s) 远程图禁用以防外发/追踪。
return v.startswith("data:image/")
class _SanitizingParser(HTMLParser):
"""解析输入 HTML,仅重建白名单内的标签与属性,其余丢弃。"""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self._out: list[str] = []
#: 当前处于"需丢弃内容"标签内的深度计数(如 <script>...</script>)。
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 # 丢弃 script/style 等危险标签的文本内容
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: # noqa: BLE001 - 解析失败一律退回纯文本转义
logger.warning("HTML 净化失败,降级为纯文本转义:%s", exc)
return _html.escape(str(fragment))
__all__ = ["sanitize_html"]