Skip to content

Commit ba9a393

Browse files
committed
fix(security): 修复逐项扫描发现的生产隐患(P0 五项 + 稳定性/安全纵深)
P0(均可复现的线上故障): - 流式请求遇未知 provider/模型返回 200 空 body:新增 executor.preflight() 在建立 StreamingResponse 前校验,并给流加 stream_guarded() 兜底错误帧 - 请求体非法 JSON 返回 500:新增 deps.read_json_body() 归一为 400 (/v1/chat/completions 与 playground 均受影响) - chunked 请求体绕过登录 8KB 上限:改为 ASGI BodySizeLimitMiddleware 按实际字节计数(原实现只看 content-length) - APP_SECRET 变更后凭证解密抛裸 500:新增 CredentialDecryptError 处理器 返回 credential_decrypt_failed;启动拒绝 <16 字符弱密钥 - TRAE 凭证永不预刷新:TraeProvider 补 credential_from(), RefreshTask 的 _needs_refresh 对其恒为 False 稳定性/安全: - 模型列表加服务端 TTL(300s)且只取可调度凭证(跳过 disabled) - pending_probes 完成后摘除,避免长跑内存累积 - 流式加 SSE 心跳(15s)与客户端断开记账(client_disconnect) - 会话/API Key 校验用户仍存在于 users.txt;logout 补 CSRF - 新增凭证恢复入口(POST /api/credentials/{id}/revive + 前端菜单) - /api、/v1 未匹配路径返回 JSON 404(原落 SPA 返回 200+HTML) - 429 补 Retry-After;登录限流改在哈希前卡全局/IP 窗口(防 CPU 放大) - 凭证增删改/pin/账号切换写审计日志 - CODEBUDDY_API_ENDPOINT 接线并强制白名单校验(此前配置无效) - 请求体 dump 有界保留(200 份);删除 dumps/ 与 .bak 残留 - schema 记录 PRAGMA user_version;删除零引用的 checkins/model_cache 表 - 清理死代码:matches/config_api_headers/candidate_with_health/ note_success/account_generation_changed/now_seconds/format_frames - CI actions 固定 SHA;补 .python-version;镜像装 tzdata 并默认 TZ 测试:新增 tests/test_hardening.py(61 个 HTTP 层回归用例,覆盖此前 “覆盖率 100% 但运行时路径未覆盖”的盲区);753 → 754 全通过,覆盖率 100%。
1 parent 59f80b2 commit ba9a393

42 files changed

Lines changed: 1716 additions & 281 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ jobs:
99
backend:
1010
runs-on: ubuntu-latest
1111
steps:
12-
- uses: actions/checkout@v4
13-
- uses: astral-sh/setup-uv@v5
12+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
13+
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
1414
- run: uv sync --frozen
1515
- name: Ruff
1616
run: uv run ruff check src tests scripts
@@ -23,11 +23,11 @@ jobs:
2323
run:
2424
working-directory: web
2525
steps:
26-
- uses: actions/checkout@v4
27-
- uses: pnpm/action-setup@v4
26+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
27+
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
2828
with:
2929
version: 10
30-
- uses: actions/setup-node@v4
30+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
3131
with:
3232
node-version: 24
3333
cache: pnpm
@@ -40,7 +40,7 @@ jobs:
4040
compose:
4141
runs-on: ubuntu-latest
4242
steps:
43-
- uses: actions/checkout@v4
43+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
4444
- name: 校验 compose 语法
4545
run: docker compose config --quiet
4646
env:

‎.python-version‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.12

‎Dockerfile‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ RUN pnpm exec vite build
1010

1111
FROM python:3.12-slim AS runtime
1212

