197 lines
6.8 KiB
Python
197 lines
6.8 KiB
Python
"""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"<int:[^>]+>"), "1"),
|
|
(re.compile(r"<uuid:[^>]+>"), "00000000-0000-0000-0000-000000000000"),
|
|
(re.compile(r"<[^>]+:[^>]+>"), "1"),
|
|
(re.compile(r"\(\?P<[^>]+>\[\^/\.\]\+\)"), "1"),
|
|
(re.compile(r"\\\.\(\?P<format>\[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 "<drf_format_suffix" in u:
|
|
continue
|
|
path = u
|
|
for rx, repl in PARAM_PATTERNS:
|
|
path = rx.sub(repl, path)
|
|
if "(" in path or "?" in path: # 仍含正则残留,跳过
|
|
continue
|
|
path = path.rstrip("$").rstrip("^")
|
|
if not path.startswith("api/"):
|
|
path = "api/" + path
|
|
urls.add("/" + path.lstrip("/"))
|
|
return sorted(urls)
|
|
|
|
|
|
def request(url: str, *, token: str = "", tenant: str = "") -> 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())
|