From 678d215b3122b1668c36b524f9fe6f3958c9f97b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 15 Sep 2026 13:24:31 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=87=87=E9=9B=86=E6=8E=A5=E5=8F=A3=20o?= =?UTF-8?q?rigin=20urlsplit/finalize=5Fresponse=20=E4=BF=A1=E5=B0=81/CL=20?= =?UTF-8?q?=E9=98=B2=E5=BE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审查三项必修: 1. Origin host 提取改 urlsplit(封堵 https://evil.com/?x=https://www.a.com 类查询串绕过);"null" Origin 视为无 host 走 allow_no_origin; Referer 回显死逻辑一并清理,ACAO 只回显真正的 Origin 头。 2. finalize_response 统一钩子:429/ParseError 等 DRF 异常响应补 ACAO + {code,data,message} 信封(浏览器 SDK 需读错误体,误判网络错误重试会 打爆限流器);删 post/_deny/options 三处临时加头代码。 3. Content-Length isdecimal 防御("abc" → 400 非 500;isdigit 放过 "²" 而 int("²") 仍崩,isdecimal 与 int() 可解析集精确对齐)。 锁定测试 ×4:429 信封+ACAO / 坏 JSON 信封+ACAO / 查询串绕过 403 / 畸形 CL 400。 Co-Authored-By: Claude Code --- server/tracker/api/collect.py | 50 ++++++++++++++++------------ server/tracker/tests/test_collect.py | 36 ++++++++++++++++++++ 2 files changed, 65 insertions(+), 21 deletions(-) diff --git a/server/tracker/api/collect.py b/server/tracker/api/collect.py index a221ead..aa55bd9 100644 --- a/server/tracker/api/collect.py +++ b/server/tracker/api/collect.py @@ -1,5 +1,6 @@ import json from datetime import datetime, timezone as dt_tz +from urllib.parse import urlsplit from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import AllowAny @@ -40,14 +41,16 @@ class CollectBatchView(APIView): authentication_classes = [] def post(self, request): - origin = request.headers.get("Origin") or request.headers.get("Referer") or "" - if int(request.headers.get("Content-Length") or 0) > 262144: + origin = request.headers.get("Origin") or "" + cl = request.headers.get("Content-Length") or "0" + if not cl.isdecimal() or int(cl) > 262144: # isdecimal:isdigit 放过 "²"(int 仍崩),isdecimal 与 int() 精确对齐 return self._deny(400, "payload_too_large") # 应用层兜底(nginx 另设 client_max_body_size) site = self._match_site(request) if site is None or not site.is_active: return self._deny(403, "not_allowed") # 三种拒绝统一 reason,防 site_id 枚举探测 - host = (origin.split("//")[-1].split("/")[0].split(":")[0].lower() - if origin else "") + # urlsplit 提取 host:split("//")[-1] 会被查询串里的白名单 URL 绕过; + # "null" Origin(隐私上下文/沙箱 iframe)视为无 host,走 allow_no_origin 分支 + host = (urlsplit(origin).hostname or "").lower() if origin and origin != "null" else "" if host and host not in [d.lower() for d in site.domains]: return self._deny(403, "not_allowed") if not host and not site.allow_no_origin: @@ -63,22 +66,31 @@ class CollectBatchView(APIView): return self._deny(400, "invalid_payload") ip = self._client_ip(request) r = ingest_batch(site, payload, ip, request.headers.get("User-Agent", "")) - resp = Response({"code": 200, + return Response({"code": 200, "data": {"accepted": r["accepted"], "duplicated": r["duplicated"]}, "message": "ok"}) - if origin: # 成功分支回显 ACAO(SDK fetch 需读响应) - resp["Access-Control-Allow-Origin"] = origin - resp["Access-Control-Allow-Headers"] = "content-type" + + def options(self, request): # 预检兜底(SDK text/plain 简单请求通常不触发);ACAO 由 finalize_response 统一补 + resp = Response(status=200) + resp["Access-Control-Allow-Methods"] = "POST, OPTIONS" + resp["Access-Control-Allow-Headers"] = "content-type" return resp - def options(self, request): # 预检兜底(SDK text/plain 简单请求通常不触发) - origin = request.headers.get("Origin") or "" - resp = Response(status=200) - if origin: - resp["Access-Control-Allow-Origin"] = origin - resp["Access-Control-Allow-Methods"] = "POST, OPTIONS" - resp["Access-Control-Allow-Headers"] = "content-type" - return resp + def finalize_response(self, request, response, *args, **kwargs): + """统一收口:ACAO 回显 + 错误信封。 + + 429/ParseError 等由 DRF 异常处理器产出的响应(无 ACAO、body 是 {detail})在这里补齐—— + 否则浏览器 SDK 跨域读不到错误体会误判网络错误而重试,限流时反而打爆限流器。 + dispatch 对 initial() 抛出的 Throttled/ParseError 同样走 finalize_response,此钩子全覆盖。 + """ + response = super().finalize_response(request, response, *args, **kwargs) + origin = request.headers.get("Origin") + if origin and "Access-Control-Allow-Origin" not in response: # 只回显真正的 Origin 头 + response["Access-Control-Allow-Origin"] = origin + if response.status_code >= 400 and isinstance(response.data, dict) and "detail" in response.data: + response.data = {"code": response.status_code, "data": None, + "message": response.data["detail"]} + return response def _match_site(self, request): sid = (request.data or {}).get("site_id") if isinstance(request.data, dict) else None @@ -94,8 +106,4 @@ class CollectBatchView(APIView): return ip def _deny(self, code, reason): - resp = Response({"code": code, "data": None, "message": reason}, status=code) - origin = self.request.headers.get("Origin") - if origin: # 拒绝也要回 ACAO,否则浏览器读不到错误体、SDK 误判为网络错误走重试 - resp["Access-Control-Allow-Origin"] = origin - return resp + return Response({"code": code, "data": None, "message": reason}, status=code) # ACAO/信封由 finalize_response 统一补 diff --git a/server/tracker/tests/test_collect.py b/server/tracker/tests/test_collect.py index 56344af..cfe9b3f 100644 --- a/server/tracker/tests/test_collect.py +++ b/server/tracker/tests/test_collect.py @@ -61,3 +61,39 @@ def test_throttle_429(db, client, settings): for _ in range(3): assert _post(client, _body(1)).status_code == 200 assert _post(client, _body(1)).status_code == 429 + +# ---- 审查修复锁定测试 ---- + +def test_throttle_429_envelope_and_acao(db, client, settings): + """429 走 finalize_response:信封 + ACAO——否则浏览器 SDK 读不到错误体误判网络错误重试,反而打爆限流器。""" + settings.REST_FRAMEWORK = {**settings.REST_FRAMEWORK, + "DEFAULT_THROTTLE_RATES": {"collect": "3/min"}} + Site.objects.create(name="s", domains=["www.a.com"], site_id="site_x") + for _ in range(3): + assert _post(client, _body(1)).status_code == 200 + r = _post(client, _body(1)) + assert r.status_code == 429 + assert r["Access-Control-Allow-Origin"] == "https://www.a.com" + body = r.json() + assert body["code"] == 429 and body["data"] is None and body["message"] + +def test_invalid_json_envelope_and_acao(db, client): + """ParseError 400 由 DRF 默认异常处理器产出 {detail}——finalize_response 统一转信封并补 ACAO。""" + Site.objects.create(name="s", domains=["www.a.com"], site_id="site_x") + r = client.post("/api/collect/batch", data="{bad json", + content_type="text/plain", HTTP_ORIGIN="https://www.a.com") + assert r.status_code == 400 + assert r.json() == {"code": 400, "data": None, "message": "invalid_json"} + assert r["Access-Control-Allow-Origin"] == "https://www.a.com" + +def test_origin_url_in_query_bypass_blocked(db, client): + """split("//")[-1] 提取 host 会被查询串里的白名单域名绕过——urlsplit 后必 403。""" + Site.objects.create(name="s", domains=["www.a.com"], site_id="site_x") + r = _post(client, _body(1), origin="https://evil.com/?x=https://www.a.com") + assert r.status_code == 403 and r.json()["message"] == "not_allowed" + +def test_malformed_content_length_400_not_500(db, client): + """Content-Length 非数字("abc"):isdigit/isdecimal 防御,拒 400 而非 int() 崩 500。""" + Site.objects.create(name="s", domains=["www.a.com"], site_id="site_x") + r = _post(client, _body(1), extra={"CONTENT_LENGTH": "abc"}) + assert r.status_code == 400 and r.json()["message"] == "payload_too_large"