XD-OP-09 · DOC

安全红线

签名只认规范串,私钥只留在自己的服务器;来源 IP、密钥与日志各有硬边界。本篇把不能做的事逐条列清。

小豆支付开放平台是面向已有自有系统的商家的支付接入服务:通过服务器对服务器的接口,把支付宝与微信收款接进商家自己的系统,并以签名事件通知回传支付状态;资金由持牌支付公司收付结算。个人也能申请:主体类型选「个人」,身份证 + 本人银行卡即可完成商户进件。央行持牌机构清算,安全可靠的个人和企业支付接口。

这一篇是必读:下面每一条都有过真实事故。带 ❌ 的条目属于接入红线,违反会被平台风控处置。

1. 密钥纪律

  • ✅ RSA2 密钥对在你自己的服务器上生成;公钥上传到门户,私钥留在本地受控存储。
  • ❌ 私钥永不上传给任何人(包括平台、包括「帮你调试」的人)。
  • ❌ 私钥永不进浏览器/小程序/App 前端:签名只在你的服务端做。
  • ❌ 私钥不进代码仓库、不进 CI 日志、不进镜像层。用密钥管理服务或受权限保护的凭证文件。
  • ❌ 不把签名材料写进日志(签名值、完整请求体、规范串都不行)。排障只需要 requestId。
  • 一个应用一套密钥;不要把同一套密钥复制到多个应用或多个环境。

2. 请求签名(XD-Signature-v1)

六个头:X-XD-App-Id、X-XD-Timestamp、X-XD-Nonce、X-XD-Key-Id、X-XD-Request-Id、X-XD-Sign。签名值 Base64(RSA-SHA256)。

待签名串固定 11 行,用 \n 连接:

XD-Signature-v1
<METHOD>
<CANONICAL_PATH>
<CANONICAL_QUERY>
<TIMESTAMP>
<NONCE>
<APP_ID>
<KEY_ID>
<REQUEST_ID>
<CONTENT_TYPE>
<SHA256_HEX(rawBody)>
位置规则(逐条都对,错一条就 401)
METHOD大写 ASCII(GET/POST)
CANONICAL_PATH绝对路径(不含 host),URL 解码恰好一次;不得含 ..、重复斜杠、反斜杠、控制字符
CANONICAL_QUERY键值各自解码一次后按 RFC3986 重编码;键名排序(重复键保留原相对顺序);无 query 时该行为空行
TIMESTAMP秒级 epoch(强校验时间窗,服务器需 NTP 对时)
NONCE一次性随机串;同一 nonce 复用会被判重放(16–64 位可见 ASCII,详见 API 参考 · 请求头)
APP_ID / KEY_ID / REQUEST_ID与请求头逐字一致;三者与 NONCE、TIMESTAMP、SIGN 的长度与字符集约束见 API 参考 · 请求头(越界与缺失返回同一个 400,错误体不指出是哪一头;唯一例外:同名头重复会在 message 里点名该头)
CONTENT_TYPE小写并去掉 ; 参数(如 application/json; charset=utf-8 → application/json);没有带这个头时该行为空行(GET 且无请求体就是这种情况)——不要想当然写 application/json,那是另一个值,会得到 401
SHA256_HEX(rawBody)对原始请求体字节做 SHA-256,小写十六进制;无请求体时为空串的哈希

编码差异不会被平台「帮你归一」:任何编码不同都表现为不同的规范路径/查询 → 签名不符。先用 undefined(在公开仓与「下载接入包」内;不在「下载文档包」内)里的参考实现跑通,再改。

3. 响应验签(XD-Response-v1)

响应头:X-XD-Response-Sign、X-XD-Response-Key-Id、X-XD-Response-Timestamp、X-XD-Response-Nonce。待签名串 8 行:

XD-Response-v1
<STATUS>
<CONTENT_TYPE>
<REQUEST_ID>
<TIMESTAMP>
<NONCE>
<KEY_ID>
<SHA256_HEX(rawBody)>
  • CONTENT_TYPE 与请求侧同一套归一化规则:小写并去掉 ; 参数(响应头通常是 application/json; charset=utf-8,参与签名的是 application/json)。这一条写错会让每一个响应都验不过。
  • STATUS 是十进制 HTTP 状态码字符串;SHA256_HEX(rawBody) 是实际交付字节(不是重新序列化的 JSON)。
  • 不是每个响应都带这四个头:鉴权链之前的拒绝一律不带——来源 IP 拒绝(403)、请求体门卫拒绝(400/413)、六头形态非法(400)、验签失败(401)、nonce 重放(409)。所以正确做法是「四头齐备才强制验签,缺头按未签名响应处理并保留其 code/requestId 排障」,不要把「缺头」当篡改丢弃(那会让你在最需要错误码时看不到任何信息)。
  • 与请求签名不是同一套拼串,不要复用同一段代码。参考实现:undefined(在公开仓与「下载接入包」内;不在「下载文档包」内)。
  • 平台公钥经固定文档页与门户双渠道发布(文档页见 接入指南 · 平台公钥与指纹,含 PEM 正文;门户「应用凭证」页显示同一把钥的 kid 与指纹);不要在未验签的响应里取公钥。

