Files
fangke/server/tracker/services/validation.py
T
浅念 f2a5f7611a fix: 校验加固(嵌套敏感键/naive时间防御)
- _depth_ok 与敏感键扫描合并为单次递归 _walk_props,dict 所有层级键均过
  _SENSITIVE(此前嵌套键不扫描,utm 白名单内可夹带敏感键)
- occurred_at 为 naive datetime 时返回 invalid_occurred_at(原为 TypeError 500)
- 删除未使用的 timedelta 导入
- 深度超限即停止下探,10 万层嵌套不爆栈

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

85 lines
3.5 KiB
Python

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 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)。
返回 (depth_ok, sensitive_hit)。深度超限即停止下探,防恶意深嵌套递归爆栈。"""
if depth > C.MAX_PROP_DEPTH:
return False, False
if isinstance(v, dict):
depth_ok, sens = True, False
for k, x in v.items():
if _SENSITIVE.search(str(k)):
sens = True
d, s = _walk_props(x, depth + 1)
depth_ok = depth_ok and d
sens = sens or s
return depth_ok, sens
if isinstance(v, list):
depth_ok, sens = True, False
for x in v:
d, s = _walk_props(x, depth + 1)
depth_ok = depth_ok and d
sens = sens or s
return depth_ok, sens
return True, False
def validate_event(ev) -> str | None:
"""返回 None 表示通过;否则返回拒绝原因码。类型/白名单/体积/深度全在此守门。"""
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:
return "invalid_event_id"
for k in ("path", "title"):
if ev.get(k) is not None and not isinstance(ev[k], str):
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") or {}
if not isinstance(props, dict) or len(props) > C.MAX_PROP_KEYS:
return "properties_invalid"
# 敏感键扫描(含嵌套层级)先于属性白名单:白名单内无任何键命中敏感模式,
# 若放在白名单之后,sensitive_field 将不可达(见 test_sensitive_property_rejected)。
depth_ok, sensitive_hit = _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"
if len(json.dumps(props, ensure_ascii=False, default=str)) > C.MAX_PROP_JSON_BYTES:
return "properties_too_large"
for v in props.values():
if isinstance(v, str) and len(v) > C.MAX_STR_LEN:
return "properties_too_long"
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]
if ev.get("path"):
ev["path"] = sanitize_url(ev["path"])
return None