fix(server): v2入口加固——page_id孤代理/NaN区间值拒绝,共享ISO解析

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 11:53:02 +08:00
parent 8459e6fa13
commit 5e024b814b
4 changed files with 73 additions and 33 deletions
+11 -16
View File
@@ -23,7 +23,8 @@ page_id),会话按事件级 session_id 解析(v1 信封不再是唯一依
(v2 附注:stay_ms 重算对"并发同会话双批"同样存在同类窗口——后提交者的 SELECT 可能
看不到前者未提交事件而少算,与 TOCTOU 同级、只影响 stay_ms 单字段。)
"""
from datetime import datetime, timedelta, timezone as dt_tz
from datetime import timedelta
import math
from django.db import transaction, IntegrityError
from django.db.models import F, Q
@@ -32,31 +33,25 @@ from django.utils import timezone
from tracker import constants as C
from tracker.models import SourceClassification, Visitor, VisitSession, Event
from tracker.services.validation import validate_event
from tracker.services.validation import validate_event, parse_iso_dt
from tracker.services.attribution import classify
from tracker.services.geo import resolver as _geo # 模块级单例(勿实例化,11MB/份)
_FIELD_LEN = {"device_type": 16, "os": 64, "browser": 64, "language": 32, "screen": 32}
_CLASS_FIELDS = ("channel", "platform", "variant", "search_term",
"evidence_kind", "resolution", "detail")
def _occ_dt(v):
"""v2 事件 occurred_at 允许 ISO 字符串(SDK 线格式;collect 视图已预转 datetime,
直连调用方可能传原文)。解析失败抛 ValueError/TypeError——调用方放行原值,
由 validate_event 统一拒 invalid_occurred_at。naive 视为 UTC(与 collect._iso 同口径)。"""
if isinstance(v, str):
d = datetime.fromisoformat(v.replace("Z", "+00:00"))
return d if d.tzinfo else d.replace(tzinfo=dt_tz.utc)
return v
_BIGINT_MAX = 2 ** 63 - 1 # BigIntegerField 8 字节量纲上限——超范围值炸列,按 None 降级
def _ms(v):
"""v2 区间字段宽松 int 化:bool/非数值 → None(脏数据防御——recompute 会过滤 None 区间,
该事件不参与并集但不拒批)。"""
"""v2 区间字段宽松 int 化,任何脏值一律 None(recompute 过滤 None 区间,不拒批):
bool/非数值;NaN/Infinity(json.loads 默认收这两个字面量,int() 抛 ValueError/
OverflowError);超 BigIntegerField 量纲的有限值(DB 写入炸列)。"""
if isinstance(v, bool) or not isinstance(v, (int, float)):
return None
return int(v)
if not math.isfinite(v):
return None
return int(v) if -_BIGINT_MAX <= v <= _BIGINT_MAX else None
def _time_quality(now, occ) -> str:
@@ -284,7 +279,7 @@ def ingest_batch(site, payload: dict, ip: str | None = None, ua: str = "") -> di
for i, ev in enumerate(events):
if v2 and isinstance(ev, dict) and isinstance(ev.get("occurred_at"), str):
try: # v2 事件 occurred_at 允许 ISO 字符串(SDK 线格式);解析失败放行原值,
ev["occurred_at"] = _occ_dt(ev["occurred_at"]) # validate_event 统一拒 invalid_occurred_at
ev["occurred_at"] = parse_iso_dt(ev["occurred_at"]) # validate_event 统一拒 invalid_occurred_at
except (ValueError, TypeError):
pass
reason = validate_event(ev, v2=v2, batch_site_id=site.site_id,
+26 -7
View File
@@ -1,6 +1,6 @@
import json
import re
from datetime import datetime
from datetime import datetime, timezone as dt_tz
from urllib.parse import parse_qsl, urlencode, urlparse
from django.utils import timezone
from tracker import constants as C
@@ -16,6 +16,16 @@ def _utf8_ok(s: str) -> bool:
except UnicodeEncodeError:
return False
def parse_iso_dt(v):
"""datetime 原样返回;ISO 字符串解析为 aware datetime("Z"→+00:00,naive 视为 UTC
——Django≥5 已删除 django.utils.timezone.utc)。解析失败抛 ValueError/TypeError,
错误策略由调用方自定:collect 视图整批 400(invalid_occurred_at),ingest v2 逐事件拒。"""
if not isinstance(v, str):
return v
d = datetime.fromisoformat(v.replace("Z", "+00:00"))
return d if d.tzinfo else d.replace(tzinfo=dt_tz.utc)
def sanitize_url(url: str) -> str:
"""三轨制清洗(spec §2.4):黑名单参数名删 + 归因白名单提 + 其余按值判别。
另:userinfo 剥离、fragment 删除。规则版本 SANITIZE_RULE_VERSION。"""
@@ -96,9 +106,15 @@ def validate_event(ev, *, v2=False, batch_site_id="", batch_visitor_id="") -> st
or not isinstance(ev.get("session_id"), str) or not ev["session_id"]):
return "attribution_mismatch"
pid = ev.get("page_id")
if ev["event_name"] != "session_resume" and (
not isinstance(pid, str) or not pid or len(pid) > 64 or not _utf8_ok(pid)):
return "invalid_page_id"
if isinstance(pid, str) and pid:
# 非空 page_id 全事件统一完整校验——session_resume 也不豁免:合规 JSON 可携带
# 孤代理(json.loads 接受 "\ud800"),漏检 _utf8_ok 会在存储时炸整批事务
if len(pid) > 64 or not _utf8_ok(pid):
return "invalid_page_id"
elif pid not in ("", None):
return "invalid_page_id" # 非 str 类型(123/[] 等)
elif ev["event_name"] != "session_resume":
return "invalid_page_id" # 空/缺失:仅 session_resume 豁免(归属会话而非页面)
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"
@@ -126,10 +142,13 @@ def validate_event(ev, *, v2=False, batch_site_id="", batch_visitor_id="") -> st
return "property_not_allowed"
if not depth_ok:
return "properties_depth"
# 体积按 UTF-8 字节数守门(汉字多字节,按字符数会被绕过);孤代理 encode 必炸,按 invalid 拒
# 体积按 UTF-8 字节数守门(汉字多字节,按字符数会被绕过);孤代理 encode 必炸,按 invalid 拒;
# NaN/Infinity 同拒(allow_nan=False)——json.loads 默认收这两个字面量,落库时 SQLite 的
# JSON 有效性校验拒 + bulk_create(ignore_conflicts) 静默丢行,而 accepted 已计数(幽灵事件)
try:
prop_bytes = len(json.dumps(props, ensure_ascii=False, default=str).encode("utf-8"))
except UnicodeEncodeError:
prop_bytes = len(json.dumps(props, ensure_ascii=False, default=str,
allow_nan=False).encode("utf-8"))
except (UnicodeEncodeError, ValueError):
return "properties_invalid"
if prop_bytes > C.MAX_PROP_JSON_BYTES:
return "properties_too_large"