根因:元景迭代中为通过 QC 门禁反复叠加了 24 个自定义中间件
(auth_gate_strict / session_gate / login_param_source / route_guard /
entry_semantics / http_semantics / notfound / get_only_405 / ...),
导致标准登录链路被破坏:
- auth_gate_strict 手写 Fernet 解密 + 用 aiohttp_session 的会话创建时间
created 按 1 天判过期 → 所有受保护端点带 cookie 仍 401
- route_guard 的 _has_business_params 用 await request.read() 消费 POST body
→ up_login 收到 params_kw={} → password_encode(None) 崩 500
改动:
- app/scense.py 回归 57 行标准入口:ServerEnv + load_appbase/load_rbac/
load_pybricks + 业务模块 load_*,零自定义中间件
- 24 个自造中间件归档到 app/_archive_disabled_20261028_151505/(可回滚)
- /healthz 改用 ahserver 标准 FunctionProcessor(RegisterFunction 注册 +
conf.website.startswiths 映射),不再用中间件拦截
实测闭环(127.0.0.1:9380 + https://gbak.opencomputing.cn):
healthz 200 | 匿名 401 | admin 登录 200+cookie | 带cookie 200 |
伪造cookie 401 | 注册 200+自动登录 | 新用户登录 200+cookie |
新用户访问 403(已认证无权限,RBAC 正确行为)| 登出 200 |
错误密码 200 业务信封(不崩 500)
register/login/logout/session 全部由 rbac 模块 up_login.dspy /
register.dspy / logout.dspy + ahserver session 中间件提供。
342 lines
14 KiB
Python
342 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""中间件链**签名契约**层(R48 根因修复)。
|
||
|
||
根因(R48 实测 2026-10-26 06:43,evidence/retest-r48-probe-20261026-064308.log)
|
||
----------------------------------------------------------------------------
|
||
部署态全站 500(`500 Internal Server Error / Server got itself in trouble`),
|
||
A/B/C 三族(/healthz 方法语义、匿名读鉴权、缺参写状态行)全部被遮蔽。
|
||
调用链:
|
||
|
||
app/get_only_405.py (healthz 兜底 / 链最外层)
|
||
-> 内层链
|
||
-> pkgs/ahserver/ahserver/real_ip.py:16 request['client_ip'] = request.remote
|
||
-> AttributeError: 'AHApp' object has no attribute 'remote'
|
||
|
||
`request` 位置上出现的是 **AHApp(应用对象)**,不是 `web.Request`。这不是 real_ip
|
||
写错,而是**中间件注册/装配的签名契约不匹配**:
|
||
|
||
aiohttp 的 `Application` 用 `__middleware_version__` 属性区分两种中间件风格——
|
||
|
||
* **new-style**:`@web.middleware` 装饰过的 `async def mw(request, handler)`,
|
||
装饰器写入 `__middleware_version__ = 1`;
|
||
* **legacy-style**:**没有**该标记的可调用对象,被当作「工厂」调用 `mw(app)`。
|
||
|
||
因此把一个**裸** `async def mw(request, handler)`(忘记 `@web.middleware`)塞进
|
||
`app.middlewares` / 构造期 `mws` 列表,aiohttp 会按 legacy 工厂路径用 **app** 去填
|
||
第一个形参 —— 中间件眼里的 `request` 变成 `AHApp`,`request.remote` 立刻
|
||
AttributeError;同时内层链可能返回**未被 await 的 coroutine/function**,产生
|
||
`Expect response, not <class 'function'>`(R21 历史日志原文)。
|
||
|
||
本仓库的暴露点(本轮机械核对,全部是「裸函数进链」):
|
||
|
||
app/get_only_405.py :: method_gate_middleware (无 @web.middleware)
|
||
app/auth_route_gate.py :: auth_route_gate_middleware(无 @web.middleware)
|
||
且 install() 直接 insert 裸函数
|
||
|
||
另有已正确装饰但仍需契约兜底的:`route_guard_middleware.py`、
|
||
`business_status_middleware.py`(它们的注册点分布在多条路径上,任一处漏装饰
|
||
就会复现同类 500)。
|
||
|
||
修复策略(本文件,确定性、可自测)
|
||
----------------------------------
|
||
1. `ensure_middleware(mw)` —— 幂等地把任意中间件规范成 new-style(补
|
||
`__middleware_version__ = 1`),保留 `__name__`;非可调用/签名不符则拒绝。
|
||
2. `assert_middleware_contract(seq)` —— 返回违规清单(供 /healthz 诊断与 selftest)。
|
||
3. `normalize_chain(seq)` —— 就地规范化中间件序列,返回诊断 dict(不静默失败:
|
||
不可修复项写进 `rejected`)。
|
||
4. `safe_bind(mw, handler)` —— 构造 chain 时把 handler 绑到**第二个**形参位
|
||
(`mw(request, handler)`),**禁止** `partial(mw, handler)` 这类把 handler 绑进
|
||
第一个(request)位的写法;并校验返回值必须是 `web.StreamResponse`,否则抛
|
||
`MiddlewareContractError`(不产生 "Expect response, not function" 的隐晦 500)。
|
||
5. `install_chain_contract(app)` —— 构造期/装配后统一规范化 `app._middlewares`
|
||
(或 `app.middlewares`),幂等,永不抛出(返回诊断)。
|
||
6. `selftest()` —— 进程内 aiohttp 应用自测(/healthz GET/HEAD/POST/PUT/DELETE +
|
||
受保护 .dspy 匿名/带 cookie 两口径 + 裸中间件回归),见 `tests/test_middleware_chain_contract.py`。
|
||
|
||
设计约束
|
||
--------
|
||
* 零应用内依赖(可被 scense.py / yuanjing.py 以 `import middleware_chain_contract`
|
||
或 `from app import ...` 两种路径导入,不触发包内其他模块的副作用)。
|
||
* 一切「不可修复」都进诊断,不 `except: pass`。
|
||
"""
|
||
|
||
import inspect
|
||
import logging
|
||
|
||
log = logging.getLogger("yuanjing.middleware_chain_contract")
|
||
|
||
#: 契约修订号(/healthz 与 selftest 断言用;改动契约语义必须递增)
|
||
CONTRACT_REVISION = "r52-chain-contract-1"
|
||
|
||
#: aiohttp new-style 中间件标记属性
|
||
MARKER = "__middleware_version__"
|
||
|
||
#: aiohttp 要求中间件的形参个数(request, handler)
|
||
ARITY = 2
|
||
|
||
|
||
class MiddlewareContractError(RuntimeError):
|
||
"""中间件不符合 `(request, handler)` 签名契约,或返回了非响应对象。"""
|
||
|
||
|
||
# ---------------------------------------------------------------- 判定
|
||
def _aiohttp_web():
|
||
"""取 aiohttp.web(取不到返回 None,调用方必须降级而不能静默成功)。"""
|
||
try:
|
||
from aiohttp import web as _w # noqa: PLC0415
|
||
return _w
|
||
except Exception: # noqa: BLE001
|
||
return None
|
||
|
||
|
||
def has_marker(mw):
|
||
"""该中间件是否已带 new-style 标记(aiohttp 才不会再按 legacy 工厂调用)。"""
|
||
return getattr(mw, MARKER, None) == 1
|
||
|
||
|
||
def positional_arity(fn):
|
||
"""可调用对象接受的位置参数个数;无法判定时返回 -1。"""
|
||
try:
|
||
sig = inspect.signature(fn)
|
||
except (TypeError, ValueError):
|
||
return -1
|
||
return len([
|
||
p for p in sig.parameters.values()
|
||
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
|
||
])
|
||
|
||
|
||
def is_middleware_shaped(mw):
|
||
"""形如 `(request, handler)` 的协程函数(或带 1 个位置的 legacy 工厂)。"""
|
||
if not callable(mw):
|
||
return False
|
||
n = positional_arity(mw)
|
||
if n == ARITY:
|
||
return True
|
||
# legacy 工厂:def factory(app) -> middleware;本轮只告警,不当作本应用风格
|
||
return False
|
||
|
||
|
||
def contract_violations(mw):
|
||
"""返回单项违规描述列表(空列表 = 合规)。"""
|
||
out = []
|
||
if not callable(mw):
|
||
out.append("not callable: %r" % (mw,))
|
||
return out
|
||
if not is_middleware_shaped(mw):
|
||
out.append("signature != (request, handler): %s%s"
|
||
% (getattr(mw, "__name__", repr(mw)), kind_suffix(mw)))
|
||
if not has_marker(mw):
|
||
out.append("missing %s (aiohttp 会按 legacy 工厂 mw(app) 调用 → "
|
||
"'AHApp' object has no attribute 'remote'): %s"
|
||
% (MARKER, getattr(mw, "__name__", repr(mw))))
|
||
return out
|
||
|
||
|
||
def kind_suffix(mw):
|
||
try:
|
||
return " (arity=%d)" % positional_arity(mw)
|
||
except Exception: # noqa: BLE001
|
||
return ""
|
||
|
||
|
||
def assert_middleware_contract(seq):
|
||
"""返回 `[{index, name, violations}]`(空列表 = 全部合规)。"""
|
||
out = []
|
||
for i, mw in enumerate(seq or ()):
|
||
v = contract_violations(mw)
|
||
if v:
|
||
out.append({"index": i, "name": getattr(mw, "__name__", repr(mw)),
|
||
"violations": v})
|
||
return out
|
||
|
||
|
||
def describe(seq):
|
||
"""中间件名列表(诊断用)。"""
|
||
return [getattr(m, "__name__", repr(m)) for m in (seq or ())]
|
||
|
||
|
||
# ---------------------------------------------------------------- 规范化
|
||
def ensure_middleware(mw, name=None):
|
||
"""幂等规范化:返回带 new-style 标记的中间件;不合规抛 MiddlewareContractError。"""
|
||
if not callable(mw):
|
||
raise MiddlewareContractError("middleware not callable: %r" % (mw,))
|
||
if not is_middleware_shaped(mw):
|
||
raise MiddlewareContractError(
|
||
"middleware signature must be (request, handler): %s%s"
|
||
% (getattr(mw, "__name__", repr(mw)), kind_suffix(mw)))
|
||
if has_marker(mw):
|
||
if name:
|
||
try_set_name(mw, name)
|
||
return mw
|
||
w = _aiohttp_web()
|
||
fixed = mw
|
||
if w is not None:
|
||
try:
|
||
fixed = w.middleware(mw) # aiohttp 官方装饰器,写入标记
|
||
except Exception: # noqa: BLE001
|
||
fixed = mw
|
||
if not has_marker(fixed):
|
||
try:
|
||
setattr(fixed, MARKER, 1) # 兜底:手工补标记
|
||
except Exception as exc: # noqa: BLE001
|
||
raise MiddlewareContractError(
|
||
"cannot mark middleware %s: %s"
|
||
% (getattr(mw, "__name__", repr(mw)), exc))
|
||
try_set_name(fixed, name or getattr(mw, "__name__", None))
|
||
return fixed
|
||
|
||
|
||
def try_set_name(mw, name):
|
||
if not name:
|
||
return mw
|
||
try:
|
||
mw.__name__ = name
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
return mw
|
||
|
||
|
||
def normalize_chain(seq, only_missing_marker=True):
|
||
"""就地规范化中间件序列,返回诊断 dict(幂等)。
|
||
|
||
:param seq: 可变序列(list)。`app._middlewares` / 构造期 mws 列表。
|
||
:param only_missing_marker: True 时只补标记,不改变顺序、不删项。
|
||
:return: {revision, total, normalized, rejected, names, ok}
|
||
"""
|
||
diag = {"revision": CONTRACT_REVISION, "total": 0, "normalized": [],
|
||
"rejected": [], "names": [], "ok": True}
|
||
if seq is None:
|
||
diag["ok"] = False
|
||
diag["rejected"].append("chain is None")
|
||
return diag
|
||
try:
|
||
total = len(seq)
|
||
except Exception: # noqa: BLE001
|
||
diag["ok"] = False
|
||
diag["rejected"].append("chain not a sized sequence")
|
||
return diag
|
||
diag["total"] = total
|
||
for i in range(total):
|
||
mw = seq[i]
|
||
name = getattr(mw, "__name__", repr(mw))
|
||
if has_marker(mw) and is_middleware_shaped(mw):
|
||
continue
|
||
try:
|
||
seq[i] = ensure_middleware(mw)
|
||
diag["normalized"].append({"index": i, "name": name})
|
||
log.warning("[chain-contract] 第 %d 层中间件 %s 未带 %s,已规范化为 "
|
||
"new-style(原实现会被 aiohttp 按 legacy 工厂 mw(app) 调用)",
|
||
i, name, MARKER)
|
||
except MiddlewareContractError as exc:
|
||
diag["rejected"].append({"index": i, "name": name, "error": str(exc)})
|
||
log.error("[chain-contract] 第 %d 层中间件 %s 不可修复:%s", i, name, exc)
|
||
diag["names"] = describe(seq)
|
||
diag["violations"] = assert_middleware_contract(seq)
|
||
diag["ok"] = not diag["rejected"] and not diag["violations"]
|
||
return diag
|
||
|
||
|
||
# ---------------------------------------------------------------- 绑定与执行
|
||
def safe_bind(mw, handler):
|
||
"""把 `handler` 绑到中间件的**第二个**形参位,返回 `chain(request)`。
|
||
|
||
**禁止** `partial(mw, handler)`:partial 会把 handler 填到第一个形参
|
||
(request)位,请求对象反而落到 handler 位 —— 这正是
|
||
「`Expect response, not <class 'function'>`」与
|
||
「`'AHApp' object has no attribute 'remote'`」两类 500 的同源错误绑定。
|
||
"""
|
||
if not callable(mw):
|
||
raise MiddlewareContractError("middleware not callable: %r" % (mw,))
|
||
|
||
async def _chain(request):
|
||
resp = await mw(request, handler)
|
||
return assert_response(resp, mw, request)
|
||
|
||
_chain.__name__ = "chain(%s)" % getattr(mw, "__name__", "mw")
|
||
return _chain
|
||
|
||
|
||
def assert_response(resp, mw=None, request=None):
|
||
"""中间件必须返回 `web.StreamResponse`;否则抛清晰契约错误。"""
|
||
w = _aiohttp_web()
|
||
name = getattr(mw, "__name__", "mw") if mw is not None else "mw"
|
||
if w is None:
|
||
return resp
|
||
if isinstance(resp, w.StreamResponse):
|
||
return resp
|
||
if inspect.isawaitable(resp):
|
||
raise MiddlewareContractError(
|
||
"%s returned an awaitable (%r) —— 中间件漏 await"
|
||
% (name, type(resp).__name__))
|
||
raise MiddlewareContractError(
|
||
"Expect response, not %r from %s (path=%s)"
|
||
% (type(resp).__name__, name, getattr(request, "path", "") or "?"))
|
||
|
||
|
||
def build_chain(middlewares, handler):
|
||
"""按 `[最外层 ... 最内层]` 顺序组链,返回 `chain(request)`;全程契约校验。"""
|
||
chain = handler
|
||
for mw in reversed(list(middlewares or ())):
|
||
chain = safe_bind(ensure_middleware(mw), chain)
|
||
return chain
|
||
|
||
|
||
# ---------------------------------------------------------------- 装配钩子
|
||
def _mw_list(app):
|
||
"""取 app 的中间件列表(aiohttp 版本差异:`middlewares` / `_middlewares`)。"""
|
||
mws = getattr(app, "_middlewares", None)
|
||
if mws is not None:
|
||
return mws, "_middlewares"
|
||
mws = getattr(app, "middlewares", None)
|
||
if mws is not None:
|
||
return mws, "middlewares"
|
||
return None, None
|
||
|
||
|
||
def install_chain_contract(app, middlewares=None):
|
||
"""构造期/装配后统一规范化中间件链签名契约(幂等,永不抛出)。
|
||
|
||
:param app: AHApp / aiohttp Application 实例(可为 None)
|
||
:param middlewares: 构造期 mws 列表(在 `make_app()` 之前先规范化)
|
||
:return: 诊断 dict(含 revision / normalized / rejected / names / ok)
|
||
"""
|
||
diag = {"revision": CONTRACT_REVISION, "ok": True, "sources": [],
|
||
"normalized": [], "rejected": [], "names": []}
|
||
try:
|
||
if middlewares is not None:
|
||
d = normalize_chain(middlewares)
|
||
diag["sources"].append("ctor-arg")
|
||
diag["normalized"] += d.get("normalized", [])
|
||
diag["rejected"] += d.get("rejected", [])
|
||
diag["ok"] = diag["ok"] and d.get("ok", True)
|
||
diag["names"] = d.get("names", [])
|
||
except Exception as exc: # noqa: BLE001
|
||
diag["ok"] = False
|
||
diag["rejected"].append({"source": "ctor-arg",
|
||
"error": "%s: %s" % (type(exc).__name__, exc)})
|
||
try:
|
||
if app is not None:
|
||
mws, src = _mw_list(app)
|
||
if mws is None:
|
||
diag["ok"] = False
|
||
diag["rejected"].append({"source": "app", "error": "no middleware list"})
|
||
else:
|
||
d = normalize_chain(mws)
|
||
diag["sources"].append(src)
|
||
diag["normalized"] += d.get("normalized", [])
|
||
diag["rejected"] += d.get("rejected", [])
|
||
diag["ok"] = diag["ok"] and d.get("ok", True)
|
||
diag["names"] = d.get("names", [])
|
||
except Exception as exc: # noqa: BLE001
|
||
diag["ok"] = False
|
||
diag["rejected"].append({"source": "app",
|
||
"error": "%s: %s" % (type(exc).__name__, exc)})
|
||
if not diag["ok"]:
|
||
log.error("[chain-contract] 装配核验未通过 rejected=%s", diag["rejected"])
|
||
else:
|
||
log.info("[chain-contract] 装配核验通过 rev=%s sources=%s names=%s",
|
||
CONTRACT_REVISION, diag["sources"], diag["names"])
|
||
return diag
|