fix: 采集接口 origin urlsplit/finalize_response 信封/CL 防御
审查三项必修: 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 统一补
|
||||
|
||||
Reference in New Issue
Block a user