"""
HTML 片段白名单净化器(防 XSS)
===============================
报告正文的 explain 段落来自 LLM 输出,而 LLM 的输入又包含**用户上传文件的内容**,
因此 LLM 段落属于**不可信内容**。若把它当作 HTML 片段原样嵌入报告(尤其是可被浏览器
直接打开的独立 HTML 下载件),攻击者可经"提示词注入"诱导模型输出
``)。
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"]