From eb27bc830b6c054fa2da53fb9baa08d1e7a633a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 15 Sep 2026 14:43:26 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20sites=20=E6=A0=87=E9=87=8F=20domains=20?= =?UTF-8?q?=E9=98=B2=E5=BE=A1/=5Fto=5Fbool/403=20=E9=94=81=E5=AE=9A/?= =?UTF-8?q?=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _norm_domains 入口类型守卫:domains 传数字曾 500、字符串曾逐字符 迭代成单字符域名静默落库,统一走 invalid_domains 400 - bool() 强转换 _to_bool:"false" 字符串曾是 True,对 allow_no_origin 这类放宽开关方向不利,改按字面解析(其余未知值仍 False) - 补 member DELETE 不存在 pk → 403(先于 404)锁定测试 - SiteDetailView 注释删去 GET 字样(无 detail GET,前端走列表+Dialog) Co-Authored-By: Claude Code --- server/tracker/api/sites.py | 22 ++++++++++++++----- server/tracker/tests/test_sites.py | 34 ++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/server/tracker/api/sites.py b/server/tracker/api/sites.py index 63334fa..4c8d54c 100644 --- a/server/tracker/api/sites.py +++ b/server/tracker/api/sites.py @@ -8,8 +8,10 @@ from tracker.models import Site def _norm_domains(raw) -> list[str]: """域名归一:去协议/端口/路径、转小写、去空去重。""" + if isinstance(raw, str) or not isinstance(raw, (list, tuple)): + return [] # 标量/字符串(会逐字符迭代成单字符域名)→ 走 invalid_domains 400 out = [] - for d in raw or []: + for d in raw: d = str(d).strip().lower() for pre in ("https://", "http://"): if d.startswith(pre): @@ -20,6 +22,16 @@ def _norm_domains(raw) -> list[str]: return out +def _to_bool(v) -> bool: + """宽松布尔解析:bool 直通,"true"/"false" 字符串按字面(bool("false") 是 True, + 对 allow_no_origin 这类放宽开关方向不利),其余(数字/未知串/None)一律 False。""" + if isinstance(v, bool): + return v + if isinstance(v, str) and v.lower() in ("true", "false"): + return v.lower() == "true" + return False + + def _serialize(site: Site) -> dict: return {"id": site.pk, "name": site.name, "site_id": site.site_id, "domains": site.domains, "allow_no_origin": site.allow_no_origin, @@ -39,13 +51,13 @@ class SiteListView(EnvelopeMixin, APIView): if not domains: return Response({"code": 400, "data": None, "message": "invalid_domains"}, status=400) site = Site.objects.create(name=name.strip()[:100], domains=domains, - allow_no_origin=bool(data.get("allow_no_origin"))) + allow_no_origin=_to_bool(data.get("allow_no_origin"))) return ok(_serialize(site), "created") class SiteDetailView(EnvelopeMixin, APIView): def get_permissions(self): - # DELETE(软删)仅 admin;GET/PATCH 登录成员即可(settings 默认 IsAuthenticated)。 + # DELETE(软删)仅 admin;PATCH 登录成员即可(settings 默认 IsAuthenticated;无 detail GET,前端走列表)。 # 在 initial() 阶段判权限:403 先于 404,且不泄露站点存在性。 if self.request.method == "DELETE": return [IsAdminUser()] @@ -68,9 +80,9 @@ class SiteDetailView(EnvelopeMixin, APIView): return Response({"code": 400, "data": None, "message": "invalid_domains"}, status=400) site.domains = domains if "allow_no_origin" in data: - site.allow_no_origin = bool(data["allow_no_origin"]) + site.allow_no_origin = _to_bool(data["allow_no_origin"]) if "is_active" in data: - site.is_active = bool(data["is_active"]) + site.is_active = _to_bool(data["is_active"]) site.save() return ok(_serialize(site)) diff --git a/server/tracker/tests/test_sites.py b/server/tracker/tests/test_sites.py index eeaf729..f1750aa 100644 --- a/server/tracker/tests/test_sites.py +++ b/server/tracker/tests/test_sites.py @@ -107,3 +107,37 @@ def test_delete_soft_keeps_collect_rejects(client, admin_user): "session_id": "s1", "events": []}), content_type="text/plain", HTTP_ORIGIN="https://a.com") assert r.status_code == 403 and r.json()["message"] == "not_allowed" + + +# ---- 审查修复锁定测试 ---- + +def test_scalar_domains_400_not_500(client, member_user): + """domains 传标量:数字曾直接 500(for d in 123)、字符串曾逐字符迭代成单字符域名静默落库——统一 400。""" + _auth(client, member_user) + assert client.post("/api/admin/sites", + {"name": "x", "domains": "example.com"}, format="json").status_code == 400 + assert client.post("/api/admin/sites", + {"name": "x", "domains": 5}, format="json").status_code == 400 + + +def test_bool_string_parsed_by_literal(client, member_user): + """bool("false") 是 True,对 allow_no_origin 这类放宽开关方向不利——"false" 字符串须按字面落库 False。""" + _auth(client, member_user) + r = client.post("/api/admin/sites", + {"name": "x", "domains": ["a.com"], "allow_no_origin": "false"}, format="json") + assert r.status_code == 200 + site = Site.objects.get(pk=r.json()["data"]["id"]) + assert site.allow_no_origin is False + client.patch(f"/api/admin/sites/{site.pk}", {"is_active": "false"}, format="json") + site.refresh_from_db() + assert site.is_active is False + client.patch(f"/api/admin/sites/{site.pk}", {"allow_no_origin": "true"}, format="json") + site.refresh_from_db() + assert site.allow_no_origin is True + + +def test_delete_member_403_before_404(client, member_user): + """权限先于存在性:member DELETE 不存在的 pk 也是 403——不泄露站点是否存在。""" + _auth(client, member_user) + r = client.delete("/api/admin/sites/99999") + assert r.status_code == 403 and r.json()["code"] == 403