Files
fangke/server/tracker/services/validation.py
T
浅念 c5f900f3bd fix: 校验守门按字节+递归长度+非dict拒绝
- 体积守门改 UTF-8 字节数(原按字符数,"汉"*1500 约 4.5KB 可绕过 4096 上限)
- 字符串长度检查并入 _walk_props 递归,嵌套字符串同样受 MAX_STR_LEN 约束
  (顺序:体积先行,故同一值既超长又超体积时报 properties_too_large)
- properties 传了但非 dict([]/""/0/None)不再被 or {} 静默放行,缺省键仍放行

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

90 lines
3.9 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 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 表示通过;否则返回拒绝原因码。类型/白名单/体积/深度全在此守门。"""
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", {})
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 字节数守门(汉字多字节,按字符数会被绕过)
if len(json.dumps(props, ensure_ascii=False, default=str).encode("utf-8")) > C.MAX_PROP_JSON_BYTES:
return "properties_too_large"
if str_too_long:
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