fix(security): close unauthenticated CMA user API surface + audit tool (cma-api-auth-surface-audit-20260914)
Root cause: main.py include_router had no global auth dependency; user routers (products/cost/predict/...) relied on assert_entity_scope, which treats a missing session token as the Bot service channel and lets the request through. Result: 46 user endpoints returned data without any token on a public site. Changes: - auth_middleware.py: add require_auth_401 (missing Bearer -> 401, delegates to require_auth otherwise); 401/403 semantics are explicit. - router-level Depends(require_auth_401) on products, cost, predict, notifications, permissions, tenant, alignment; endpoint-level on /auth/roles and /admin/cache/clear. - deps.py: document that get_entity_id/assert_entity_scope are NOT auth. - frontend: add the missing Bearer header to 5 tokenless fetch/axios calls that hit the newly protected endpoints (KPIAlignment x2, RolePermissions x2, Dashboard x1). - tests: new test_api_auth_surface_20260914.py (58 cases: no-token 401, token 200, cross-entity 403, bot channel unchanged); adapt test_predict / test_kpi_forecast to send session tokens like production. - scripts/audit_api_auth_surface.py: reusable FastAPI dependency-graph audit (423 endpoints; A bot / B gap / C public), exits 1 when a B-class gap exists.
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CMA API 鉴权面审计器(cma-api-auth-surface-audit-20260914)
|
||||
|
||||
用途
|
||||
----
|
||||
枚举 FastAPI app 的**全部** APIRoute,逐个判定其鉴权姿态并分类:
|
||||
|
||||
A 类 — 合法 Bot 通道:以 X-BOT-KEY / bridge token 鉴权(verify_bot_key / verify_bridge_token)
|
||||
B 类 — 用户端点缺鉴权(缺口):既无 X-BOT-KEY 也无 require_auth 依赖
|
||||
C 类 — 显式公开:在 PUBLIC_PATHS 白名单内(登录/注册/验证码/官网留资/健康探针等)
|
||||
|
||||
判定依据是**依赖图**(route.dependant 递归),不是源码文本 grep——router 级
|
||||
dependencies=[Depends(require_auth_401)] 与端点级 Depends(require_auth) 都能识别。
|
||||
|
||||
用法(在 backend 目录下)
|
||||
-------------------------
|
||||
./venv/bin/python3 scripts/audit_api_auth_surface.py # 人类可读表格
|
||||
./venv/bin/python3 scripts/audit_api_auth_surface.py --json # 机器可读
|
||||
|
||||
退出码:0 = 无 B 类缺口;1 = 存在 B 类缺口(可直接当 CI 闸门用)。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
|
||||
# 允许直接以脚本方式运行(把 backend 根加入 sys.path)
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_BACKEND = os.path.dirname(_HERE)
|
||||
if _BACKEND not in sys.path:
|
||||
sys.path.insert(0, _BACKEND)
|
||||
|
||||
# 测试/离线环境变量兜底(不连接真实 MySQL:仅枚举路由,不触发 get_db)
|
||||
os.environ.setdefault("CMA_DB_USER", "audit")
|
||||
os.environ.setdefault("CMA_DB_PASS", "audit")
|
||||
os.environ.setdefault("CMA_DB_HOST", "localhost")
|
||||
os.environ.setdefault("CMA_DB_PORT", "3306")
|
||||
os.environ.setdefault("CMA_DB_NAME", "audit")
|
||||
|
||||
AUTH_STRICT = {"require_auth", "require_auth_401", "role_checker"}
|
||||
BOT_AUTH = {"verify_bot_key", "verify_bridge_token"}
|
||||
|
||||
# C 类:显式登记的公开端点((METHOD, path))
|
||||
PUBLIC_PATHS = {
|
||||
("GET", "/health"),
|
||||
("POST", "/api/cma/auth/login"),
|
||||
("POST", "/api/cma/auth/register"),
|
||||
("GET", "/api/cma/auth/login-entities"),
|
||||
("GET", "/api/cma/bot/ping"),
|
||||
("GET", "/api/cma/security/captcha/request"),
|
||||
("GET", "/api/cma/security/captcha/request2"),
|
||||
("POST", "/api/cma/security/captcha/verify"),
|
||||
("POST", "/api/cma/lead"),
|
||||
}
|
||||
|
||||
|
||||
def dep_names(route) -> set:
|
||||
"""递归收集 route 依赖图中所有可调用对象的名字"""
|
||||
acc = set()
|
||||
|
||||
def walk(dep, depth=0):
|
||||
if depth > 12:
|
||||
return
|
||||
fn = getattr(dep.call, "__name__", None)
|
||||
acc.add(fn if fn else dep.call.__class__.__name__)
|
||||
for sub in dep.dependencies:
|
||||
walk(sub, depth + 1)
|
||||
|
||||
for dep in route.dependant.dependencies:
|
||||
walk(dep)
|
||||
return acc
|
||||
|
||||
|
||||
def collect_routes(app) -> list:
|
||||
"""返回全部 APIRoute 的鉴权姿态行"""
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
rows = []
|
||||
for r in app.routes:
|
||||
if not isinstance(r, APIRoute):
|
||||
continue
|
||||
methods = sorted(m for m in r.methods if m not in ("HEAD", "OPTIONS"))
|
||||
deps = dep_names(r)
|
||||
auth = sorted(deps & AUTH_STRICT)
|
||||
bot = sorted(deps & BOT_AUTH)
|
||||
public = bool(methods) and all((m, r.path) in PUBLIC_PATHS for m in methods)
|
||||
if auth:
|
||||
cls = "AUTHED"
|
||||
elif bot:
|
||||
cls = "A" # Bot 通道
|
||||
elif public:
|
||||
cls = "C" # 显式公开
|
||||
else:
|
||||
cls = "B" # 缺口
|
||||
rows.append({
|
||||
"path": r.path,
|
||||
"methods": ",".join(methods),
|
||||
"handler": r.endpoint.__name__,
|
||||
"module": r.endpoint.__module__,
|
||||
"auth": auth,
|
||||
"bot_auth": bot,
|
||||
"class": cls,
|
||||
})
|
||||
rows.sort(key=lambda x: (x["class"], x["path"], x["methods"]))
|
||||
return rows
|
||||
|
||||
|
||||
def build_app():
|
||||
"""构建 app 实例(不连接真实 DB:仅导入模块)"""
|
||||
# 先让 database 模块指向内存 SQLite,避免任何意外真实连接
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from app import database as db_module
|
||||
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool)
|
||||
db_module.engine = engine
|
||||
db_module.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
from app.main import app
|
||||
return app
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--json", action="store_true", help="输出 JSON")
|
||||
args = ap.parse_args()
|
||||
|
||||
rows = collect_routes(build_app())
|
||||
b = [r for r in rows if r["class"] == "B"]
|
||||
a = [r for r in rows if r["class"] == "A"]
|
||||
c = [r for r in rows if r["class"] == "C"]
|
||||
authed = [r for r in rows if r["class"] == "AUTHED"]
|
||||
|
||||
if args.json:
|
||||
print(json.dumps({"total": len(rows), "authed": len(authed), "A": len(a),
|
||||
"B": len(b), "C": len(c), "rows": rows}, ensure_ascii=False, indent=1))
|
||||
else:
|
||||
print(f"CMA API 鉴权面审计 — 端点总数 {len(rows)}")
|
||||
print(f" AUTHED(require_auth/role) = {len(authed)}")
|
||||
print(f" A 类(合法 Bot 通道 X-BOT-KEY) = {len(a)}")
|
||||
print(f" C 类(显式公开) = {len(c)}")
|
||||
print(f" B 类(缺鉴权缺口) = {len(b)}")
|
||||
if b:
|
||||
print("\n=== B 类缺口(需补鉴权)===")
|
||||
for r in b:
|
||||
print(f' {r["methods"]:16s} {r["path"]:52s} {r["module"]}')
|
||||
if c:
|
||||
print("\n=== C 类(显式公开)===")
|
||||
for r in c:
|
||||
print(f' {r["methods"]:16s} {r["path"]:52s} {r["module"]}')
|
||||
return 1 if b else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user