4. 事件验签(XD-Webhook-v1)

见 webhook 验签。要点复述:7 行规范串、从原始字节算哈希、按 eventId 去重、业务幂等、返回体字节正好是 success。

5. 回调与跳转

  • ❌ 不信任任何未经签名校验的回调:验签失败一律丢弃(哪怕内容看起来对)。
  • ❌ 不要把回调当唯一真相:必须接查单作为补偿路径(重复/乱序/丢失都要能自愈)。
  • ✅ returnUrl 一经使用即被冻结;不要依赖「传新值改跳转目标」。
  • ✅ 回跳不是自动的:买家在收银页点「返回商家」才会带着一次性凭据回跳(该按钮只在同一页面会话里、首次观察到支付成功时提供;扫码支付(PC 出码 → 手机付款)不受此限)。买家不点、刷新过页面时不会发生回跳——终态仍只认查单与验签事件。
  • ❌ 不要把交付内容(卡密、下载链接、密码)放进 returnUrl 的 query——会被浏览器历史、日志、Referer 带走。交付只由你的服务端依据订单状态驱动。
  • ❌ 不得把 payUrl/qrCode 转发到第三方站点或第三方二维码服务(不得代理、不得改写、不得二次编码)。

6. 浏览器侧

  • 收银页是平台提供的页面:按 收银页对接 的用法使用即可;商家自有页面里不要自行实现终端分派逻辑。
  • ❌ 商家自有页面不得把平台返回的收款材料地址放进 localStorage/sessionStorage/Cookie。
  • ❌ 不引入第三方 CDN / 统计 / 字体到承载收款材料的页面(供应链面会被扩大,且可能泄露 Referer)。
  • 收银链必须走 HTTPS;平台侧收银页带 no-store / no-referrer 等安全头,商家侧页面也不要放宽这些约束。

7. 请求来源

  • 商家 API 校验来源 IP:出口 IP 需在门户白名单内。校验默认开启,可在门户「IP 白名单」页自助关闭/开启(关闭须短信二次验证,平台留痕)——关闭后平台不再做这一层校验,风险由你承担;白名单条目在关闭期间原样保留,重开是一键。
  • 开启状态下白名单不能为空:空清单等于所有调用必被拒,所以平台拒绝把校验开在空清单上(而不是让你把调用全打挂)——先加条目,再开校验。
  • 判定口径:平台看的是边缘节点实际观测到的对端地址(不是你机器上 ifconfig 看到的地址)。多出口 / NAT / 自建代理的场景下,请以实际发起连接的那个出口为准,并把全部可能出口(含灾备)都加进白名单。
  • 这个 403 不回显平台观测到的地址(防探测)。它有两条腿,处置方式不一样:
  1. 平台边缘腿(请求还没进到你的应用就被判掉):信封另带 details.reason,取值只有七种——PEER_NOT_TRUSTED(对端不是平台边缘,例如绕开边缘直连)、FORWARDED_HEADER_PRESENT / XFF_MISSING / XFF_CHAIN_ABNORMAL / XFF_LAST_INVALID / REAL_IP_MISSING / HEADER_CONFLICT(转发链形态不符)。这几种加白名单解决不了,属于你的出口路径或平台侧部署问题:带 requestId 与出口 IP 走官网反馈渠道。
  2. 应用白名单腿:你的出口 IP 不在门户白名单内。信封只有 code/message/requestId 三个字段(没有 details),处置=把该出口加进白名单(灾备 / 多可用区出口都要加)。
  • 自检方法:用生产出口发一笔最小合法请求(例如查一笔不存在的订单),拿到 403 说明来源没过,拿到 404 说明来源已通过。
  • ❌ 不要把请求从不受控的代理/办公网出口发出;来源判定失败一律 403,且不会因为签名正确而放行。

8. 遇到拒绝时的正确反应

拒绝❌ 错误反应✅ 正确反应
403 SOURCE_IP_REJECTED换代理/换出口硬试把出口 IP 加进门户白名单(若你已关闭来源校验,确认是否要开回来)
422 CHANNEL_UNAVAILABLE反复重试/换通道刷按 错误码与排障 判断是否为未放行通道,走官网反馈
429 RATE_LIMITED紧循环重试指数退避 + 复用 requestId(额度窗口见响应头 RateLimit-* / Retry-After)
503 GUARD_UNAVAILABLE改换路径绕过退避重试;503 是安全地拒绝,绕不过也不该绕

本表用简写便于阅读;信封里的 code 一律是全名(带 OPEN_API_ 前缀),逐字以 错误码与排障 的总表为准。按本表字面去比对 code 永远匹配不上。

9. 泄露应急

怀疑私钥泄露:立刻在门户轮换密钥(见 凭证与密钥管理),并检查是否有人用旧密钥发起过请求。轮换后旧密钥进入失效流程,不需要你停机。

内容源:open-platform/docs/09-security-redlines.md · 最后更新 2026-10-12 下载本文 .md 在 GitHub 查看