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

11 KiB
Raw Blame History

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 的 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。