根因:元景迭代中为通过 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 中间件提供。
502 lines
20 KiB
Python
502 lines
20 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""登录端点入参的多源取值与「是否真的缺参」判定(Bug kQkhtciiSmu3EMhovy3Lf / UKeLdiljGVbc7IQZO12nV / Iq_o8E0510HAm7dPpGsZx)。
|
||
|
||
为什么需要这个模块(实测根因)
|
||
------------------------------
|
||
`app/http_semantics_middleware.py` 的 ⑤ 号闸门(R41 引入)本意是:**缺** username/password
|
||
时早返回 400,避免 None 被灌进 `appPublic/rc4._crypt` 崩成 500。它只读
|
||
`collect_request_params()` 的结果。
|
||
|
||
R49-b 线上回显(docs/03-test/R49-b-deployed-verify-decision.md §2.2,2026-10-26):
|
||
|
||
POST -d 'username=admin&password=admin123' /rbac/user/up_login.dspy -> HTTP=400
|
||
POST -H 'Content-Type: application/json' -d '{"username":"admin",...}' -> HTTP=400
|
||
body: {"status":400, "reason":"missing required parameter", "missing":["username","password"]}
|
||
|
||
即**参数明明提交了,却判成缺失**。原因不在 `parse_body_params`(form/JSON 解析在离线自测里是
|
||
通过的),而在取值时序:本中间件之前还有语义中间件链(`app/semantic_wiring.py` 装配的
|
||
auth_gate / notfound / business_status 等)。只要链上任何一环先消费过请求流(走
|
||
`request.multipart()` / StreamReader 而不是 `request.read()`),aiohttp 的 `_read_bytes`
|
||
缓存就是空的,`request.read()` 此时返回 `b""` —— 于是 body 解析得到 `{}`,
|
||
`missing_login_params()` 判「两个必填都缺」,登录被 400 挡死。
|
||
|
||
后果是**阻断面**:没有会话 → 全部受保护端点在部署态不可观测,test/deploy 的矩阵只能匿名跑。
|
||
|
||
修法(两层)
|
||
------------
|
||
1. **多源取值**:query / 路径变量 / ahserver 已合并的请求参数缓存(若存在)/ 原始 body
|
||
字节(JSON、form-urlencoded、multipart 普通字段)/ `request.post()` 兜底,逐源合并,
|
||
任一源取到即用。
|
||
2. **证据分级 + fail-open**:只有「确实读到了请求体(或确实没有请求体)且必填名为空」
|
||
才判 `missing` → 400;如果所有源都取不到值**且请求体读取失败/不可判**,判 `unknown`,
|
||
**不再伪造 400**,放行业务处理器(让它按真实情况成功或返回自己的错误)。
|
||
理由:把「已提交凭据」误判成「缺参」是**功能性阻断**(P0,全站不可用),
|
||
比漏判一次 500 严重得多;而真正的缺参请求(`-d '{}'` / 无 body)证据链完整,
|
||
仍然稳定 400(保留 R41 的原口径,不回退)。
|
||
|
||
硬约束
|
||
------
|
||
* 不打印口令(日志里只出现参数名与证据键,值一律 `***`);
|
||
* 不修改基座只读模块(rbac / ahserver / apppublic),一切收敛在应用层;
|
||
* 本模块只做取值与判定,不写响应体(响应仍由 http_semantics_middleware 统一出)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
|
||
log = logging.getLogger("yuanjing.login_param_source")
|
||
|
||
#: 判定结论
|
||
VERDICT_MISSING = "missing" # 证据充分:必填参数确实没提交 → 可以 400
|
||
VERDICT_PRESENT = "present" # 取到了必填参数 → 放行
|
||
VERDICT_UNKNOWN = "unknown" # 证据不足(请求体不可读且各源皆空)→ 放行(不伪造 400)
|
||
|
||
#: ahserver/aiohttp 上可能已存放「合并后请求参数」的属性名(best-effort 探测,命中即用)
|
||
_CACHED_PARAM_ATTRS = (
|
||
"params_kw", # ahserver 约定:query+body 合并后的参数字典
|
||
"_params_kw",
|
||
"ah_params",
|
||
"request_params",
|
||
"_ah_params",
|
||
)
|
||
|
||
_BODY_METHODS = ("POST", "PUT", "PATCH", "DELETE")
|
||
|
||
#: 标量白名单:口令之类允许空串判定,但容器值一律丢弃(避免把 dict 当参数值)
|
||
_SCALAR_TYPES = (str, int, float, bool, bytes)
|
||
|
||
# 自测钩子:置 1 时 `_read_body_bytes` 模拟「流已被上游消费」的场景
|
||
_FORCE_BODY_UNREADABLE = bool(os.environ.get("YJ_FAKE_BODY_UNREADABLE"))
|
||
|
||
|
||
def _is_meaningful(value):
|
||
"""非空标量才算「提交了该参数」(空串/纯空白/容器/None 都算没提交)。"""
|
||
if value is None:
|
||
return False
|
||
if isinstance(value, (dict, list, tuple, set)):
|
||
return False
|
||
if isinstance(value, bytes):
|
||
try:
|
||
value = value.decode("utf-8", "replace")
|
||
except Exception: # noqa: BLE001
|
||
return False
|
||
if isinstance(value, str):
|
||
return value.strip() != ""
|
||
return True
|
||
|
||
|
||
#: 合法参数名:可见字符,不含空白/控制符/换行,不以 -- 开头(防 multipart 脏 key 入库)
|
||
_INVALID_NAME_RE = re.compile(r"[\r\n\t\x00-\x1f]|^--|;|content-disposition", re.IGNORECASE)
|
||
|
||
|
||
def _valid_name(name):
|
||
return bool(name) and not _INVALID_NAME_RE.search(str(name))
|
||
|
||
|
||
def _flatten_mapping(raw, out):
|
||
"""把 dict / MultiDict 类容器摊平成 {小写名: 值}(只收有意义的标量)。"""
|
||
if raw is None:
|
||
return out
|
||
items = None
|
||
if isinstance(raw, dict):
|
||
items = raw.items()
|
||
elif hasattr(raw, "items") and callable(getattr(raw, "items")):
|
||
try:
|
||
items = raw.items()
|
||
except Exception: # noqa: BLE001
|
||
items = None
|
||
elif hasattr(raw, "getall") and callable(getattr(raw, "getall")):
|
||
items = None
|
||
if items is not None:
|
||
try:
|
||
for k, v in items:
|
||
out.setdefault(str(k).lower(), v)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
else:
|
||
try:
|
||
for k in raw:
|
||
out.setdefault(str(k).lower(), raw[k])
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
return out
|
||
|
||
|
||
def _from_framework_cache(request, out):
|
||
"""源①:ahserver 已解析并挂到 request / _run_ns 上的参数缓存(存在即最可信)。"""
|
||
hit = None
|
||
carriers = [request]
|
||
run_ns = getattr(request, "_run_ns", None)
|
||
if run_ns is not None:
|
||
carriers.append(run_ns)
|
||
for carrier in carriers:
|
||
for attr in _CACHED_PARAM_ATTRS:
|
||
candidate = getattr(carrier, attr, None)
|
||
if isinstance(candidate, (dict, list, tuple)) or hasattr(candidate, "items"):
|
||
hit = candidate
|
||
break
|
||
if hit is not None:
|
||
break
|
||
if hit is None:
|
||
return out, False
|
||
_flatten_mapping(hit, out)
|
||
return out, True
|
||
|
||
|
||
def _from_query_and_match(request, out):
|
||
"""源②:查询串 + 路径变量(两者都是 aiohttp 的只读缓存,读取无副作用)。"""
|
||
try:
|
||
_flatten_mapping(request.query, out)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
try:
|
||
_flatten_mapping(request.match_info, out)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
# 兼容历史写法:GET 表单走 request.GET
|
||
try:
|
||
_flatten_mapping(getattr(request, "GET", None), out)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
return out
|
||
|
||
|
||
def _json_dict(text):
|
||
try:
|
||
parsed = json.loads(text)
|
||
except Exception: # noqa: BLE001
|
||
return None
|
||
return parsed if isinstance(parsed, dict) else None
|
||
|
||
|
||
def _qsl_pairs(text):
|
||
try:
|
||
from urllib.parse import parse_qsl
|
||
return parse_qsl(text, keep_blank_values=True)
|
||
except Exception: # noqa: BLE001
|
||
return []
|
||
|
||
|
||
_NAME_RE = re.compile(r"name=\"([^\"]*)\"", re.IGNORECASE)
|
||
_NAME_RE2 = re.compile(r"name=([^;\r\n\"]+)", re.IGNORECASE)
|
||
|
||
|
||
_BOUNDARY_RE = re.compile(r'boundary="?([^";,]+)"?', re.IGNORECASE)
|
||
|
||
|
||
def _boundary_of(content_type):
|
||
"""取 Content-Type 里的 boundary(**保留原始大小写**)。"""
|
||
m = _BOUNDARY_RE.search(content_type or "")
|
||
return m.group(1).strip() if m else ""
|
||
|
||
|
||
def _field_name(header_text):
|
||
"""从 Content-Disposition 头里取 name= (带引号优先,其次裸值)。"""
|
||
m = _NAME_RE.search(header_text)
|
||
if m:
|
||
return m.group(1).strip()
|
||
m = _NAME_RE2.search(header_text)
|
||
if m:
|
||
return m.group(1).strip().strip('"').strip("'")
|
||
return None
|
||
|
||
|
||
def multipart_plain_fields(body, boundary, keep_case=False):
|
||
"""按 RFC 2046 精确切分 multipart 分段,取**普通文本字段**(跳过 filename= 文件字段)。
|
||
|
||
keep_case=True 时返回的字段名保持原始大小写(`request.params` 口径需要),
|
||
默认小写(本模块内部判定口径)。
|
||
|
||
为什么不能 `body.split("--"+boundary)`(原实现的缺陷,QC 意见 #4):
|
||
* delimiter 的**前导 CRLF** 属于分隔符的一部分;直接用 `--boundary` split 会把
|
||
上一段正文结尾的 `\\r\\n` 留在下一段的头部,导致 `username` 的值被吞成
|
||
`'admin\\r\\n--XY\\r\\nContent-Disposition...'`;
|
||
* 每段结尾的 `\\r\\n`(属于下一个 delimiter)会被当成正文的一部分留下;
|
||
* `--boundary--`(final boundary)之后的 epilogue 也会被当成一段解析,产出
|
||
`--xy\\r\\ncontent-disposition: form-data; name` 这类脏 key。
|
||
本实现逐段定位 delimiter(含前导 CRLF),识别 final boundary 即终止,
|
||
并在截取的段内剥离尾部 CRLF,只取 Content-Disposition 的 name= 与紧随其后的正文。
|
||
"""
|
||
out = {}
|
||
if not body or not boundary:
|
||
return out
|
||
delim = ("--" + str(boundary)).encode("utf-8", "replace")
|
||
first = body.find(delim)
|
||
if first < 0:
|
||
return out
|
||
idx = first
|
||
while True:
|
||
start = idx + len(delim)
|
||
# final boundary: "--boundary--" → 后面只有 epilogue,解析结束
|
||
if body[start:start + 2] == b"--":
|
||
break
|
||
# delimiter 之后必须紧跟 CRLF(或流末尾),否则不是合法分段起点
|
||
if body[start:start + 2] != b"\r\n":
|
||
nxt = body.find(delim, start)
|
||
if nxt < 0:
|
||
break
|
||
idx = nxt
|
||
continue
|
||
start += 2
|
||
nxt = body.find(delim, start)
|
||
if nxt < 0:
|
||
part = body[start:]
|
||
else:
|
||
part = body[start:nxt]
|
||
# 段尾的 CRLF 属于下一个 delimiter,剥掉
|
||
if part.endswith(b"\r\n"):
|
||
part = part[:-2]
|
||
if part and part[-2:] == b"\r\n":
|
||
part = part[:-2]
|
||
head_raw, sep, payload = part.partition(b"\r\n\r\n")
|
||
if sep:
|
||
head_text = head_raw.decode("utf-8", "replace")
|
||
low = head_text.lower()
|
||
if "content-disposition" in low and "filename=" not in low:
|
||
name = _field_name(head_text)
|
||
if name:
|
||
key = name if keep_case else name.lower()
|
||
out.setdefault(key, payload.decode("utf-8", "replace"))
|
||
if nxt < 0:
|
||
break
|
||
idx = nxt
|
||
return out
|
||
|
||
|
||
#: 向后兼容旧私有名
|
||
def _multipart_plain_fields(body, boundary):
|
||
return multipart_plain_fields(body, boundary)
|
||
|
||
|
||
def _parse_body_bytes(body, content_type, out):
|
||
"""源④:原始请求体字节(multipart → JSON → form-urlencoded)。"""
|
||
if not body:
|
||
return out
|
||
ctype_raw = content_type or ""
|
||
ctype = ctype_raw.lower()
|
||
if ctype.startswith("multipart/"):
|
||
# boundary 是**大小写敏感**的(RFC 2046),必须从原始 Content-Type 取值,
|
||
# 不能从 lower() 后的串取——否则 `boundary=XY` 会被当成 `xy` 而一段都切不到,
|
||
# 再落进 form-urlencoded 兜底,产出 `--xy\r\ncontent-disposition...` 脏 key。
|
||
merged = multipart_plain_fields(body, _boundary_of(ctype_raw))
|
||
for k, v in merged.items():
|
||
out.setdefault(k, v)
|
||
if out:
|
||
return out
|
||
# 没解析出任何字段也**不得**回落到 qsl 解析(会把整段 multipart 当表单串切坏)
|
||
return out
|
||
text = body.decode("utf-8", "replace").strip()
|
||
if not text:
|
||
return out
|
||
if text.startswith("{") or "json" in ctype:
|
||
parsed = _json_dict(text)
|
||
if isinstance(parsed, dict):
|
||
for k, v in parsed.items():
|
||
out.setdefault(str(k).lower(), v)
|
||
return out
|
||
for k, v in _qsl_pairs(text):
|
||
out.setdefault(str(k).lower(), v)
|
||
return out
|
||
|
||
|
||
async def _read_body_bytes(request):
|
||
"""读请求体字节,返回 (body, readable)。
|
||
|
||
readable=False 表示「拿不到证据」:上游已消费流且补读失败——此时**不得**判缺参。
|
||
"""
|
||
if _FORCE_BODY_UNREADABLE:
|
||
return b"", False
|
||
cached = getattr(request, "_read_bytes", None)
|
||
if isinstance(cached, (bytes, bytearray)) and bytes(cached):
|
||
return bytes(cached), True
|
||
# 上游已消费过:_read_bytes 为空 → read() 只会拿到 b"",此时尝试 can_read_body 判定
|
||
if not getattr(request, "can_read_body", True):
|
||
try:
|
||
body = await request.read()
|
||
except Exception: # noqa: BLE001
|
||
body = b""
|
||
if isinstance(body, (bytes, bytearray)) and bytes(body):
|
||
return bytes(body), True
|
||
# 请求声明了 body 却读不到内容:证据不足(真实提交过凭据也可能落在这里)
|
||
return b"", False
|
||
try:
|
||
body = await request.read()
|
||
except Exception: # noqa: BLE001
|
||
return b"", False
|
||
if isinstance(body, (bytes, bytearray)):
|
||
return bytes(body), True
|
||
return b"", False
|
||
|
||
|
||
def _declares_body(request):
|
||
"""请求是否在协议层**声明**带了 body(Content-Length>0 或 Transfer-Encoding: chunked)。
|
||
|
||
这是判定「证据不足」的关键:只要协议声明有 body,而本层一个字节都没读到
|
||
(上游中间件已消费流 / aiohttp 缓存为空),就**不能**断言「参数没提交」。
|
||
"""
|
||
try:
|
||
if getattr(request, "content_length", None):
|
||
return True
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
try:
|
||
headers = getattr(request, "headers", None)
|
||
except Exception: # noqa: BLE001
|
||
headers = None
|
||
if headers is not None:
|
||
try:
|
||
cl = headers.get("Content-Length")
|
||
except Exception: # noqa: BLE001
|
||
cl = None
|
||
try:
|
||
if cl is not None and int(cl) > 0:
|
||
return True
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
try:
|
||
te = str(headers.get("Transfer-Encoding") or "").lower()
|
||
except Exception: # noqa: BLE001
|
||
te = ""
|
||
if "chunked" in te:
|
||
return True
|
||
# 有 Content-Type 但没有 Content-Length 的替身对象(离线自测):视为声明带 body
|
||
try:
|
||
ctype = getattr(request, "content_type", "") or ""
|
||
except Exception: # noqa: BLE001
|
||
ctype = ""
|
||
if ctype and getattr(request, "content_length", None) is None:
|
||
return True
|
||
return False
|
||
|
||
|
||
async def _from_request_post(request, out):
|
||
"""源⑤:`request.post()`(form/multipart 的官方解析路径)兜底。"""
|
||
try:
|
||
post = await request.post()
|
||
except Exception: # noqa: BLE001
|
||
return out
|
||
_flatten_mapping(post, out)
|
||
return out
|
||
|
||
|
||
def _required_names(path, spec_table):
|
||
"""取该端点的必填 canonical 名与别名表(缺配置返回 None)。"""
|
||
specs = (spec_table or {}).get(path or "")
|
||
if not specs:
|
||
return None
|
||
return [(canonical, tuple(a.lower() for a in aliases)) for canonical, aliases in specs]
|
||
|
||
|
||
async def collect_login_params(request, spec_table):
|
||
"""多源收集登录端点参数,返回 (params, evidence)。
|
||
|
||
params : {小写参数名: 原始值}(只保留有意义的标量)
|
||
evidence : {"sources":[..], "body_readable":bool, "body_len":int,
|
||
"framework_cache_hit":bool}
|
||
"""
|
||
params = {}
|
||
sources = []
|
||
|
||
merged, cache_hit = _from_framework_cache(request, params)
|
||
if cache_hit and any(_is_meaningful(v) for v in merged.values()):
|
||
sources.append("framework_cache")
|
||
|
||
before = len([k for k, v in params.items() if _is_meaningful(v)])
|
||
_from_query_and_match(request, params)
|
||
if len([k for k, v in params.items() if _is_meaningful(v)]) > before:
|
||
sources.append("query/match_info")
|
||
|
||
body_readable = True
|
||
body_len = 0
|
||
declared_body = False
|
||
if request.method in _BODY_METHODS:
|
||
declared_body = _declares_body(request)
|
||
body, body_readable = await _read_body_bytes(request)
|
||
body_len = len(body or b"")
|
||
if body:
|
||
_parse_body_bytes(body, request.content_type, params)
|
||
sources.append("raw_body")
|
||
if not any(_is_meaningful(v) for v in params.values()):
|
||
await _from_request_post(request, params)
|
||
if any(_is_meaningful(v) for v in params.values()):
|
||
sources.append("request.post")
|
||
|
||
clean = {k: v for k, v in params.items() if _is_meaningful(v)}
|
||
evidence = {
|
||
"sources": sources,
|
||
"body_readable": body_readable,
|
||
"body_len": body_len,
|
||
"declared_body": declared_body,
|
||
"framework_cache_hit": cache_hit,
|
||
"names": sorted(clean.keys()),
|
||
}
|
||
return clean, evidence
|
||
|
||
|
||
def judge_required(path, params, evidence, spec_table):
|
||
"""返回 (verdict, missing_names)。
|
||
|
||
* 必填都取到 → present
|
||
* 确实缺(且有证据) → missing
|
||
* 取不到但证据不足 → unknown(调用方**不得**据此 400)
|
||
"""
|
||
specs = _required_names(path, spec_table)
|
||
if not specs:
|
||
return VERDICT_PRESENT, []
|
||
lowered = {str(k).lower(): v for k, v in (params or {}).items()}
|
||
missing = []
|
||
for canonical, aliases in specs:
|
||
if not any(canonicalize_alias(a) in lowered and _is_meaningful(lowered.get(canonicalize_alias(a)))
|
||
for a in aliases):
|
||
missing.append(canonical)
|
||
if not missing:
|
||
return VERDICT_PRESENT, []
|
||
# 有必填没取到:区分「真没提交」与「没读到」
|
||
ev = evidence or {}
|
||
body_readable = ev.get("body_readable", True)
|
||
body_len = ev.get("body_len", 0) or 0
|
||
declared_body = bool(ev.get("declared_body"))
|
||
# 口径①(QC 意见 #5 的核心):协议声明带 body(Content-Length>0 / chunked)而本层
|
||
# 读不到内容 → 证据不足,必须判 unknown 放行,绝不伪造 400 挡死合法登录。
|
||
if not body_readable and (declared_body or body_len > 0):
|
||
return VERDICT_UNKNOWN, missing
|
||
# 口径②:读到过内容(body_len>0 或已取到别的参数)但仍缺必填,且流不可重读 →
|
||
# 同样属于"取值链不完整",证据不足放行。
|
||
if not body_readable and (lowered or body_len > 0):
|
||
return VERDICT_UNKNOWN, missing
|
||
# 口径③:Content-Length=0 / 确实无 body / 读到了 body 但必填为空 → 真缺参,可以 400
|
||
return VERDICT_MISSING, missing
|
||
|
||
|
||
def canonicalize_alias(alias):
|
||
return str(alias).lower()
|
||
|
||
|
||
def masked_evidence(evidence):
|
||
"""日志安全的证据摘要(不含任何值,只有参数名/长度/来源)。"""
|
||
ev = evidence or {}
|
||
return "sources=%s names=%s body_len=%s body_readable=%s fw_cache=%s" % (
|
||
",".join(ev.get("sources") or []) or "-",
|
||
",".join(ev.get("names") or []) or "-",
|
||
ev.get("body_len", 0),
|
||
ev.get("body_readable", True),
|
||
ev.get("framework_cache_hit", False),
|
||
)
|
||
|
||
|
||
# --- 兼容旧调用(离线自测里按同口径构造过判定)-----------------------------
|
||
def judge_missing(path, params, spec_table):
|
||
"""旧接口:只做名字判定,返回缺失 canonical 列表。"""
|
||
specs = _required_names(path, spec_table) or []
|
||
lowered = {str(k).lower(): v for k, v in (params or {}).items()}
|
||
out = []
|
||
for canonical, aliases in specs:
|
||
if not any(a in lowered and _is_meaningful(lowered.get(a)) for a in aliases):
|
||
out.append(canonical)
|
||
return out
|