"""API 权限矩阵探测(开发/审计工具)。 做什么:枚举全部 URL,用「匿名 / 无租户头 / 错误租户 / 有效 JWT」四种身份发 GET, 把状态码汇总成矩阵,标出**预期外开放**的端点(潜在越权)。 判定基线(本项目的既定设计): - 匿名能拿 200 的只应是"公开端点"白名单:ping / auth/token / demo/enter / billing/plans / open/statements / storefront 客户端入口 - 其余端点匿名应为 401/403(受全局 IsAuthenticated 保护) - 带有效 JWT 但**无租户头**时,业务端点应 400(无法识别租户)而非 200 用法: python scripts/audit_api_permissions.py # 用 dev 库 + 自动登录 alice python scripts/audit_api_permissions.py --base http://127.0.0.1:9000 """ from __future__ import annotations import argparse import json import os import re import sys import urllib.error import urllib.request from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev") import django # noqa: E402 django.setup() from django.urls import get_resolver # noqa: E402 # 公开端点前缀(匿名访问 200/4xx 都正常,不算问题) PUBLIC_PREFIXES = ( "api/v1/ping/", "api/v1/auth/token/", "api/v1/demo/enter/", "api/v1/billing/plans/", "api/v1/open/statements/", ) # 需要替换路径参数的占位(探测用 1) PARAM_PATTERNS = [ (re.compile(r"]+>"), "1"), (re.compile(r"]+>"), "00000000-0000-0000-0000-000000000000"), (re.compile(r"<[^>]+:[^>]+>"), "1"), (re.compile(r"\(\?P<[^>]+>\[\^/\.\]\+\)"), "1"), (re.compile(r"\\\.\(\?P\[a-z0-9\]\+\)/\\?\$"), ""), ] def collect_urls() -> list: """枚举所有 API URL(把正则路径参数换成占位值)。""" resolver = get_resolver() raw = set() def walk(patterns, prefix=""): for p in patterns: pat = prefix + str(p.pattern) if hasattr(p, "url_patterns"): walk(p.url_patterns, pat) else: raw.add(pat) walk(resolver.url_patterns) urls = set() for u in raw: if not u.startswith("api/"): continue if " int: """发 GET,返回状态码(0 = 连接/其他异常)。""" req = urllib.request.Request(url, method="GET") if token: req.add_header("Authorization", f"Bearer {token}") if tenant: req.add_header("X-Tenant-Id", tenant) try: with urllib.request.urlopen(req, timeout=10) as resp: return resp.status except urllib.error.HTTPError as e: return e.code except Exception: return 0 def login(base: str, username: str, password: str) -> str: body = json.dumps({"username": username, "password": password}).encode() req = urllib.request.Request(f"{base}/api/v1/auth/token/", data=body, method="POST") req.add_header("Content-Type", "application/json") try: with urllib.request.urlopen(req, timeout=10) as resp: return json.loads(resp.read().decode())["access"] except Exception as exc: print(f"登录失败:{exc}") return "" def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--base", default="http://127.0.0.1:9000") ap.add_argument("--user", default="alice") ap.add_argument("--password", default="alice12345") ap.add_argument("--tenant", default="default") ap.add_argument("--verbose", action="store_true") args = ap.parse_args() base = args.base.rstrip("/") token = login(base, args.user, args.password) if not token: print("无法登录,探测中止") return 2 urls = collect_urls() print(f"探测 {len(urls)} 个端点 · base={base} · 租户={args.tenant}\n") findings = [] rows = [] for path in urls: url = base + path anon = request(url) no_tenant = request(url, token=token) ok = request(url, token=token, tenant=args.tenant) is_public = any(path.startswith("/" + p) for p in PUBLIC_PREFIXES) rows.append((path, anon, no_tenant, ok, is_public)) # 问题 1:非公开端点匿名可访问 if not is_public and anon == 200: findings.append(("匿名可访问", path, f"anon={anon}")) # 问题 2:带 JWT 但无租户头仍返回 200(应为 400 无法识别租户) if not is_public and no_tenant == 200 and ok != 200: findings.append(("缺租户头仍 200", path, f"no_tenant={no_tenant}")) # 问题 3:合法请求反而 500(服务端缺陷) if ok == 500: findings.append(("合法请求 500", path, "with_tenant=500")) # 问题 4:合法请求 0(路由不可达) if ok == 0: findings.append(("端点不可达", path, "network/route error")) print(f"{'端点':<62} {'匿名':>5} {'无租户':>7} {'正常':>5}") print("-" * 84) for path, anon, nt, ok_, is_pub in rows: if args.verbose or anon == 200 or ok_ in (500, 0, 401): mark = " [公开]" if is_pub else "" print(f"{path:<62} {anon:>5} {nt:>7} {ok_:>5}{mark}") print() # 明细:带 JWT 仍被拒的端点(排查鉴权问题) auth_rejected = [(p, a, nt, o) for p, a, nt, o, pub in rows if o in (401, 403) and not pub] if auth_rejected: print("带 JWT 仍被拒的端点:") for p_, a_, nt_, o_ in auth_rejected: print(f" {p_} anon={a_} no_tenant={nt_} with_tenant={o_}") if findings: print(f"发现 {len(findings)} 项需要关注:") for kind, path, detail in findings: print(f" [{kind}] {path} ({detail})") else: print("权限矩阵无明显异常") # 汇总统计 from collections import Counter n_pub = sum(1 for *_, p in rows if p) n_401 = sum(1 for _, a, _, _, p in rows if not p and a == 401) anon_dist = dict(sorted(Counter(a for _, a, _, _, _ in rows).items())) ok_dist = dict(sorted(Counter(o for _, _, _, o, _ in rows).items())) print(f"\n匿名状态码分布:{anon_dist}") print(f"正常请求状态码分布:{ok_dist}") print(f"统计:公开端点 {n_pub} · 匿名被拦 {n_401} · 其他 {len(rows) - n_pub - n_401}") return 1 if findings else 0 if __name__ == "__main__": sys.exit(main())