Skip to content

Commit cf90ddf

Browse files
committed
fix(ops): Atom 解析改用 defusedxml(bandit B314)
上游状态页的 DeepSeek 解析器用 stdlib `ElementTree.fromstring` 读外部 Atom, 对「十亿笑声 / 实体展开」这类 XXE / DoS 载荷不设防,CI security job 的 bandit 报 B314(Medium)。 - 解析入口换成 `defusedxml.ElementTree.fromstring`(bandit 官方推荐的修法, 比逐处 `# nosec` 或全局 skip B314 更稳:保留规则对新增解析点的保护) - Element 类型仍复用 stdlib(defusedxml 只是换了安全解析入口) - 新增用例:XXE 外部实体与十亿笑声载荷都必须抛 ValueError → 由 fetch_source 兜成 unknown 条目,不让外部站点读到本地文件或炸内存 pyproject 新增 defusedxml>=0.7.1(纯 Python,约 25KB)。
1 parent 628cd83 commit cf90ddf

4 files changed

Lines changed: 43 additions & 7 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ readme = "README.md"
66
requires-python = ">=3.12"
77
dependencies = [
88
"cryptography>=44",
9+
"defusedxml>=0.7.1",
910
"fastapi>=0.115",
1011
"httpx[socks]>=0.28",
1112
"itsdangerous>=2.2",

‎src/upstream_status.py‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,21 @@
3232
import html
3333
import re
3434
import time
35-
import xml.etree.ElementTree as ElementTree
3635
from collections.abc import Iterable
3736
from dataclasses import dataclass
3837
from datetime import datetime
3938
from typing import Any, Literal
4039

40+
#: defusedxml 复用 stdlib 的 Element 类型(只是把解析入口换成安全版)。
41+
from xml.etree.ElementTree import Element as XmlElement
42+
4143
import httpx
4244

45+
# Atom 事故史来自外部站点:用 defusedxml 而非 stdlib ElementTree——后者对
46+
# 「十亿笑声 / 实体展开」这类 XXE / DoS 载荷不设防(bandit B314)。
47+
from defusedxml.ElementTree import ParseError as XmlParseError
48+
from defusedxml.ElementTree import fromstring as xml_fromstring
49+
4350
# ------------------------------------------------------------------ 健康档位
4451

4552
LEVEL_OPERATIONAL = "operational"
@@ -508,8 +515,8 @@ def parse_atom(spec: SourceSpec, text: Any, *, now: int) -> SourceStatus:
508515
if not isinstance(text, str):
509516
raise ValueError("atom payload is not text")
510517
try:
511-
root = ElementTree.fromstring(text)
512-
except ElementTree.ParseError as error:
518+
root = xml_fromstring(text)
519+
except XmlParseError as error:
513520
raise ValueError(f"atom payload is not valid XML: {error}") from error
514521

515522
incidents: list[Incident] = []
@@ -581,22 +588,22 @@ def _deepseek_title_level(title: str) -> str:
581588
_ATOM_NS = "http://www.w3.org/2005/Atom"
582589

583590

584-
def _atom_children(root: ElementTree.Element, name: str) -> list[ElementTree.Element]:
591+
def _atom_children(root: XmlElement, name: str) -> list[XmlElement]:
585592
"""按 Atom 命名空间找子元素;无命名空间的裸 `<entry>` 也认(上游换库不崩)。"""
586593
found = root.findall(f"{{{_ATOM_NS}}}{name}")
587594
if found:
588595
return found
589596
return [child for child in root if child.tag.split("}")[-1] == name]
590597

591598

592-
def _atom_text(entry: ElementTree.Element, name: str) -> str | None:
599+
def _atom_text(entry: XmlElement, name: str) -> str | None:
593600
children = _atom_children(entry, name)
594601
if not children:
595602
return None
596603
return _text("".join(children[0].itertext())) or None
597604

598605

599-
def _atom_link(entry: ElementTree.Element) -> str | None:
606+
def _atom_link(entry: XmlElement) -> str | None:
600607
for child in _atom_children(entry, "link"):
601608
href = child.get("href")
602609
if href:

‎tests/test_upstream_status.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,23 @@ def test_parse_atom_rejects_bad_input():
493493
parse_atom(_spec("deepseek"), "<feed><entry>", now=NOW)
494494

495495

496+
def test_parse_atom_rejects_xxe_and_entity_expansion():
497+
"""外部实体 / 十亿笑声:走 defusedxml,绝不能让外部站点读本地文件或炸内存。
498+
499+
stdlib `ElementTree.fromstring` 对这两类载荷不设防(bandit B314),故解析
500+
入口用 defusedxml——命中即抛 `ValueError`,由 `fetch_source` 兜成 unknown。
501+
"""
502+
xxe = ('<?xml version="1.0"?><!DOCTYPE feed [<!ENTITY xxe SYSTEM '
503+
'"file:///etc/passwd">]><feed><entry><title>&xxe;</title></entry></feed>')
504+
with pytest.raises(ValueError):
505+
parse_atom(_spec("deepseek"), xxe, now=NOW)
506+
billion_laughs = ('<?xml version="1.0"?><!DOCTYPE lolz [<!ENTITY lol "lol">'
507+
'<!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;">]>'
508+
'<feed><entry><title>&lol2;</title></entry></feed>')
509+
with pytest.raises(ValueError):
510+
parse_atom(_spec("deepseek"), billion_laughs, now=NOW)
511+
512+
496513
def test_parse_atom_tolerates_unrelated_entries_and_missing_link():
497514
"""无 link 的条目 url 为 None;summary 为空文本时 detail 为 None。"""
498515
feed = ("<feed><entry><title>性能下降</title><summary> </summary></entry>"

‎uv.lock‎

Lines changed: 12 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)