API 客户端响应处理

API 客户端响应处理

非浏览器客户端被拦截 / 挑战 / 限流时收到什么:X-Mo-Guard 头、JSON 结构、PoW 兜底与「配 API 画像免挑战」。

墨守拦截或挑战一个请求时,通过 HTTP 状态码 + X-Mo-Guard 响应头 + 响应体告诉你发生了什么。浏览器会收到好看的 HTML 页;非浏览器客户端(Accept 不含 text/html)一律收到机器可读的 JSON,方便在代码里分支处理。这一页写给微信小程序、App、fetch / XHR、服务端调用的开发者。

#一眼判断:X-Mo-Guard 响应头

只要响应带了 X-Mo-Guard 头,就说明是 WAF 的处置,不是你后端返回的。先看有没有这个头,有就按下表处理;没有就是你后端的正常响应。

状态码`X-Mo-Guard`含义客户端建议
403blocked命中攻击检测(SQLi / XSS / RCE…)提示「请求被安全策略拦截」,把 ref 一并显示
403deniedIP 黑名单 / 地理围栏 / UA 黑名单同上
403bot-blocked判定为自动化 / 异常客户端,或签名校验未通过同上;若是正常小程序,去配 API 画像
413body-too-large请求体超限减小上传体积
401challenge-api需要人机验证(API 模式)见下文,或配 API 画像直接免掉
429rate-limited触发限流 / CC 防御退避重试,有 Retry-After 就按它来
503overloaded / waiting-room过载卸载 / 排队中稍后重试

#拦截响应(403)的 JSON 结构

{
  "error":  "request_blocked",
  "action": "blocked",              // blocked | denied | bot-blocked | body-too-large
  "reason": "检测到潜在的攻击特征",   // 中文原因,可直接展示
  "ref":    "A1B2C3D4E5",           // 事件编号,给管理员排查误拦用
  "by":     "mo-guard"
}

小程序里的处理示例:

wx.request({
  url, method, data,
  success(res) {
    const g = res.header['X-Mo-Guard'] || res.header['x-mo-guard']
    if (g && res.statusCode === 403) {
      const d = res.data || {}
      wx.showModal({
        title: '访问被安全网关拦截',
        content: (d.reason || '请求被拦截') + ',事件编号:' + (d.ref || '-'),
        showCancel: false,
      })
      return
    }
    // …正常业务处理…
  }
})
ref 是这次事件的编号。让用户把它报给管理员,就能在 攻击日志 / 访问日志 里精确定位这条记录,判断是不是误拦。

#人机验证(401 challenge-api)

收到 401 + X-Mo-Guard: challenge-api 时,响应形如:

HTTP/1.1 401 Unauthorized
X-Mo-Guard: challenge-api
WWW-Authenticate: MoGuardPoW realm="bot", challenge="<token>", difficulty=16
Content-Type: application/json

{
  "error": "bot_challenge_required",
  "algorithm": "sha256-leading-zero-bits",
  "challenge": "<token>",
  "difficulty": 16,
  "verify_url": "/__moguard/verify"
}

方案 A(强烈推荐):配 API 画像,根本不被挑战

小程序解 PoW 很麻烦。正确做法是让它压根不被挑战——在控制台配 API 画像,见下一节。配好后合法请求直接放行,客户端不需要写任何挑战处理代码。

方案 B:自行解工作量证明

  1. 1
    取参数从响应体拿 challengedifficultyverify_url
  2. 2
    求解从 0 开始枚举整数 answer,直到 sha256(challenge + ":" + answer)二进制前导零位数 ≥ difficulty(默认 16,平均约 6.5 万次哈希)。
  3. 3
    提交POST {verify_url},body {"c": challenge, "answer": "<answer>"};成功返回 200 {"ok":"true","pass":"<pass>"}
  4. 4
    带上通行证之后每个请求都带 X-Moguard-Pass: <pass> 头即可放行(默认有效期 30 分钟,过期后再收到 401 时重解)。

失败返回:400 格式错 / 403 解错或过期 / 409 重放(同一个 challenge 只能用一次)。

Python 兜底求解示例
import hashlib

def _leading_zero_bits(digest: bytes) -> int:
    bits = 0
    for byte in digest:
        if byte == 0:
            bits += 8
            continue
        for m in range(7, -1, -1):     # 遇到第一个 1 bit 即停
            if byte & (1 << m):
                return bits
            bits += 1
        break
    return bits

def solve_pow(challenge: str, difficulty: int) -> str:
    n = 0
    while True:
        h = hashlib.sha256(f"{challenge}:{n}".encode()).digest()
        if _leading_zero_bits(h) >= difficulty:
            return str(n)
        n += 1
JS SDK 已内置整套 PoW 兜底与 pass 复用,JS 客户端无需自己写。纯机器对接(服务端↔服务端)通常只需签名,很少会被挑战。

#彻底免挑战:给小程序配 API 画像

控制台 → 人机验证 → API 画像 → 添加

字段填什么
路由前缀小程序实际调用的 API 前缀,如 /api/
Referer 域servicewechat.com(微信强制设置、不可伪造)
UA 必含留空(小程序 UA 不稳定)
漂移处置挑战(或拦截)

含义:该前缀下带 servicewechat.com Referer 的就是真小程序 → 直接放行,不评分、不挑战;不符合的(浏览器直调、Postman 伪造)→ 按处置挑战或拦截。同一个前缀可以配多条画像,命中任意一条即算合法客户端——比如小程序一条、原生 App 一条。

画像不等于防重放画像挡的是「换个客户端来调」,挡不住原样重放。要防抓包重放,请叠加 API 请求签名

#给客户端的统一处理骨架

function handleWafResponse(res) {
  const g = res.header['X-Mo-Guard'] || res.header['x-mo-guard'] || ''
  if (!g) return false                 // 非 WAF 响应,交给正常业务
  switch (res.statusCode) {
    case 401: /* challenge-api:配了 API 画像就不会走到这;否则解 PoW */ break
    case 403: /* 被拦截:展示 res.data.reason + res.data.ref */ break
    case 413: /* 体积超限 */ break
    case 429: /* 限流:退避后重试 */ break
    case 503: /* 过载或排队:稍后重试 */ break
  }
  return true                          // 已由 WAF 处置
}
  • 判断是否 WAF 处置,只看 X-Mo-Guard 头是否存在,别去猜状态码。
  • 403 的 body 是 JSON,直接取 reason / ref 展示。
  • 小程序优先配 API 画像免挑战,而不是在前端解 PoW。
  • 别把 401 一律当成「登录过期」——先看 X-Mo-Guard 头,否则会把用户莫名其妙踢下线。

相关:API 请求签名与 SDK · 人机验证 · 访客页面