11 KiB
11 KiB
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.md4.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 登录不被接受」专测 |
二、做对的(勿在修补中回退)
- 生成编排严格照 4.1:
resolve→能力校验→定价→precharge→调上游→mark_success / refund。任一上游异常先refund_call_points再抛;错误码齐(insufficient_points→402、no_pricing_rule→400、model_not_allowed→400、upstream_error→502)。所有分支(余额不足不调上游、能力不符不扣点、上游失败退点)都有测试。 - 鉴权隔离干净:
ExternalApiView(Key-only)与PortalSessionApiView(Session)分离;Key 用sha256查 +hmac.compare_digest定长比对;禁用 Key/User→403、缺失/无效/畸形头→401,全部有测试。permission_denied覆写把认证器缺失翻成 401(而非 403)。 - 支付回调防护到位:
@csrf_exempt+ 无鉴权(网关调用)+ 验签前置;mock 是真验签(HMAC-SHA256 +compare_digest,不是跳过校验);mock/真实 SDK 双路径由PAYMENT_CALLBACK_MODE切换,SDK 惰性 import 不影响未装 SDK 时启动;应答格式对(微信{code:SUCCESS}、支付宝纯文本success)。 - 入账逻辑符合 4.2:锁订单行→幂等→pay_method 校验→金额校验→用锁定
points_granted→锁钱包入账写RECHARGE流水(带ref_order_id)→置 PAID + txn_no + paid_at。并发重复回调经订单行锁串行化,第二次见 PAID 空操作。 - Portal 会话接口自动带 CSRF:
RechargeCreateView走SessionAuthentication,DRF 对会话请求强制 CSRF;有「真实会话客户端强制 CSRF」专测。与 Key 接口的无 CSRF(靠签名)清晰分工。 parameters透传安全:生成层把parameters交给 provider,Phase 1 T-105 的白名单在 provider 层拦截核心/计费字段覆盖——这条链路是安全的。- 存储与留痕:生成图片用
uuid4文件名(无路径穿越);CallRecord不存原始 base64(专测does_not_store_raw_base64);下单 CHECK 约束amount/exchange_rate/points_granted > 0。 - 测试覆盖为四阶段最全:鉴权 8 + 余额 3 + 回调 5(验签/金额/幂等/csrf-exempt)+ 下单查单 6(含 CSRF 强制、owner-only、主动查单入账)+ 生成 9(含上游失败退点)+ billing recharge(幂等/金额/重复充值 DB 约束)。
- 向后兼容:
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)。 - 怎么改:
- 只允许
http/https; - 解析主机→IP,拒绝私有/回环/链路本地/保留网段(
ipaddress.ip_address(...).is_private/is_loopback/is_link_local/is_reserved),并对重定向后的最终地址与DNS 解析结果都校验(防 30x 绕过与 rebinding),或直接allow_redirects=False; - 加响应大小上限(如 stream + 累计字节阈值,超限即断)与显式连接/读取超时(读超时已有);
- 可选:加白名单域 / 关闭
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 登记,不遗失。 check0 issues、test全绿(建议一次完整套件)、init通过,证据入progress.md。