#!/usr/bin/env python3 # -*- coding: utf-8 -*- """鉴权豁免清单**单一真源**(Bug uifdBIMdmixZcmJEdV33O / T4)。 问题 ---- 修复前,「哪些端点免鉴权」这件事在 5 个文件里各存了一份: app/auth_route_gate.py PUBLIC_DSPY_PATHS / PUBLIC_PATH_PREFIXES app/route_guard_middleware.py PUBLIC_DSPY_PATHS / PUBLIC_PATH_PREFIXES app/auth_gate_strict.py PUBLIC_DSPY_PATHS / PUBLIC_PATH_PREFIXES app/entry_guard_r19.py 自己没有清单,委托 auth_gate_strict app/notfound_middleware.py **完全没有清单**(内联兜底闸门任何 .dspy 都 401) 后果(R57b / R59 实测):登录端点 `/rbac/user/up_login.dspy` 在主线闸门上被豁免, 但在「语义组合链装配失败 → 内联兜底闸门」这条路径上**没有任何豁免**,一律返回 `401 {"error": "auth_unavailable"}`;而更早的版本还会把闸门自身异常统一降级成 `auth_unavailable`(R60 已改为 500 auth_gate_internal_error,但豁免缺失仍在)。 于是「登录端点自己 401」被固化,cookie jar 恒空,全部带登录态复测无法执行。 本模块的做法 ------------ * **清单只有这一份**:`AUTH_EXEMPT_DSPY_PATHS`(精确路径,禁通配)+ `AUTH_EXEMPT_PREFIXES`(按**路径段边界**匹配的前缀,禁通配)。 * 所有闸门只调用 `is_exempt_path()`,不再各自维护副本。 * 豁免判定必须是闸门里**最靠前**的判定:在「资源存在性」「会话校验」之前。 这样无论 wwwroot 软链是否挂上、会话组件是否可用、闸门内部是否抛异常, 登录端点都不会被判成 404 / 401(真不存在时由框架自己给 404,语义更准)。 豁免判据(逐条写明,评审可核对) -------------------------------- 1. 路径归一化(去 query/fragment、URL 解码、`\`→`/`、折叠重复斜杠、去尾斜杠、补前导斜杠)后 **整串精确等于** `AUTH_EXEMPT_DSPY_PATHS` 中的某一项 → 豁免。 **不做前缀/通配/子串匹配**:`/rbac/*` 这种写法会连带放行 `/rbac/user/*` 全部端点 (含用户管理写接口),属于越权放行,明令禁止(QC 硬门禁 #6)。 2. 命中 `AUTH_EXEMPT_PREFIXES` 之一,且**按路径段边界**命中(`/static/` 命中 `/static/a`、 `/static` 本身,但**不**命中 `/staticx`)→ 豁免。这些是静态资产与健康探针,无业务数据。 3. 其余一律不豁免(默认拒绝,default-deny)。 零硬编码:本文件不含 host / port / 域名 / 凭据;清单是代码常量,逐条精确。 """ import os import logging log = logging.getLogger("yuanjing.auth_exempt") # --------------------------------------------------------------------------- # # 豁免清单(唯一真源,逐条精确,禁止通配) # --------------------------------------------------------------------------- # #: 免鉴权的动态端点(**精确路径**,小写比较)。 #: 依据:登录/登出/注册/会话自检这几件事发生在「还没有会话」的时刻, #: 若要求会话则构成"先登录才能登录"的自指死循环。 AUTH_EXEMPT_DSPY_PATHS = frozenset(( "/rbac/user/up_login.dspy", # 主登录端点(前端表单 / API 均打这里) "/rbac/user/login.dspy", # 兼容旧前端占位 "/rbac/user/up_logout.dspy", # 登出(销毁会话,本身不需要有效会话) "/rbac/user/logout.dspy", # 兼容别名 "/rbac/user/register.dspy", # 注册 "/rbac/user/check_login.dspy", # 会话自检(返回是否已登录) "/rbac/user/get_login_user.dspy", # 当前登录用户(未登录返回空,不 401) )) #: 免鉴权前缀(静态资产 / 健康探针;按**路径段边界**匹配)。 AUTH_EXEMPT_PREFIXES = ( "/healthz", "/health", "/robots.txt", "/favicon.ico", "/i18n/", "/static/", "/files/", ) #: 【R62 / Bug 匿名读全站 P0】受保护 API 前缀(**显式列举,禁通配**)。 #: 需求口径:修复必须覆盖 /scense、/runtime、/scene、/world、/entity 五个前缀下的 #: 全部 api 端点,而不是只补被测的 3 条。判据见 `is_protected_api_path()`: #: 命中这五个前缀且「路径含 /api/ 段」或「以 .dspy 结尾」→ 必须带可验证会话。 #: 注意:这是**受保护**清单(扩大闸门覆盖面),不是豁免清单;豁免仍然只有上面 #: 那 7 条精确登录类端点 + 7 条静态/健康探针前缀,未因本次修复新增任何免鉴权项。 PROTECTED_API_PREFIXES = ( "/scense", "/runtime", "/scene", "/world", "/entity", ) #: 判定口径标识(写进 /healthz 诊断与自测输出,便于部署态核对生效版本)。 EXEMPT_REVISION = "r62-api-prefix-coverage-v1" # --------------------------------------------------------------------------- # # 路径归一化与匹配 # --------------------------------------------------------------------------- # def normalize_path(path): r"""归一化请求路径,封堵写法绕过。 [fix rvtGi1mlUsTwQrXnybZj6 / R61] 本文档串含反斜杠样例 `\up_login.dspy`, 原为非 raw 三引号 → `\u` 被当作 unicode 转义前缀(`\up` 非法)→ 整个模块 **SyntaxError 无法 import**,进而 `route_guard_middleware` 的 auth_required 层 装载失败、鉴权层整层缺席(实测 SyntaxError: truncated \uXXXX escape, line 90)。 改为 raw docstring 后模块可正常导入;语义不变。 `/rbac/user/up_login.dspy/`、`//rbac/user/up_login.dspy`、 `/rbac/user/%75p_login.dspy`、`/rbac/user\up_login.dspy`、 `/rbac/user/up_login.dspy?x=1` 必须与规范路径得到**同一判定**。 """ p = str(path or "") try: from urllib.parse import unquote p = unquote(p) except Exception: # noqa: BLE001 pass p = p.split("?", 1)[0].split("#", 1)[0] p = p.replace("\\", "/") p = "/" + "/".join([seg for seg in p.split("/") if seg]) if p != "/": p = p.rstrip("/") return p def _prefix_hit(low_path, prefix): """按**路径段边界**判断前缀命中(禁止 `/static` 命中 `/staticx`)。""" pre = str(prefix or "").lower() if not pre: return False if pre.endswith("/"): base = pre.rstrip("/") return low_path == base or low_path.startswith(pre) return low_path == pre or low_path.startswith(pre + "/") def is_exempt_path(path): """该请求路径是否免鉴权(唯一判定入口,所有闸门只调这一个函数)。""" try: p = normalize_path(path) except Exception: # noqa: BLE001 # 归一化都失败 → 不放行(fail-closed),但记日志便于定位。 log.error("[auth_exempt] 路径归一化失败,按不豁免处理: %r", path) return False low = p.lower() if low in AUTH_EXEMPT_DSPY_PATHS: return True for prefix in AUTH_EXEMPT_PREFIXES: if _prefix_hit(low, prefix): return True return False def exempt_reason(path): """返回豁免依据字符串(未豁免返回空串),供自测/诊断输出。""" p = normalize_path(path) low = p.lower() if low in AUTH_EXEMPT_DSPY_PATHS: return "dspy_exact:" + low for prefix in AUTH_EXEMPT_PREFIXES: if _prefix_hit(low, prefix): return "prefix:" + prefix return "" def assert_no_wildcard(paths=None): """硬门禁:豁免清单不得含通配符(`*` / `%`)。命中即抛 ValueError。""" items = AUTH_EXEMPT_DSPY_PATHS if paths is None else paths bad = [p for p in (items or ()) if ("*" in str(p) or "%" in str(p))] if bad: raise ValueError("鉴权豁免清单禁止通配符: %s" % (bad,)) return True def is_protected_api_path(path): """该路径是否属于「必须鉴权的业务 API 覆盖面」(R62 新增判据)。 判据(默认拒绝,与豁免清单解耦): 1. 归一化 + 小写; 2. 必须**按路径段边界**命中 `PROTECTED_API_PREFIXES` 之一(`/scene` 不命中 `/scense`, 也不命中 `/scenex`); 3. 且该路径含 `/api/` 段,或以 `.dspy` 结尾(动态端点)。 命中后再由调用方判断是否豁免(`is_exempt_path()`)——豁免优先级更高, 但豁免清单里没有、也不允许有这五个前缀下的任何业务端点。 """ try: low = normalize_path(path).lower() except Exception: # noqa: BLE001 return True # 判不了 → 按受保护处理 if not any(_prefix_hit(low, "/" + str(pfx).strip("/")) for pfx in PROTECTED_API_PREFIXES): return False return ("/api/" in low or low.endswith(".dspy") or ("/api" in low and low.endswith(".api"))) def manifest(): """豁免清单快照(写进 /healthz 诊断字段与自测输出)。""" return { "revision": EXEMPT_REVISION, "dspy_paths": sorted(AUTH_EXEMPT_DSPY_PATHS), "prefixes": list(AUTH_EXEMPT_PREFIXES), "protected_api_prefixes": list(PROTECTED_API_PREFIXES), "wildcard_free": assert_no_wildcard(), } if __name__ == "__main__": # pragma: no cover import json as _json log.setLevel(logging.INFO) print(_json.dumps(manifest(), ensure_ascii=False, indent=2)) for _p in ("/rbac/user/up_login.dspy", "/rbac/user/up_login.dspy/", "/rbac/user/%75p_login.dspy", "/rbac/user/up_login.dspy?a=1", "/rbac/user/list_users.dspy", "/healthz", "/staticx/a.js"): print("%-42s exempt=%-5s reason=%s" % (_p, is_exempt_path(_p), exempt_reason(_p) or "-"))