Files
fangke/server/tracker/services/validation.py
T
浅念 7b26c7e14a fix: 孤代理三入口堵漏+精确断言+截断清洗换序
- _utf8_ok 助手:event_id/path/title 含孤代理("\ud800")在入口即拒
  (原会流到下游 encode 必 500);props 字节守门 try/except 归入 properties_invalid
- 补零覆盖原因码测试:path=123 → invalid_field_type;utm 三层嵌套 → properties_depth
- 子串断言("sensitive" in r 等)全改精确等值断言,码即 API
- path 改为先 sanitize_url 再截 500:中文查询参数 urlencode 膨胀最多 3 倍,
  先截后洗会破坏出门 ≤MAX_PATH_LEN 不变量(实测汉*200 查询洗后截回 500)
- docstring 写明就地写回契约(Task 6 ingest 依赖)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 12:20:27 +08:00

106 lines
4.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import re
from datetime import datetime
from urllib.parse import parse_qsl, urlencode, urlparse
from django.utils import timezone
from tracker import constants as C
_SENSITIVE = re.compile("|".join(C.SENSITIVE_KEY_PATTERNS), re.I)
_URL_DROP_KEY = re.compile("|".join(C.URL_PARAM_DROP_PATTERNS), re.I)
def _utf8_ok(s: str) -> bool:
"""孤代理(如 "\ud800")在下游 encode/JSON 序列化时必然 500,入口处即拒。"""
try:
s.encode("utf-8")
return True
except UnicodeEncodeError:
return False
def sanitize_url(url: str) -> str:
"""按参数名整键删除(fullmatch 防止误伤 monkey 这类含 key 子串的参数)。"""
p = urlparse(url)
kept = [(k, v) for k, v in parse_qsl(p.query, keep_blank_values=True)
if not _URL_DROP_KEY.fullmatch(k)]
return p._replace(query=urlencode(kept)).geturl()
def _walk_props(v, depth=0):
"""单次递归同时做深度检查、敏感键扫描与字符串超长检查。
dict 所有层级的键都过 _SENSITIVE,所有层级的字符串都受 MAX_STR_LEN 约束。
返回 (depth_ok, sensitive_hit, str_too_long)。深度超限即停止下探,防恶意深嵌套递归爆栈。"""
if depth > C.MAX_PROP_DEPTH:
return False, False, False
if isinstance(v, dict):
depth_ok, sens, too_long = True, False, False
for k, x in v.items():
if _SENSITIVE.search(str(k)):
sens = True
d, s, l = _walk_props(x, depth + 1)
depth_ok = depth_ok and d
sens = sens or s
too_long = too_long or l
return depth_ok, sens, too_long
if isinstance(v, list):
depth_ok, sens, too_long = True, False, False
for x in v:
d, s, l = _walk_props(x, depth + 1)
depth_ok = depth_ok and d
sens = sens or s
too_long = too_long or l
return depth_ok, sens, too_long
if isinstance(v, str) and len(v) > C.MAX_STR_LEN:
return True, False, True
return True, False, False
def validate_event(ev) -> str | None:
"""返回 None 表示通过;否则返回拒绝原因码。类型/白名单/体积/深度全在此守门。
通过时会就地截断/清洗并写回 ev(path 先 sanitize_url 再截 MAX_PATH_LEN,title 截
MAX_TITLE_LEN)——Task 6 的 ingest 依赖此写回契约。"""
if not isinstance(ev, dict):
return "event_invalid_shape"
if ev.get("event_name") not in C.EVENT_NAMES:
return "event_name_not_allowed"
eid = ev.get("event_id")
if not isinstance(eid, str) or not eid or len(eid) > 64 or not _utf8_ok(eid):
return "invalid_event_id"
for k in ("path", "title"):
v = ev.get(k)
if v is not None and (not isinstance(v, str) or not _utf8_ok(v)):
return "invalid_field_type"
occ = ev.get("occurred_at")
if not isinstance(occ, datetime):
return "invalid_occurred_at"
if timezone.is_naive(occ):
return "invalid_occurred_at"
skew = (timezone.now() - occ).total_seconds()
if abs(skew) > C.CLOCK_SKEW_REJECT_SEC:
return "occurred_at_out_of_window"
props = ev.get("properties", {})
if not isinstance(props, dict) or len(props) > C.MAX_PROP_KEYS:
return "properties_invalid" # 传了但非 dict([]/""/0/None)一律拒
# 敏感键扫描(含嵌套层级)先于属性白名单:白名单内无任何键命中敏感模式,
# 若放在白名单之后,sensitive_field 将不可达(见 test_sensitive_property_rejected)。
depth_ok, sensitive_hit, str_too_long = _walk_props(props)
if sensitive_hit:
return "sensitive_field"
if not set(props) <= C.EVENT_PROP_WHITELIST.get(ev["event_name"], set()):
return "property_not_allowed"
if not depth_ok:
return "properties_depth"
# 体积按 UTF-8 字节数守门(汉字多字节,按字符数会被绕过);孤代理 encode 必炸,按 invalid 拒
try:
prop_bytes = len(json.dumps(props, ensure_ascii=False, default=str).encode("utf-8"))
except UnicodeEncodeError:
return "properties_invalid"
if prop_bytes > C.MAX_PROP_JSON_BYTES:
return "properties_too_large"
if str_too_long:
return "properties_too_long"
# 先清洗后截断:urlencode 对中文查询参数最多膨胀 3 倍,先截后洗会破坏"出门 ≤MAX_PATH_LEN"不变量
if ev.get("path"):
ev["path"] = sanitize_url(ev["path"])
if len(ev.get("path") or "") > C.MAX_PATH_LEN:
ev["path"] = ev["path"][:C.MAX_PATH_LEN]
if len(ev.get("title") or "") > C.MAX_TITLE_LEN:
ev["title"] = ev["title"][:C.MAX_TITLE_LEN]
return None