2026-09-15 12:01:45 +08:00
|
|
|
|
import json
|
|
|
|
|
|
import re
|
2026-09-15 12:04:40 +08:00
|
|
|
|
from datetime import datetime
|
2026-09-15 12:01:45 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-09-15 12:20:27 +08:00
|
|
|
|
def _utf8_ok(s: str) -> bool:
|
|
|
|
|
|
"""孤代理(如 "\ud800")在下游 encode/JSON 序列化时必然 500,入口处即拒。"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
s.encode("utf-8")
|
|
|
|
|
|
return True
|
|
|
|
|
|
except UnicodeEncodeError:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
2026-09-15 12:01:45 +08:00
|
|
|
|
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()
|
|
|
|
|
|
|
2026-09-15 12:04:40 +08:00
|
|
|
|
def _walk_props(v, depth=0):
|
2026-09-15 12:10:56 +08:00
|
|
|
|
"""单次递归同时做深度检查、敏感键扫描与字符串超长检查。
|
|
|
|
|
|
dict 所有层级的键都过 _SENSITIVE,所有层级的字符串都受 MAX_STR_LEN 约束。
|
|
|
|
|
|
返回 (depth_ok, sensitive_hit, str_too_long)。深度超限即停止下探,防恶意深嵌套递归爆栈。"""
|
2026-09-15 12:01:45 +08:00
|
|
|
|
if depth > C.MAX_PROP_DEPTH:
|
2026-09-15 12:10:56 +08:00
|
|
|
|
return False, False, False
|
2026-09-15 12:01:45 +08:00
|
|
|
|
if isinstance(v, dict):
|
2026-09-15 12:10:56 +08:00
|
|
|
|
depth_ok, sens, too_long = True, False, False
|
2026-09-15 12:04:40 +08:00
|
|
|
|
for k, x in v.items():
|
|
|
|
|
|
if _SENSITIVE.search(str(k)):
|
|
|
|
|
|
sens = True
|
2026-09-15 12:10:56 +08:00
|
|
|
|
d, s, l = _walk_props(x, depth + 1)
|
2026-09-15 12:04:40 +08:00
|
|
|
|
depth_ok = depth_ok and d
|
|
|
|
|
|
sens = sens or s
|
2026-09-15 12:10:56 +08:00
|
|
|
|
too_long = too_long or l
|
|
|
|
|
|
return depth_ok, sens, too_long
|
2026-09-15 12:01:45 +08:00
|
|
|
|
if isinstance(v, list):
|
2026-09-15 12:10:56 +08:00
|
|
|
|
depth_ok, sens, too_long = True, False, False
|
2026-09-15 12:04:40 +08:00
|
|
|
|
for x in v:
|
2026-09-15 12:10:56 +08:00
|
|
|
|
d, s, l = _walk_props(x, depth + 1)
|
2026-09-15 12:04:40 +08:00
|
|
|
|
depth_ok = depth_ok and d
|
|
|
|
|
|
sens = sens or s
|
2026-09-15 12:10:56 +08:00
|
|
|
|
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
|
2026-09-15 12:01:45 +08:00
|
|
|
|
|
2026-09-16 15:42:06 +08:00
|
|
|
|
def sanitize_payload(obj):
|
|
|
|
|
|
"""原始批次快照脱敏:递归遍历 dict/list,键名命中 SENSITIVE_KEY_PATTERNS(大小写不敏感)
|
|
|
|
|
|
→ 值替换为字符串 "[FILTERED]"(结构保留,便于排查时仍看清字段形态)。
|
|
|
|
|
|
返回新对象(容器全部重建,叶值原样共享)——调用方在 ingest 之前对 payload 深拷贝后传入,
|
|
|
|
|
|
与 ingest/validate_event 的就地改写互不影响。"""
|
|
|
|
|
|
if isinstance(obj, dict):
|
|
|
|
|
|
return {k: ("[FILTERED]" if _SENSITIVE.search(str(k)) else sanitize_payload(v))
|
|
|
|
|
|
for k, v in obj.items()}
|
|
|
|
|
|
if isinstance(obj, list):
|
|
|
|
|
|
return [sanitize_payload(x) for x in obj]
|
|
|
|
|
|
return obj
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-15 12:01:45 +08:00
|
|
|
|
def validate_event(ev) -> str | None:
|
2026-09-15 12:20:27 +08:00
|
|
|
|
"""返回 None 表示通过;否则返回拒绝原因码。类型/白名单/体积/深度全在此守门。
|
|
|
|
|
|
通过时会就地截断/清洗并写回 ev(path 先 sanitize_url 再截 MAX_PATH_LEN,title 截
|
|
|
|
|
|
MAX_TITLE_LEN)——Task 6 的 ingest 依赖此写回契约。"""
|
2026-09-15 12:01:45 +08:00
|
|
|
|
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")
|
2026-09-15 12:20:27 +08:00
|
|
|
|
if not isinstance(eid, str) or not eid or len(eid) > 64 or not _utf8_ok(eid):
|
2026-09-15 12:01:45 +08:00
|
|
|
|
return "invalid_event_id"
|
|
|
|
|
|
for k in ("path", "title"):
|
2026-09-15 12:20:27 +08:00
|
|
|
|
v = ev.get(k)
|
|
|
|
|
|
if v is not None and (not isinstance(v, str) or not _utf8_ok(v)):
|
2026-09-15 12:01:45 +08:00
|
|
|
|
return "invalid_field_type"
|
|
|
|
|
|
occ = ev.get("occurred_at")
|
|
|
|
|
|
if not isinstance(occ, datetime):
|
|
|
|
|
|
return "invalid_occurred_at"
|
2026-09-15 12:04:40 +08:00
|
|
|
|
if timezone.is_naive(occ):
|
|
|
|
|
|
return "invalid_occurred_at"
|
2026-09-15 12:01:45 +08:00
|
|
|
|
skew = (timezone.now() - occ).total_seconds()
|
|
|
|
|
|
if abs(skew) > C.CLOCK_SKEW_REJECT_SEC:
|
|
|
|
|
|
return "occurred_at_out_of_window"
|
2026-09-15 12:10:56 +08:00
|
|
|
|
props = ev.get("properties", {})
|
2026-09-15 12:01:45 +08:00
|
|
|
|
if not isinstance(props, dict) or len(props) > C.MAX_PROP_KEYS:
|
2026-09-15 12:10:56 +08:00
|
|
|
|
return "properties_invalid" # 传了但非 dict([]/""/0/None)一律拒
|
2026-09-15 12:04:40 +08:00
|
|
|
|
# 敏感键扫描(含嵌套层级)先于属性白名单:白名单内无任何键命中敏感模式,
|
2026-09-15 12:01:45 +08:00
|
|
|
|
# 若放在白名单之后,sensitive_field 将不可达(见 test_sensitive_property_rejected)。
|
2026-09-15 12:10:56 +08:00
|
|
|
|
depth_ok, sensitive_hit, str_too_long = _walk_props(props)
|
2026-09-15 12:04:40 +08:00
|
|
|
|
if sensitive_hit:
|
|
|
|
|
|
return "sensitive_field"
|
2026-09-15 12:01:45 +08:00
|
|
|
|
if not set(props) <= C.EVENT_PROP_WHITELIST.get(ev["event_name"], set()):
|
|
|
|
|
|
return "property_not_allowed"
|
2026-09-15 12:04:40 +08:00
|
|
|
|
if not depth_ok:
|
2026-09-15 12:01:45 +08:00
|
|
|
|
return "properties_depth"
|
2026-09-15 12:20:27 +08:00
|
|
|
|
# 体积按 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:
|
2026-09-15 12:01:45 +08:00
|
|
|
|
return "properties_too_large"
|
2026-09-15 12:10:56 +08:00
|
|
|
|
if str_too_long:
|
|
|
|
|
|
return "properties_too_long"
|
2026-09-15 12:20:27 +08:00
|
|
|
|
# 先清洗后截断:urlencode 对中文查询参数最多膨胀 3 倍,先截后洗会破坏"出门 ≤MAX_PATH_LEN"不变量
|
|
|
|
|
|
if ev.get("path"):
|
|
|
|
|
|
ev["path"] = sanitize_url(ev["path"])
|
2026-09-15 12:01:45 +08:00
|
|
|
|
if len(ev.get("path") or "") > C.MAX_PATH_LEN:
|
|
|
|
|
|
ev["path"] = ev["path"][:C.MAX_PATH_LEN]
|
2026-09-16 17:03:15 +08:00
|
|
|
|
# referrer 是完整 URL:同样过参数清洗(email/token 等整键剔除)再截 MAX_STR_LEN。
|
|
|
|
|
|
# 放在 str_too_long 检查之后——超长 referrer 仍按 properties_too_long 拒,不静默截半。
|
|
|
|
|
|
if ev["event_name"] == "page_view" and isinstance(props.get("referrer"), str) and props["referrer"]:
|
|
|
|
|
|
props["referrer"] = sanitize_url(props["referrer"])[:C.MAX_STR_LEN]
|
2026-09-15 12:01:45 +08:00
|
|
|
|
if len(ev.get("title") or "") > C.MAX_TITLE_LEN:
|
|
|
|
|
|
ev["title"] = ev["title"][:C.MAX_TITLE_LEN]
|
|
|
|
|
|
return None
|