13+
# tzdata 是 CHECKIN_HOUR / 额度周期正确性的前提:任务用 time.localtime() 判断
14+
# "今天 9 点",基础镜像不带 zoneinfo 时 TZ 会被静默忽略、退回 UTC。
15+
RUN apt-get update \
16+
&& apt-get install -y --no-install-recommends tzdata \
17+
&& rm -rf /var/lib/apt/lists/*
18+
1319
ENV PYTHONUNBUFFERED=1 \
1420
PYTHONDONTWRITEBYTECODE=1 \
1521
UV_PROJECT_ENVIRONMENT=/app/.venv \
@@ -37,6 +43,7 @@ USER appuser
3743

3844
ENV DATA_DIR=/app/data \
3945
USERS_FILE=/app/secrets/users.txt \
46+
TZ=Asia/Shanghai \
4047
HOST=0.0.0.0 \
4148
PORT=8000
4249

‎PROPOSAL.md‎

Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -335,24 +335,8 @@ CREATE TABLE usage_hourly (
335335
PRIMARY KEY (hour_utc, username, provider, model)
336336
);
337337

338-
-- 签到记录(按上游账号去重,避免同号多凭证重复签到)
339-
CREATE TABLE checkins (
340-
provider TEXT NOT NULL,
341-
account_key TEXT NOT NULL,
342-
date_local TEXT NOT NULL,
343-
ok INTEGER NOT NULL,
344-
credit REAL,
345-
checked_at INTEGER NOT NULL,
346-
PRIMARY KEY (provider, account_key, date_local)
347-
);
348-
349-
-- 模型缓存
350-
CREATE TABLE model_cache (
351-
provider TEXT NOT NULL,
352-
model_id TEXT NOT NULL,
353-
fetched_at INTEGER NOT NULL,
354-
PRIMARY KEY (provider, model_id)
355-
);
338+
-- 签到去重与模型列表缓存不进库:前者由 CheckinTask 的当日作用域集合实现,
339+
-- 后者是进程内 TTL 缓存(重启即重建,无需持久化)。
356340
```
357341

358342
**脱敏纪律**(继承 CB):不存提示词、回答、请求头、Token、工具参数、原始错误体、会话 ID。
@@ -478,16 +462,23 @@ coding2api/
478462
沿用 codebuddy2api 的既有约定:
479463

480464
- 上游 endpoint 白名单:**只接受明确配置的地址**,真实 Token 绝不转发到未授权站点
465+
(`CODEBUDDY_API_ENDPOINT` 启动时强制校验,不在白名单直接失败)
481466
- TLS 校验默认开启,公网部署必须保持
482467
- Host / Origin 白名单,CSP `frame-ancestors`
483468
- 登录三级限流(全局 / IP / 用户名)+ PBKDF2 并发上限
484-
- 请求体上限 16MB,登录接口 8KB
469+
- 请求体上限 16MB,登录接口 8KB(ASGI 层按实际字节计数,`chunked` 不能绕过)
485470
- API Key 仅存摘要,明文只在创建时返回一次
486471
- 凭证内容加密入库,密钥走 `APP_SECRET` env;**密钥丢失 = 已存凭证全部不可解,只能重录**,不做密钥轮换
487-
- 管理台会话 Cookie `SameSite=Lax` + 写操作自定义头校验(CSRF)
472+
- `APP_SECRET` 最短 16 字符,弱密钥拒绝启动(占位值不再能加密真实凭证)
473+
- 解密失败返回可行动错误码 `credential_decrypt_failed`,不暴露裸 500
474+
- 管理台会话 Cookie `SameSite=Lax` + 写操作自定义头校验(CSRF,含 logout)
475+
- 会话与 API Key 除签名/摘要外**校验用户仍存在于 users.txt**:删用户即失效
476+
- 未匹配的 `/api`、`/v1` 路径返回 JSON 404(不再落到 SPA 的 200 + HTML)
488477
- 日志脱敏:不打印 Token、完整请求体
478+
- 审计:凭证增删改、pin、账号切换写 INFO 日志(含操作人)
489479

490-
不做的:mTLS、审计日志、IP 白名单(交给反向代理)。
480+
不做的:mTLS、IP 白名单(交给反向代理)。审计日志只覆盖凭证管理写操作,
481+
不做全量请求审计(统计表已是脱敏的请求级记录)。
491482

492483
### 配置参考(env)
493484

@@ -501,15 +492,15 @@ coding2api/
501492
| `HOST` / `PORT` | `127.0.0.1` / `8000` | 监听地址与端口 |
502493
| `DATA_DIR` | `./data` | SQLite 与运行数据目录 |
503494
| `DEFAULT_MODEL` | `glm-5.2` | model 为空/auto 时的路由目标 |
504-
| `CHECKIN_HOUR` | `9` | 每日签到时刻(服务器本地时区) |
495+
| `CHECKIN_HOUR` | `9` | 每日签到时刻(容器本地时区,镜像默认 `TZ=Asia/Shanghai`) |
505496
| `QUOTA_PROBE_MINUTES` | `60` | 额度探测周期 |
506497
| `PACER_MIN_SECONDS` / `PACER_MAX_SECONDS` | `5` / `20` | 后台任务随机节流区间 |
507498
| `REFRESH_SKEW_HOURS` | `24` | token 预刷新窗口 |
508499
| `LOG_LEVEL` | `INFO` | 日志级别 |
509500
| `CODEBUDDY_CHAT_MIN_INTERVAL` | `5` | CB 聊天最小间隔(秒),避频控风控;0 关闭 |
510501
| `MODEL_BLOCKLIST` | `custom_model_*,*sub*agent*,summary,browser_use_*` | 模型列表黑名单(fnmatch glob);只影响列表展示 |
511502
| `ALLOWED_HOSTS` | 空 | Host 白名单(防 DNS rebinding);空 = 本地回环 + PUBLIC_BASE_URL 主机 |
512-
| `DUMP_REQUEST_BODIES` | `false` | 诊断开关:/v1 原始请求体落到 data/dumps/ |
503+
| `DUMP_REQUEST_BODIES` | `false` | 诊断开关:/v1 原始请求体落到 data/dumps/(最多保留 200 份) |
513504

514505
---
515506

‎README.md‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,18 +142,34 @@ curl http://127.0.0.1:8000/v1/chat/completions \
142142

143143
| 变量 | 默认 | 说明 |
144144
|---|---|---|
145-
| `APP_SECRET` | **必填** | 凭证列加密密钥;丢失等于凭证全部作废 |
145+
| `APP_SECRET` | **必填** | 凭证列加密密钥,至少 16 字符;丢失等于凭证全部作废 |
146146
| `ADMIN_USERNAMES` | 空 | 逗号分隔;空则所有用户只读 |
147147
| `USERS_FILE` | `secrets/users.txt` | 用户文件路径 |
148148
| `DATA_DIR` | `./data` | SQLite 与运行数据目录 |
149149
| `PUBLIC_BASE_URL` | `http://127.0.0.1:8000` | 浏览器可达地址;TRAE 登录回调依赖它 |
150-
| `CODEBUDDY_API_ENDPOINT` | 中国站 | 上游地址,只接受白名单内地址 |
150+
| `CODEBUDDY_API_ENDPOINT` | 中国站 | 上游地址,只接受 `CODEBUDDY_ALLOWED_ENDPOINTS` 白名单内地址 |
151+
| `CODEBUDDY_ALLOWED_ENDPOINTS` | 中/国际站 | 逗号分隔白名单;不在其中启动即失败 |
151152
| `DEFAULT_MODEL` | `glm-5.2` | 模型为空或 `auto` 时的目标 |
152-
| `CHECKIN_HOUR` | `9` | 每日签到时刻(服务器本地时区) |
153+
| `CHECKIN_HOUR` | `9` | 每日签到时刻(容器本地时区,镜像默认 `TZ=Asia/Shanghai`) |
153154
| `QUOTA_PROBE_MINUTES` | `60` | 额度探测周期 |
154155
| `CODEBUDDY_CHAT_MIN_INTERVAL` | `5` | CB 聊天最小间隔(秒),避频控风控;0 关闭 |
155156
| `MODEL_BLOCKLIST` | `custom_model_*,*sub*agent*,summary,browser_use_*` | 模型列表黑名单(fnmatch glob,逗号分隔,完全替换语义);只影响列表展示,直连指定不受影响 |
156157
| `ALLOWED_HOSTS` | 空 | Host 白名单(防 DNS rebinding);空 = 本地回环 + `PUBLIC_BASE_URL` 主机 |
158+
| `DUMP_REQUEST_BODIES` | `false` | 诊断开关:把 `/v1` 原始请求体落到 `data/dumps/`(含对话内容,勿长期开启) |
159+
160+
## 部署注意
161+
162+
- **挂载目录属主**:容器内以 uid 1001(`appuser`)运行,`./data` 与 `./secrets`
163+
必须可写/可读,否则 SQLite 打不开、服务崩溃重启:
164+
165+
```bash
166+
mkdir -p data secrets && sudo chown -R 1001:1001 data secrets
167+
```
168+
169+
- **时区**:`CHECKIN_HOUR` 按容器本地时区解释。镜像已装 `tzdata` 并默认
170+
`TZ=Asia/Shanghai`;如需其它时区,显式覆盖 `TZ`,否则签到时刻会按 UTC 算。
171+
- **APP_SECRET**:至少 16 字符,弱密钥(如 `.env.example` 里的占位串)会在
172+
启动时被拒绝;更换后已存凭证全部无法解密,需重新录入。
157173

158174
## 开发
159175

‎src/api/admin_auth.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,12 @@ async def login(request: Request, payload: dict):
2626
password = str(payload.get("password") or "")
2727
ip = request.client.host if request.client else ""
2828
throttle = services.login_throttle
29+
# 哈希前先卡全局/IP 窗口:PBKDF2(600k 迭代)是 CPU 密集操作,
30+
# 不限流的话无效尝试就能占满线程池(DoS)。
31+
# 用户名窗口故意放到验证之后:它可能被他人输错用户名抬高,
32+
# 提前拦截会误伤合法用户。
33+
throttle.check_transport_windows(ip=ip)
2934
# PBKDF2 是 CPU 密集同步操作:线程池 + 信号量限并发,防事件循环卡死。
30-
# 先验证再限流:正确密码永不被窗口卡死(一次成功即解锁),
31-
# 失败才走窗口计数——爆破频率被压到可用性以下。
3235
verified = await throttle.verify(services.users.verify, username, password)
3336
if not verified:
3437
# 超限时抛 429 且不再计数;未超限则记一次失败并回 401
@@ -46,7 +49,8 @@ async def login(request: Request, payload: dict):
4649
return response
4750

4851
@router.post("/api/auth/logout")
49-
async def logout():
52+
async def logout(_csrf: None = Depends(csrf_protected)):
53+
# 不校验 CSRF 时,任何跨站页面都能强制登出(拒绝服务式骚扰)。
5054
response = JSONResponse({"ok": True})
5155
response.delete_cookie(SESSION_COOKIE, path="/")
5256
return response

‎src/api/admin_credentials.py‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ async def import_credential(payload: dict,
7171
credential_data=credential_data,
7272
nickname=str(payload.get("nickname") or ""),
7373
added_by=principal.username)
74+
logger.info("管理员 %s 新增凭证 %s(上游 %s)", principal.username, credential_id,
75+
provider_id)
7476
schedule_probe(credential_id)
7577
return {"id": credential_id}
7678

@@ -81,14 +83,29 @@ async def toggle_credential(credential_id: str, payload: dict,
8183
require_admin(principal)
8284
if not credentials.set_enabled(credential_id, bool(payload.get("enabled", True))):
8385
raise InvalidRequest("credential not found")
86+
logger.info("管理员 %s 开关凭证 %s -> %s", principal.username, credential_id,
87+
payload.get("enabled", True))
88+
return {"ok": True}
89+
90+
@router.post("/api/credentials/{credential_id}/revive")
91+
async def revive_credential(credential_id: str,
92+
_csrf: None = Depends(csrf_protected),
93+
principal=Depends(principal_from_request)):
94+
"""解除硬禁用/冷却,让重新登录后的凭证回到池子。"""
95+
require_admin(principal)
96+
if not credentials.revive(credential_id):
97+
raise InvalidRequest("credential not found")
98+
logger.info("管理员 %s 恢复了凭证 %s", principal.username, credential_id)
8499
return {"ok": True}
85100

86101
@router.post("/api/credentials/pin")
87102
async def pin_credential(payload: dict,
88103
_csrf: None = Depends(csrf_protected),
89104
principal=Depends(principal_from_request)):
90105
require_admin(principal)
91-
credentials.set_pinned(payload.get("credential_id"))
106+
pin = payload.get("credential_id")
107+
credentials.set_pinned(pin)
108+
logger.info("管理员 %s 固定凭证 %s", principal.username, pin)
92109
return {"ok": True}
93110

94111
@router.delete("/api/credentials/{credential_id}")
@@ -98,6 +115,7 @@ async def delete_credential(credential_id: str,
98115
require_admin(principal)
99116
if not credentials.delete(credential_id):
100117
raise InvalidRequest("credential not found")
118+
logger.info("管理员 %s 删除凭证 %s", principal.username, credential_id)
101119
return {"ok": True}
102120

103121
@router.post("/api/credentials/{credential_id}/probe")
@@ -164,6 +182,8 @@ async def select_credential_account(credential_id: str, payload: dict,
164182
raise InvalidRequest("credential does not support account switching")
165183
switched = await provider.switch_account(data, str(payload.get("account_id") or ""))
166184
credentials.save_credential_data(credential_id, switched)
185+
logger.info("管理员 %s 切换凭证 %s 账号 -> %s", principal.username, credential_id,
186+
payload.get("account_id"))
167187
# 账号切换后额度对应的是新账号,必须重探测而不是沿用旧值
168188
schedule_probe(credential_id)
169189
return {"switched": True}

‎src/api/chat.py‎

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import contextlib
56
import json
67
import time
78
from pathlib import Path
@@ -10,7 +11,30 @@
1011
from fastapi.responses import JSONResponse, StreamingResponse
1112

1213
from ..compat.openai.request import parse_chat_request
13-
from .deps import Services, api_key_user
14+
from .deps import Services, api_key_user, read_json_body
15+
from .streaming import with_keepalive
16+
17+
# dump 目录最多保留的文件数:诊断开关长期开着时不能无限侵占磁盘
18+
DUMP_KEEP_FILES = 200
19+
20+
21+
def dump_request_body(data_dir: Path, body: object) -> None:
22+
"""把原始请求体写到 data/dumps/(诊断用),并淘汰超量的旧文件。
23+
24+
请求体包含完整对话内容,因此这个开关只应用于排查客户端差异,
25+
不能长期开启;这里只做基本的有界保留。
26+
"""
27+
dump_dir = data_dir / "dumps"
28+
dump_dir.mkdir(parents=True, exist_ok=True)
29+
try:
30+
(dump_dir / f"{int(time.time() * 1000)}.json").write_text(
31+
json.dumps(body, ensure_ascii=False, indent=1), encoding="utf-8")
32+
except OSError: # 磁盘只读/满:诊断失败绝不能影响请求
33+
return
34+
stale = sorted(dump_dir.glob("*.json"))[:-DUMP_KEEP_FILES]
35+
for path in stale:
36+
with contextlib.suppress(OSError):
37+
path.unlink()
1438

1539

1640
def create_router(services: Services) -> APIRouter:
@@ -20,16 +44,16 @@ def create_router(services: Services) -> APIRouter:
2044

2145
@router.post("/v1/chat/completions")
2246
async def chat_completions(request: Request, user: str = Depends(api_key_user)):
23-
body = await request.json()
47+
body = await read_json_body(request)
2448
if settings.dump_request_bodies:
25-
dump_dir = Path(settings.data_dir) / "dumps"
26-
dump_dir.mkdir(parents=True, exist_ok=True)
27-
(dump_dir / f"{int(time.time() * 1000)}.json").write_text(
28-
json.dumps(body, ensure_ascii=False, indent=1), encoding="utf-8")
49+
dump_request_body(Path(settings.data_dir), body)
2950
chat_request = parse_chat_request(body)
3051
if chat_request.stream:
31-
return StreamingResponse(executor.stream(chat_request, username=user),
32-
media_type="text/event-stream")
52+
# 前置校验:在 200 响应头发出前拒绝不可能成功的请求
53+
executor.preflight(chat_request)
54+
return StreamingResponse(
55+
with_keepalive(executor.stream_guarded(chat_request, username=user)),
56+
media_type="text/event-stream")
3357
return JSONResponse(await executor.complete(chat_request, username=user))
3458

3559
return router

‎src/api/deps.py‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010

11+
import json
1112
from collections.abc import Callable
1213
from dataclasses import dataclass, field
1314
from typing import Any
@@ -18,6 +19,7 @@
1819
from ..auth.rbac import Principal, UnauthorizedError
1920
from ..auth.session import verify_session_token
2021
from ..auth.throttle import LoginThrottle
22+
from ..compat.openai.request import InvalidRequest
2123
from ..config import Settings
2224
from ..db.repo import ApiKeyRepository, CredentialRepository
2325
from ..engine.executor import Executor
@@ -49,18 +51,25 @@ class Services:
4951
# 模型列表缓存:provider_id → {小写模型名: Model}(含元数据)。
5052
# list_models 成功时更新,某上游拉取失败时用缓存兜底(v1/models 稳定返回)。
5153
model_list_cache: dict[str, dict[str, Any]] = field(default_factory=dict)
54+
# 上次成功拉取时间(monotonic):TTL 内的请求直接用缓存,
55+
# 避免客户端频繁调 /v1/models 时上游被打成大。
56+
model_list_fetched_at: dict[str, float] = field(default_factory=dict)
5257

5358

5459
def get_services(request: Request) -> Services:
5560
return request.app.state.services
5661

5762

5863
async def principal_from_request(request: Request) -> Principal:
59-
"""会话 Cookie → Principal(管理台内部端点)。"""
64+
"""会话 Cookie → Principal(管理台内部端点)。
65+
66+
除签名与过期外还要确认用户仍存在于 users.txt:删除用户后旧会话
67+
最长还能再用 12 小时,属于权限撤销漏洞(cookie 是无状态签名)。
68+
"""
6069
services = get_services(request)
6170
token = request.cookies.get(SESSION_COOKIE, "")
6271
username = verify_session_token(token, services.settings.app_secret)
63-
if not username:
72+
if not username or not services.users.has(username):
6473
raise UnauthorizedError("session missing or expired")
6574
return Principal(username=username,
6675
is_admin=services.settings.is_admin(username))
@@ -74,11 +83,25 @@ async def api_key_user(request: Request) -> str:
7483
if not header.lower().startswith(prefix.lower()):
7584
raise UnauthorizedError("missing api key")
7685
username = services.api_keys.verify(header[len(prefix):].strip())
77-
if not username:
86+
# 用户被删除后旧 Key 永久有效,必须同样校验
87+
if not username or not services.users.has(username):
7888
raise UnauthorizedError("invalid api key")
7989
return username
8090

8191

8292
async def csrf_protected(request: Request) -> None:
8393
"""写操作 CSRF 校验(仅对带会话 cookie 的请求生效)。"""
8494
check_csrf(request)
95+
96+
97+
async def read_json_body(request: Request) -> Any:
98+
"""读取并解析 JSON 请求体;非法 JSON 归一为 400 而不是 500。
99+
100+
FastAPI 把 `payload: dict` 声明在签名里时,解析失败会返回 422;
101+
但本项目的 /v1 与 playground 走原始 Request(需要透传任意字段),
102+
裸调 `request.json()` 会让 json.JSONDecodeError 冒泡成 500。
103+
"""
104+
try:
105+
return await request.json()
106+
except (json.JSONDecodeError, UnicodeDecodeError) as error:
107+
raise InvalidRequest("request body is not valid JSON") from error

0 commit comments

Comments
 (0)