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

97 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`。