#!/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