fix: 孤代理三入口堵漏+精确断言+截断清洗换序
- _utf8_ok 助手:event_id/path/title 含孤代理("\ud800")在入口即拒 (原会流到下游 encode 必 500);props 字节守门 try/except 归入 properties_invalid - 补零覆盖原因码测试:path=123 → invalid_field_type;utm 三层嵌套 → properties_depth - 子串断言("sensitive" in r 等)全改精确等值断言,码即 API - path 改为先 sanitize_url 再截 500:中文查询参数 urlencode 膨胀最多 3 倍, 先截后洗会破坏出门 ≤MAX_PATH_LEN 不变量(实测汉*200 查询洗后截回 500) - docstring 写明就地写回契约(Task 6 ingest 依赖) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,14 @@ 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 _utf8_ok(s: str) -> bool:
|
||||
"""孤代理(如 "\ud800")在下游 encode/JSON 序列化时必然 500,入口处即拒。"""
|
||||
try:
|
||||
s.encode("utf-8")
|
||||
return True
|
||||
except UnicodeEncodeError:
|
||||
return False
|
||||
|
||||
def sanitize_url(url: str) -> str:
|
||||
"""按参数名整键删除(fullmatch 防止误伤 monkey 这类含 key 子串的参数)。"""
|
||||
p = urlparse(url)
|
||||
@@ -44,16 +52,19 @@ def _walk_props(v, depth=0):
|
||||
return True, False, False
|
||||
|
||||
def validate_event(ev) -> str | None:
|
||||
"""返回 None 表示通过;否则返回拒绝原因码。类型/白名单/体积/深度全在此守门。"""
|
||||
"""返回 None 表示通过;否则返回拒绝原因码。类型/白名单/体积/深度全在此守门。
|
||||
通过时会就地截断/清洗并写回 ev(path 先 sanitize_url 再截 MAX_PATH_LEN,title 截
|
||||
MAX_TITLE_LEN)——Task 6 的 ingest 依赖此写回契约。"""
|
||||
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:
|
||||
if not isinstance(eid, str) or not eid or len(eid) > 64 or not _utf8_ok(eid):
|
||||
return "invalid_event_id"
|
||||
for k in ("path", "title"):
|
||||
if ev.get(k) is not None and not isinstance(ev[k], str):
|
||||
v = ev.get(k)
|
||||
if v is not None and (not isinstance(v, str) or not _utf8_ok(v)):
|
||||
return "invalid_field_type"
|
||||
occ = ev.get("occurred_at")
|
||||
if not isinstance(occ, datetime):
|
||||
@@ -75,15 +86,20 @@ def validate_event(ev) -> str | None:
|
||||
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:
|
||||
# 体积按 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:
|
||||
return "properties_too_large"
|
||||
if str_too_long:
|
||||
return "properties_too_long"
|
||||
# 先清洗后截断:urlencode 对中文查询参数最多膨胀 3 倍,先截后洗会破坏"出门 ≤MAX_PATH_LEN"不变量
|
||||
if ev.get("path"):
|
||||
ev["path"] = sanitize_url(ev["path"])
|
||||
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
|
||||
|
||||
@@ -17,8 +17,7 @@ def test_unknown_event_name():
|
||||
assert validate_event(_ev(event_name="xxx")) == "event_name_not_allowed"
|
||||
|
||||
def test_sensitive_property_rejected():
|
||||
r = validate_event(_ev(properties={"user_password": "1"}))
|
||||
assert "sensitive" in r
|
||||
assert validate_event(_ev(properties={"user_password": "1"})) == "sensitive_field"
|
||||
|
||||
def test_shape_type_and_whitelist():
|
||||
assert validate_event(["x"]) == "event_invalid_shape"
|
||||
@@ -26,14 +25,11 @@ def test_shape_type_and_whitelist():
|
||||
assert validate_event(_ev(properties={"foo": "bar"})) == "property_not_allowed"
|
||||
|
||||
def test_too_many_props_and_long_string():
|
||||
r = validate_event(_ev(properties={f"k{i}": "v" for i in range(40)}))
|
||||
assert "properties" in r
|
||||
r = validate_event(_ev(properties={"referrer": "x" * 600}))
|
||||
assert "properties" in r
|
||||
assert validate_event(_ev(properties={f"k{i}": "v" for i in range(40)})) == "properties_invalid"
|
||||
assert validate_event(_ev(properties={"referrer": "x" * 600})) == "properties_too_long"
|
||||
|
||||
def test_future_and_stale_time():
|
||||
r = validate_event(_ev(occurred_at=NOW + timedelta(days=2)))
|
||||
assert "occurred_at" in r # 超窗拒绝
|
||||
assert validate_event(_ev(occurred_at=NOW + timedelta(days=2))) == "occurred_at_out_of_window" # 超窗拒绝
|
||||
assert validate_event(_ev(occurred_at=NOW - timedelta(hours=2))) is None # 窗内迟到可收
|
||||
|
||||
def test_sanitize_url_drops_sensitive_params():
|
||||
@@ -42,8 +38,7 @@ def test_sanitize_url_drops_sensitive_params():
|
||||
assert "monkey=1" in u # fullmatch:含 key 子串的正常参数不误伤
|
||||
|
||||
def test_nested_sensitive_key_rejected():
|
||||
r = validate_event(_ev(properties={"utm": {"utm_a": 1, "user_password": "x"}}))
|
||||
assert "sensitive" in r # utm 在 page_view 白名单内,嵌套敏感键仍须命中
|
||||
assert validate_event(_ev(properties={"utm": {"utm_a": 1, "user_password": "x"}})) == "sensitive_field" # utm 在白名单内,嵌套敏感键仍须命中
|
||||
|
||||
def test_naive_datetime_rejected():
|
||||
assert validate_event(_ev(occurred_at=NOW.replace(tzinfo=None))) == "invalid_occurred_at"
|
||||
@@ -57,3 +52,19 @@ def test_nested_string_length_capped():
|
||||
|
||||
def test_falsy_non_dict_properties_rejected():
|
||||
assert validate_event(_ev(properties=[])) == "properties_invalid"
|
||||
|
||||
def test_lone_surrogate_event_id_rejected():
|
||||
assert validate_event(_ev(event_id="\ud800")) == "invalid_event_id"
|
||||
|
||||
def test_lone_surrogate_field_rejected():
|
||||
assert validate_event(_ev(path="/p\ud800")) == "invalid_field_type"
|
||||
assert validate_event(_ev(title="t\ud800")) == "invalid_field_type"
|
||||
|
||||
def test_lone_surrogate_props_rejected():
|
||||
assert validate_event(_ev(properties={"utm": {"a": "\ud800"}})) == "properties_invalid"
|
||||
|
||||
def test_non_str_path_rejected():
|
||||
assert validate_event(_ev(path=123)) == "invalid_field_type"
|
||||
|
||||
def test_prop_depth_exceeded():
|
||||
assert validate_event(_ev(properties={"utm": {"a": {"b": 1}}})) == "properties_depth"
|
||||
|
||||
Reference in New Issue
Block a user