""" 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"") 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"]