根因:元景迭代中为通过 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 中间件提供。
187 lines
8.9 KiB
Python
187 lines
8.9 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""请求体「读后回填」的唯一实现(P0 崩点修复,QC 意见 #2)。
|
||
|
||
要解决的问题(目标机 traceback 原文已写进本迭代 P0 单 _HCoWd3He1axFKKa8ae91)
|
||
--------------------------------------------------------------------------
|
||
`app/http_semantics_middleware.py` 的 `restore_request_body()` 为了让我方读完 body 后
|
||
下游 `processorResource.getPostData()` 还能再解析一次,做了:
|
||
|
||
from aiohttp.payload import BytesPayload
|
||
request._payload = BytesPayload(body)
|
||
|
||
在 aiohttp 3.10.10 里 `Payload` / `BytesPayload` **没有** `at_eof()` 方法,而
|
||
`aiohttp.web_request.BaseRequest.can_read_body` 的实现是:
|
||
|
||
return not self._payload.at_eof()
|
||
|
||
于是本中间件一旦回填过 `_payload`,下游任何一次 `can_read_body` 读取都会抛
|
||
`AttributeError: 'BytesPayload' object has no attribute 'at_eof'` → 该请求 **HTTP 500**。
|
||
影响面:全站 `*.dspy` 500 = 37/215,其中 POST/PUT 写类端点(CRUD `add_*` /
|
||
`create_*` / `entity_create` / `runtime_control` / `add_world` 等 42 条)必崩,
|
||
本迭代的 500 门禁(须为 0)不可能清零。
|
||
|
||
两种改法(QC 给了 A1/A2 二选一)
|
||
------------------------------
|
||
* A1:完全不替换 `_payload`,只保留 `request._read_bytes` 缓存,并把所有下游读取点
|
||
改为优先走缓存。**问题**:下游读取点在基座 `ahserver.processorResource`(本应用
|
||
`referenced_modules`,按 project-directory-spec §三「引用模块写保护」只读,
|
||
改了会破坏所有依赖 ahserver 的项目),本角色无权修改。
|
||
* A2(本文件采用):改用 `aiohttp.streams.StreamReader` + `feed_data/feed_eof`
|
||
重建 `_payload`。`StreamReader` **有** `at_eof()`(`self._eof and not self._data`),
|
||
因此 `can_read_body` 正常返回 bool;同时新流未 EOF 前可被 `post()/multipart()`
|
||
重新消费,等价于原意图。
|
||
|
||
三条硬约束
|
||
----------
|
||
1. **绝不构造 `BytesPayload`**:构造失败/类型不可用时,宁可**不碰** `_payload`
|
||
(原 `_payload` 本身就是 `StreamReader`,有 `at_eof`),也绝不塞一个没有
|
||
`at_eof` 的对象进去——把 AttributeError 崩点换成"下游读不到 body"是倒退。
|
||
2. 回填同时保留(并写回)`request._read_bytes`,让 `await request.read()` 幂等。
|
||
3. 本模块**顶层不 import aiohttp**:aiohttp 只在函数内部按需导入,因此可以脱离
|
||
web 运行时做离线自测(见 `scripts/selftest_body_restore_r50.py`)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
|
||
log = logging.getLogger("yuanjing.body_restore")
|
||
|
||
#: 回填结果码
|
||
RESTORED = "restored" # _payload 已用 StreamReader 重建,下游可重读
|
||
CACHE_ONLY = "cache_only" # 只写了 _read_bytes 缓存,未碰 _payload(安全降级)
|
||
SKIPPED_EMPTY = "skipped_empty" # body 为空,无需回填
|
||
FAILED = "failed" # 回填抛异常(已吞异常,仅告警)
|
||
|
||
|
||
def _make_stream_reader(body, template=None, loop=None):
|
||
"""构造一个已喂入 body 并到达 EOF 的 aiohttp StreamReader。
|
||
|
||
返回 (reader, "") 或 (None, reason)。构造成功后必须满足:
|
||
* 有 `at_eof()`(`can_read_body` 不再 AttributeError);
|
||
* `at_eof()` 在 EOF 后返回 True(不谎报"还有 body 可读")。
|
||
"""
|
||
try:
|
||
from aiohttp.streams import StreamReader
|
||
except Exception as exc: # noqa: BLE001
|
||
return None, "no aiohttp.streams.StreamReader: %s" % exc
|
||
kwargs = {}
|
||
# 尽量沿用原 payload 的分片/上限/事件循环参数,避免行为漂移
|
||
for attr, key in (("_limit", "limit"), ("_chunk", "chunk"), ("_timer", "timer"),
|
||
("_loop", "loop")):
|
||
val = getattr(template, attr, None) if template is not None else None
|
||
if val is None and key == "loop":
|
||
val = loop
|
||
if val is not None:
|
||
kwargs[key] = val
|
||
try:
|
||
reader = StreamReader(**kwargs)
|
||
except Exception: # noqa: BLE001
|
||
# 参数签名随版本变化:退回到无参构造( aiohttp 全版本支持)
|
||
try:
|
||
reader = StreamReader()
|
||
except Exception as exc: # noqa: BLE001
|
||
return None, "StreamReader() failed: %s" % exc
|
||
try:
|
||
reader.feed_data(body)
|
||
reader.feed_eof()
|
||
except Exception as exc: # noqa: BLE001
|
||
return None, "feed_data/feed_eof failed: %s" % exc
|
||
if not callable(getattr(reader, "at_eof", None)):
|
||
return None, "constructed reader has no at_eof()"
|
||
# 自检:at_eof() 必须**可安全求值并返回 bool**(这正是线上 500 的位置)。
|
||
# 注意:喂入 body + feed_eof 之后 at_eof() 仍应为 False(body 未被消费),
|
||
# 这正是让下游 `can_read_body=True → read()` 能重读到 body 的语义。
|
||
try:
|
||
if not isinstance(reader.at_eof(), bool):
|
||
return None, "at_eof() did not return bool"
|
||
except Exception as exc: # noqa: BLE001
|
||
return None, "at_eof() raised: %s" % exc
|
||
return reader, ""
|
||
|
||
|
||
def restore_request_payload(request, body):
|
||
"""把已读走的 body 安全回填给下游。返回 (ok: bool, code: str)。
|
||
|
||
安全顺序(任一步失败都不影响前面已完成的步骤):
|
||
1. 写 `request._read_bytes`(aiohttp 自己的读缓存,重复读幂等);
|
||
2. 用 `StreamReader` 重建 `request._payload`(A2 方案,有 at_eof);
|
||
3. 第 2 步不可用时**保持 `_payload` 原样**(= CACHE_ONLY 降级),
|
||
绝不写 `BytesPayload`(那正是 P0 500 的崩点)。
|
||
"""
|
||
if not body:
|
||
return True, SKIPPED_EMPTY
|
||
if not isinstance(body, (bytes, bytearray)):
|
||
return False, FAILED
|
||
|
||
# 1) 读缓存(一定先做:即使后面 payload 重建失败,handler 走 read() 仍有内容)
|
||
cache_ok = False
|
||
try:
|
||
request._read_bytes = bytes(body)
|
||
cache_ok = True
|
||
except Exception as exc: # noqa: BLE001
|
||
log.warning("[body_restore] 写 _read_bytes 失败 path=%s: %s",
|
||
getattr(request, "path", ""), exc)
|
||
|
||
# 2) 用 StreamReader 重建 _payload
|
||
template = None
|
||
loop = None
|
||
try:
|
||
template = getattr(request, "_payload", None)
|
||
except Exception: # noqa: BLE001
|
||
template = None
|
||
try:
|
||
loop = getattr(request, "_loop", None) or getattr(template, "_loop", None)
|
||
except Exception: # noqa: BLE001
|
||
loop = None
|
||
reader, reason = _make_stream_reader(bytes(body), template=template, loop=loop)
|
||
if reader is None:
|
||
# 3) 安全降级:**不碰** _payload
|
||
log.warning("[body_restore] 跳过 _payload 重建(保持原流,避免 at_eof 缺失)"
|
||
"path=%s reason=%s cache_ok=%s",
|
||
getattr(request, "path", ""), reason, cache_ok)
|
||
return (cache_ok, CACHE_ONLY)
|
||
|
||
try:
|
||
request._payload = reader
|
||
# 让 aiohttp 的 post()/multipart() 重新解析(缓存的旧解析结果已不属于新流)
|
||
for attr in ("_post_params", "_body_attrs_set"):
|
||
try:
|
||
if getattr(request, attr, None) is not None:
|
||
setattr(request, attr, None)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
try:
|
||
request._reading = False
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
except Exception as exc: # noqa: BLE001
|
||
log.warning("[body_restore] 赋 _payload 失败 path=%s: %s",
|
||
getattr(request, "path", ""), exc)
|
||
return (cache_ok, CACHE_ONLY)
|
||
|
||
# 自检:回填后 can_read_body 必须可安全求值(这正是线上 500 的位置)
|
||
try:
|
||
request.can_read_body
|
||
except Exception as exc: # noqa: BLE001
|
||
log.error("[body_restore] 回填后 can_read_body 仍抛异常 path=%s: %s "
|
||
"→ 回退为仅缓存", getattr(request, "path", ""), exc)
|
||
try:
|
||
if template is not None:
|
||
request._payload = template
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
return (cache_ok, CACHE_ONLY)
|
||
return True, RESTORED
|
||
|
||
|
||
def describe(request):
|
||
"""诊断串(不含任何 body 内容/凭据,只有类型名),用于日志与自测。"""
|
||
payload = getattr(request, "_payload", None)
|
||
return "payload_type=%s has_at_eof=%s read_bytes_len=%s" % (
|
||
type(payload).__name__,
|
||
callable(getattr(payload, "at_eof", None)),
|
||
len(getattr(request, "_read_bytes", b"") or b""),
|
||
)
|