fix(export_sns): _parse_timeline_xml 兼容 4 种 content 编码 + 老 XML 清洗 (#119)

朋友圈 XML 解析跨版本兼容 + 顺手堵 XXE 绕过.

1. SnsTimeLine.content 跨版本会以 4 种编码出现: bytes (zstd 压缩, magic 28 B5 2F FD) / plain XML / hex / base64. 老逻辑直接 ET.fromstring 喂 raw 入参, 后三种 ParseError → 整条 row 静默丢失, 不报错不警告.

2. 老朋友圈 (2013-2017) XML 还含 ElementTree 拒绝的字符: URL 里的裸 &, 文本字段手打的 < >, 控制字符 (\x00-\x08). 一样导致 ParseError → 静默丢 row.

3. 安全修复: XXE / 长度检查老逻辑跑在 raw 入参, zstd / hex / base64 编码的恶意 DOCTYPE 能绕过. 新逻辑跑在 decoded payload 上, 拦得住.

修复:
- _decode_sns_content_blob: 按 bytes / 已 XML / hex / base64 顺序检测, bytes 带 zstd magic 时先解压
- _sanitize_sns_pseudo_xml: 剥控制字符, CDATA 外裸 & 转义, text-only 节点内部裸 < > 转义
- _parse_timeline_xml: decode → 安全检查 (decoded payload) → sanitize → ET

测试: 17 case (DecodeContentBlobTests 10 + SanitizePseudoXmlTests 4 + SecurityAndLimitsTests 3), 全合成 XML 无 PII.
This commit is contained in:
Belugary
2026-05-23 18:13:27 +08:00
committed by GitHub
parent be361bb6c0
commit 87b0b419d6
2 changed files with 325 additions and 4 deletions

View File

@@ -5,7 +5,10 @@
汇总文件: <output_base_dir>/<display_name>/SNS/timeline.json
时间线: <output_base_dir>/<display_name>/SNS/timeline.html
"""
import base64
import binascii
import bisect
import html
import os
import sys
import json
@@ -17,6 +20,8 @@ from datetime import datetime
from urllib.request import urlopen, Request
from urllib.error import URLError
import zstandard as zstd
if sys.platform == "win32":
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
@@ -29,6 +34,106 @@ from decode_image import aligned_aes_block_size
_SNS_XML_UNSAFE_RE = re.compile(r'<!DOCTYPE|<!ENTITY', re.IGNORECASE)
_SNS_XML_MAX_LEN = 200_000
# SnsTimeLine.content 实际有 4 种编码形态(不同 WeChat 版本/历史时段):
# 1. byteszstd 压缩magic 28 B5 2F FD或裸 UTF-8 bytes
# 2. 已是 plain XML 字符串
# 3. hex 字符串(整段 0-9a-f偶数长度
# 4. base64 字符串A-Za-z0-9+/=
# 直接喂 ET.fromstring 时,后三种以 ParseError 静默返回 None整条 row 丢失。
_SNS_ZSTD_MAGIC = b"\x28\xb5\x2f\xfd"
_SNS_HEX_RE = re.compile(r"^[0-9a-fA-F]+$")
_SNS_BASE64_RE = re.compile(r"^[A-Za-z0-9+/=]+$")
# 2013-2017 老朋友圈 XML 含 ElementTree 无法接受的字符:
# - 裸 &URL 里的 query string应该是 &amp;
# - 文本字段里裸 < >(用户在 contentDesc 等里手打的尖括号)
# - 控制字符(\x00-\x08 等 XML 1.0 禁字符)
# CDATA 块内的 & < > 是合法的,不能动 —— 必须先把 CDATA 圈出来再清洗外面。
_SNS_INVALID_CTRL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
_SNS_CDATA_BLOCK_RE = re.compile(r"<!\[CDATA\[.*?\]\]>", re.DOTALL)
_SNS_BARE_AMP_RE = re.compile(
r"&(?!(amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)"
)
_SNS_TEXT_ONLY_NODES = (
"content", "title", "description", "nickname", "contentDesc",
"appname", "sourceName", "sourcename", "poiName", "displayName",
"feeddesc",
)
_SNS_TEXT_NODE_RE = re.compile(
r"(<(" + "|".join(_SNS_TEXT_ONLY_NODES) + r")\b[^>]*>)(.*?)(</\2>)",
re.DOTALL,
)
def _decode_sns_content_blob(value):
"""把 SnsTimeLine.content 列(任意编码形态)转成 UTF-8 XML 字符串。
bytes 优先尝试 zstd 解压;字符串按 plain XML / hex / base64 顺序检测。
无法识别时返回原值的 string 形态,让上层 ET.fromstring 自然 ParseError。
None / 空值 → 空字符串。
"""
if value is None:
return ""
if isinstance(value, (bytes, bytearray)):
raw = bytes(value)
if raw.startswith(_SNS_ZSTD_MAGIC):
try:
raw = zstd.ZstdDecompressor().decompress(raw)
except Exception:
pass
return html.unescape(raw.decode("utf-8", errors="ignore").strip())
text = str(value).strip()
if not text:
return ""
if text.lstrip().startswith("<"):
return html.unescape(text)
compact = "".join(text.split())
if len(compact) >= 16 and len(compact) % 2 == 0 and _SNS_HEX_RE.match(compact):
try:
return _decode_sns_content_blob(bytes.fromhex(compact))
except ValueError:
pass
if len(compact) >= 24 and len(compact) % 4 == 0 and _SNS_BASE64_RE.match(compact):
try:
return _decode_sns_content_blob(base64.b64decode(compact, validate=True))
except (ValueError, binascii.Error):
pass
return html.unescape(text)
def _sanitize_sns_pseudo_xml(xml_text):
"""修 WeChat 老朋友圈 XML 的非法字符,让 ElementTree 能解析。
CDATA 块内不动;块外把裸 & 转成 &amp;。
text-only 节点content/title/description/...)内部的裸 < > 转义掉。
控制字符直接剥除。
"""
s = _SNS_INVALID_CTRL_RE.sub("", xml_text)
parts = []
last = 0
for m in _SNS_CDATA_BLOCK_RE.finditer(s):
head = s[last:m.start()]
parts.append(_SNS_BARE_AMP_RE.sub("&amp;", head))
parts.append(m.group(0))
last = m.end()
parts.append(_SNS_BARE_AMP_RE.sub("&amp;", s[last:]))
out = "".join(parts)
def _esc(m):
open_tag, _, text, close_tag = (
m.group(1), m.group(2), m.group(3), m.group(4)
)
return (
open_tag
+ text.replace("<", "&lt;").replace(">", "&gt;")
+ close_tag
)
return _SNS_TEXT_NODE_RE.sub(_esc, out)
_cfg = load_config()
DECRYPTED_DIR = _cfg["decrypted_dir"]
SNS_DB_PATH = os.path.join(DECRYPTED_DIR, "sns", "sns.db")
@@ -430,17 +535,24 @@ def _parse_media_list(timeline_obj):
def _parse_timeline_xml(content_xml):
"""解析 SnsTimeLine 的 Content XML返回结构化数据"""
"""解析 SnsTimeLine 的 Content XML返回结构化数据
content_xml 入参可能是 bytes / zstd 字节 / hex 串 / base64 串 / plain XML
先 decode 再 sanitize 老 XML 脏数据(裸 & < > 控制字符),最后才喂 ET。
"""
if not content_xml:
return None
if len(content_xml) > _SNS_XML_MAX_LEN:
decoded = _decode_sns_content_blob(content_xml)
if not decoded:
return None
if _SNS_XML_UNSAFE_RE.search(content_xml):
if len(decoded) > _SNS_XML_MAX_LEN:
return None
if _SNS_XML_UNSAFE_RE.search(decoded):
# XXE 防护: 拒绝 DOCTYPE/ENTITY,避免恶意朋友圈 XML 通过 entity expansion
# 或外部实体引用执行 SSRF/读取本地文件
return None
try:
root = ET.fromstring(content_xml)
root = ET.fromstring(_sanitize_sns_pseudo_xml(decoded))
except ET.ParseError:
return None