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