Files
cmhub/docs/phase-3-review.md
T

97 lines
11 KiB
Markdown
Raw Normal View History

2026-07-03 09:53:23 +08:00
# Phase 3 对外 API 与充值审核报告(T-301 ~ T-305)
> 审核人:Claude Code(全栈视角)|日期:2026-07-03|结论:**功能验收全部达标,测试覆盖极全;但发现 1 个上线前必须处理的安全问题(SSRF)**。
> Phase 3 是攻击面最大的一层(对外鉴权 + CSRF-exempt 回调 + 验签 + 幂等 + 资金入账)。逐条比对 `05-coding-rules.md` §8 与 `04-architecture.md` 4.1/4.2:鉴权隔离、回调验签、幂等入账、金额校验、失败退点**全部正确落地**,且有对应测试。**新增的对外风险是 `image_url` 服务端拉取的 SSRF**(P1-1)。
> 本文面向 codex 执行:每条修补项给出「症状 / 位置 / 怎么改 / 怎么验证」。修补任务见 [`06-tasks.md`](06-tasks.md) 的 **T-306**。
## 一、验收核对(功能全部达标)
| 任务 | 验收要点 | 结果 |
| --- | --- | --- |
| T-301 | Key 哈希比对定位 ApiKey→User;只挂 Key 认证不挂 Session;无效/缺失 401;用户或 Key 禁用 403 | ✅(含「web session 不被外部 API 接受」专测) |
| T-302 | 编排「别名解析+能力校验→预扣→调上游→成功确认/失败退点→写记录」;`parameters` 白名单透传;缺原图/能力错误翻 400;点数不足 402 | ✅ |
| T-303 | 余额=流水累加;含测试 | ✅ |
| T-304 | 两端点 `@csrf_exempt`;验签;同 `order_no` 幂等;金额校验;用锁定 `points_granted` 入账;主动查单兜底;幂等测试 | ✅ |
| T-305 | weixin(native)/alipay(precreate) 下单锁汇率/预计点数→取 code_url/qr_code→轮询 status;缺密钥 mock | ✅ |
### §8 / 4.2 资金安全逐条核对
| 约束 | 落地 | 证据 |
| --- | --- | --- |
| 充值幂等(同 order_no 只入账一次) | ✅ | `apply_recharge_payment` 事务内 `select_for_update` 锁订单行,已 PAID 直接幂等返回 `applied=False` |
| 回调验签,失败不入账并留痕 | ✅ | mock 用 HMAC-SHA256 + `compare_digest`,真实用 SDK;验签在入账前,失败 `logger.warning` + 拒绝;均有专测 |
| 回调 `@csrf_exempt` + 主动查单兜底 | ✅ | 两回调视图 `@method_decorator(csrf_exempt)`、`authentication_classes=()`;`query_and_apply_recharge_payment` 复用同一入账路径 |
| 回调金额与订单金额一致 | ✅ | `order_amount != callback_amount → RechargeAmountMismatchError`;有「金额不一致不入账」专测 |
| 用订单锁定的 `points_granted` 入账、不重算汇率 | ✅ | 下单时 `quote_recharge_points` 锁 `exchange_rate`+`points_granted` 到订单;入账直接用订单值 |
| 对外只认 Key 不认 Session | ✅ | `ExternalApiView` 仅挂 `ApiKeyAuthentication`;有「session 登录不被接受」专测 |
## 二、做对的(勿在修补中回退)
1. **生成编排严格照 4.1**:`resolve→能力校验→定价→precharge→调上游→mark_success / refund`。任一上游异常先 `refund_call_points` 再抛;错误码齐(`insufficient_points`→402、`no_pricing_rule`→400、`model_not_allowed`→400、`upstream_error`→502)。所有分支(余额不足不调上游、能力不符不扣点、上游失败退点)都有测试。
2. **鉴权隔离干净**:`ExternalApiView`(Key-only)与 `PortalSessionApiView`(Session)分离;Key 用 `sha256` 查 + `hmac.compare_digest` 定长比对;禁用 Key/User→403、缺失/无效/畸形头→401,全部有测试。`permission_denied` 覆写把认证器缺失翻成 401(而非 403)。
3. **支付回调防护到位**:`@csrf_exempt` + 无鉴权(网关调用)+ 验签前置;**mock 是真验签**(HMAC-SHA256 + `compare_digest`,不是跳过校验);mock/真实 SDK 双路径由 `PAYMENT_CALLBACK_MODE` 切换,SDK 惰性 import 不影响未装 SDK 时启动;应答格式对(微信 `{code:SUCCESS}`、支付宝纯文本 `success`)。
4. **入账逻辑符合 4.2**:锁订单行→幂等→pay_method 校验→金额校验→用锁定 `points_granted`→锁钱包入账写 `RECHARGE` 流水(带 `ref_order_id`)→置 PAID + txn_no + paid_at。并发重复回调经订单行锁串行化,第二次见 PAID 空操作。
5. **Portal 会话接口自动带 CSRF**:`RechargeCreateView` 走 `SessionAuthentication`,DRF 对会话请求强制 CSRF;有「真实会话客户端强制 CSRF」专测。与 Key 接口的无 CSRF(靠签名)清晰分工。
6. **`parameters` 透传安全**:生成层把 `parameters` 交给 provider,Phase 1 T-105 的白名单在 provider 层拦截核心/计费字段覆盖——这条链路是安全的。
7. **存储与留痕**:生成图片用 `uuid4` 文件名(无路径穿越);`CallRecord` 不存原始 base64(专测 `does_not_store_raw_base64`);下单 CHECK 约束 `amount/exchange_rate/points_granted > 0`。
8. **测试覆盖为四阶段最全**:鉴权 8 + 余额 3 + 回调 5(验签/金额/幂等/csrf-exempt)+ 下单查单 6(含 CSRF 强制、owner-only、主动查单入账)+ 生成 9(含上游失败退点)+ billing recharge(幂等/金额/重复充值 DB 约束)。
9. **向后兼容**:`aliases.resolve_alias` 保留(委托给新的 `resolve_model_alias`),Phase 1 的 smoke 命令未被破坏。
## 三、优化建议 / 修补清单
### P1 · 上线前必须处理(安全)
#### P1-1 SSRF:`image_url` 让服务端拉取调用方任意 URL
- **症状**:`generate_title/image` 支持传 `image_url`,`load_image_input → download_image_input(url)` 会用服务端发起 GET。当前仅 `URLField` 校验格式 + 事后 content-type 检查,**没有**内网/回环/链路本地拦截。自助注册用户可让服务器请求 `http://169.254.169.254/latest/meta-data/`(云元数据)、`http://127.0.0.1:3306`、内网服务等。**更糟的是**:`load_image_input` 在 `precharge` **之前**执行,所以 **0 点数用户也能无成本触发** SSRF。另外无响应大小上限(可拉超大文件→内存耗尽 DoS)、无重定向限制(可 30x 绕过校验 / DNS rebinding)。
- **位置**:`apps/api/generation.py:download_image_input`(及 `load_image_input`)。
- **怎么改**:
1. 只允许 `http`/`https`;
2. 解析主机→IP,**拒绝私有/回环/链路本地/保留网段**(`ipaddress.ip_address(...).is_private/is_loopback/is_link_local/is_reserved`),并对**重定向后的最终地址**与**DNS 解析结果**都校验(防 30x 绕过与 rebinding),或直接 `allow_redirects=False`;
3. 加响应大小上限(如 stream + 累计字节阈值,超限即断)与显式连接/读取超时(读超时已有);
4. 可选:加白名单域 / 关闭 `image_url` 只保留 `image_base64`(若产品不需要远程图)。
- **验证**:新增测试——`image_url=http://127.0.0.1/`、`http://169.254.169.254/` 一律 400 且不发起真实内网请求(可 mock 解析层断言被拦);超大响应被截断;30x 跳到内网被拒。
### P2 · 建议处理
#### P2-1 无全局 DRF 安全默认,靠每视图显式设置(脚枪)
- **症状**:`settings.py` 无 `REST_FRAMEWORK` 块,DRF 默认 `DEFAULT_AUTHENTICATION_CLASSES = [Session, Basic]`。当前外部视图都显式覆盖为 `ApiKeyAuthentication`(安全),但**将来任何新增的 `APIView` 若忘了设置,会默默继承 Session+Basic 默认**——对「只认 Key」的对外服务是隐患。
- **位置**:`config/settings.py`。
- **怎么改**:显式配置 `REST_FRAMEWORK = {"DEFAULT_AUTHENTICATION_CLASSES": [], "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"]}`(默认空认证,强制每视图显式声明),Portal/外部视图各自 opt-in;顺带把 `DEFAULT_THROTTLE_*` 一并配上(见 P2-2)。
- **验证**:加一条测试——一个不显式设 `authentication_classes` 的 APIView 默认不接受 session。
#### P2-2 生成 / 鉴权端点无限流
- **症状**:付费且调上游的 `generate` 接口、以及 401 认证失败**无任何节流**。存在刷点/放大上游成本/Key 枚举探测的空间。Backlog 有「限流细化」,但**生成端点建议上线前就加**。
- **位置**:`config/settings.py` + `apps/api/views.py`。
- **怎么改**:配 DRF `ScopedRateThrottle`,给生成接口一个较严 scope(按用户/Key),认证失败按 IP 限流。
- **验证**:超过阈值返回 429。
#### P2-3 充值金额无上限
- **症状**:`RechargeCreateRequestSerializer.amount` 只有 `min_value=0.01`,无上限(`max_digits=12` 理论到百亿)。易因误操作/异常产生超大额 pending 订单。
- **位置**:`apps/api/serializers.py`。
- **怎么改**:加单笔 `max_value`(按业务定,如 100000 元)。
- **验证**:超上限返回 400。
### P3 · 登记 / 后续
- **充值流水 DB 兜底已存在,非待办**:T-304 已在 `billing.0004_rechargeorder_and_more` 落地 `unique(ref_order_id, change_type)`,并有重复充值流水 `IntegrityError` 测试;后续修补 T-306 时不要重复增加同类迁移。
- **生成图片以 `/media` 公网 URL 暴露**:`uuid4` 不可猜但无 per-user 访问控制。MVP 可接受;敏感场景需签名 URL / 鉴权代理。
- **完整测试套件在 T-305 后未再单次全绿**:受远程 MySQL `43.128.3.240` 不稳定影响,靠分 app 子集通过;T-302 时有 60 tests 单次全绿记录。建议 DB 稳定后补一次 T-305 后的完整全绿。
- **过期订单不自动置 `expired`**:属有意设计(避免误挡延迟到达的真实回调),二维码过期仅前端提示,入账仍以回调/查单为准。合理,登记备忘。
## 四、说明:未本地复跑
审核机(WSL)无 `python3.12`,**未本地复跑**。本报告基于静态审查(`api/authentication`、`generation`、`views`、`serializers`、`storage`、`urls`;`billing/services`、`payment_gateways`、`models`、`migrations`;`settings`;两 app `tests`)+ codex 执行记录(T-301 52 tests / T-302 60 tests 均单次全绿;T-303~305 分 app 子集通过,完整套件受远程 MySQL 连接稳定性阻塞,失败点为连接超时非断言失败)。
T-306 处理后,请重跑 `check`/`test`/`init` 并把证据记入 `progress.md`(`06-tasks.md` 使用规则第 5 条)。
## 五、T-306 完成定义
- **P1-1** 已处理:`download_image_input` 加协议白名单 + 私有/回环/链路本地网段拦截(含重定向后地址)+ 响应大小上限;含「内网 URL/元数据地址被拒、超大响应被截断」测试。
- **P2-1/P2-2/P2-3** 已处理:全局 `REST_FRAMEWORK` 安全默认 + 生成/认证限流 + 充值金额上限;各含测试。
- **P3** 各项已在 `06-tasks.md` 对应任务或 Backlog 登记,不遗失。
- `check` 0 issues、`test` 全绿(建议一次完整套件)、`init` 通过,证据入 `progress.md`。