Files
fangke/server/tracker/api/collect.py
T
浅念 678d215b31 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>
2026-09-15 13:24:31 +08:00

110 lines
5.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
from rest_framework.throttling import AnonRateThrottle
from rest_framework.parsers import BaseParser
from rest_framework.settings import api_settings
from tracker.models import Site
from tracker.services.ingest import ingest_batch
class PlainJSONParser(BaseParser):
media_type = "text/plain"
def parse(self, stream, *args, **kwargs):
try:
return json.loads(stream.read().decode("utf-8") or "{}")
except (ValueError, UnicodeDecodeError):
from rest_framework.exceptions import ParseError
raise ParseError("invalid_json") # DRF 对 parser 异常是 re-raise 不是包装,不自包就是 500
class CollectThrottle(AnonRateThrottle):
scope = "collect"
def get_rate(self):
# SimpleRateThrottle.THROTTLE_RATES 在 import 时固化为类属性,
# override_settings 改 DEFAULT_THROTTLE_RATES 对它不生效——改读实时 api_settings(DRF 收 setting_changed 会 reload)
return api_settings.DEFAULT_THROTTLE_RATES[self.scope]
def _iso(dt):
if isinstance(dt, str):
s = dt.replace("Z", "+00:00")
d = datetime.fromisoformat(s)
return d if d.tzinfo else d.replace(tzinfo=dt_tz.utc) # Django≥5 已删除 django.utils.timezone.utc
return dt
class CollectBatchView(APIView):
permission_classes = [AllowAny]
parser_classes = [PlainJSONParser]
throttle_classes = [CollectThrottle]
authentication_classes = []
def post(self, request):
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 枚举探测
# 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:
return self._deny(403, "not_allowed")
payload = request.data if isinstance(request.data, dict) else {}
for ev in payload.get("events") or []:
if isinstance(ev, dict) and "occurred_at" in ev:
try:
ev["occurred_at"] = _iso(ev["occurred_at"])
except (ValueError, TypeError):
return self._deny(400, "invalid_occurred_at")
if not payload.get("visitor_id") or not payload.get("session_id"):
return self._deny(400, "invalid_payload")
ip = self._client_ip(request)
r = ingest_batch(site, payload, ip, request.headers.get("User-Agent", ""))
return Response({"code": 200,
"data": {"accepted": r["accepted"], "duplicated": r["duplicated"]},
"message": "ok"})
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 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
return Site.objects.filter(site_id=sid).first() if sid else None
def _client_ip(self, request):
"""nginx 前置时取 X-Forwarded-For 最后一段(与 DRF NUM_PROXIES=1 一致;套 CDN 时按层数前移)。"""
xff = request.META.get("HTTP_X_FORWARDED_FOR", "")
ip = xff.split(",")[-1].strip() if xff else request.META.get("REMOTE_ADDR")
# 归一 IPv4 映射地址(::ffff:1.2.3.4 → 1.2.3.4),防地理解析丢失
if ip and ip.lower().startswith("::ffff:"):
ip = ip[7:]
return ip
def _deny(self, code, reason):
return Response({"code": code, "data": None, "message": reason}, status=code) # ACAO/信封由 finalize_response 统一补