开发者文档

waf.bi 开发者接入指南

了解人机挑战机制、响应码含义,以及如何在 Android、iOS、Flutter、Python、Node.js 等各类客户端中正确处理安全挑战。

接入说明

本文档面向接入 waf.bi CDN 的开发者,说明不同类型客户端在遇到安全验证时的正确处理方式,以及各语言/平台的集成示例代码。

💡
当您的站点开启了安全防护后,边缘节点在必要时会向客户端下发人机验证挑战。浏览器客户端会自动处理,App / SDK 客户端需要按照本文档进行适配。

浏览器客户端

标准浏览器(Chrome、Firefox、Safari、Edge 等)无需任何额外处理,验证流程由浏览器自动完成,用户通过后即可正常访问,体验无感知。

App / SDK 客户端

集成原理

原生 App(Android、iOS、Flutter 等)使用 HTTP 客户端库(OkHttp、URLSession、Dio 等)发起请求,无法直接执行 JavaScript。当这些客户端触发人机挑战时,边缘节点会返回 JSON 格式的 403 响应,携带挑战 URL。App 需要:

  1. 检测响应头 X-Challenge-Required: true
  2. 从响应头 X-Challenge-URL 获取挑战地址
  3. 在应用内弹出 WebView 加载该 URL(浏览器引擎会自动执行验证流程)
  4. 用户在 WebView 内完成人机交互验证
  5. 验证通过后,从 WebView Cookie Store 提取安全凭证 Cookie
  6. 后续请求携带该 Cookie,边缘节点验证凭证后放行
⚠️
挑战必须由真实用户在 WebView 中完成,系统会验证用户交互的真实性。自动化脚本无法通过挑战。
挑战响应格式
HTTP 403 响应
HTTP/1.1 403 Forbidden
Content-Type: application/json
X-Challenge-Required: true
X-Challenge-URL: https://your-domain.com/__sv_challenge?next=/api/user/info

{
  "code": 403,
  "message": "security challenge required",
  "challenge": true,
  "challenge_url": "https://your-domain.com/__sv_challenge?next=/api/user/info"
}

Android — Kotlin (OkHttp)

推荐使用 OkHttp 拦截器 + WebViewActivity 的方式处理挑战。

ChallengeInterceptor.kt
import okhttp3.*
import android.app.Activity
import android.webkit.*
import kotlinx.coroutines.*

/**
 * OkHttp 拦截器:自动检测人机挑战并弹出 WebView 完成验证
 */
class ChallengeInterceptor(private val activity: Activity) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        val response = chain.proceed(request)

        // 检测是否需要人机挑战
        if (response.code == 403 &&
            response.header("X-Challenge-Required") == "true") {

            val challengeUrl = response.header("X-Challenge-URL")
                ?: response.body?.string()?.let { parseJsonChallengeUrl(it) }
                ?: return response

            response.close()

            // 在主线程打开 WebView,阻塞等待完成
            val cookie = runBlocking { showChallenge(challengeUrl) }

            return if (cookie != null) {
                // 携带验证 Cookie 重试原请求
                val newRequest = request.newBuilder()
                    .header("Cookie", "__sv_tk=$cookie")
                    .build()
                chain.proceed(newRequest)
            } else {
                // 用户取消验证,返回 403
                chain.proceed(request)
            }
        }
        return response
    }

    private suspend fun showChallenge(url: String): String? =
        suspendCancellableCoroutine { cont ->
            activity.runOnUiThread {
                val dialog = android.app.Dialog(activity, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
                val webView = WebView(activity).apply {
                    settings.apply {
                        javaScriptEnabled = true
                        domStorageEnabled = true
                    }
                    webViewClient = object : WebViewClient() {
                        override fun onPageFinished(view: WebView?, loadedUrl: String?) {
                            // 读取 Cookie
                            val cookies = CookieManager.getInstance().getCookie(loadedUrl)
                            val token = cookies?.split(";")
                                ?.firstOrNull { it.trim().startsWith("__sv_tk=") }
                                ?.split("=")?.getOrNull(1)?.trim()
                            if (token != null) {
                                dialog.dismiss()
                                if (cont.isActive) cont.resume(token) {}
                            }
                        }
                    }
                    loadUrl(url)
                }
                dialog.setContentView(webView)
                dialog.setOnCancelListener {
                    if (cont.isActive) cont.resume(null) {}
                }
                dialog.show()
            }
        }

    private fun parseJsonChallengeUrl(body: String): String? {
        return try {
            org.json.JSONObject(body).optString("challenge_url").takeIf { it.isNotEmpty() }
        } catch (e: Exception) { null }
    }
}

// ── 使用示例 ──────────────────────────────────────────────
val client = OkHttpClient.Builder()
    .addInterceptor(ChallengeInterceptor(this)) // this = Activity
    .build()

Android — Java (OkHttp)

ChallengeInterceptor.java
import okhttp3.*;
import android.app.*;
import android.webkit.*;

public class ChallengeInterceptor implements Interceptor {
    private final Activity activity;

    public ChallengeInterceptor(Activity activity) {
        this.activity = activity;
    }

    @Override
    public Response intercept(Chain chain) throws IOException {
        Request request = chain.request();
        Response response = chain.proceed(request);

        if (response.code() == 403 &&
            "true".equals(response.header("X-Challenge-Required"))) {

            String challengeUrl = response.header("X-Challenge-URL");
            if (challengeUrl == null) return response;
            response.close();

            // 使用 CountDownLatch 等待 WebView 完成
            CountDownLatch latch = new CountDownLatch(1);
            String[] cookieHolder = {null};

            activity.runOnUiThread(() -> {
                Dialog dialog = new Dialog(activity,
                    android.R.style.Theme_Black_NoTitleBar_Fullscreen);
                WebView webView = new WebView(activity);
                webView.getSettings().setJavaScriptEnabled(true);
                webView.getSettings().setDomStorageEnabled(true);
                webView.setWebViewClient(new WebViewClient() {
                    @Override
                    public void onPageFinished(WebView view, String url) {
                        String cookies = CookieManager.getInstance().getCookie(url);
                        if (cookies != null && cookies.contains("__sv_tk=")) {
                            for (String part : cookies.split(";")) {
                                if (part.trim().startsWith("__sv_tk=")) {
                                    cookieHolder[0] = part.split("=")[1].trim();
                                    dialog.dismiss();
                                    latch.countDown();
                                    break;
                                }
                            }
                        }
                    }
                });
                webView.loadUrl(challengeUrl);
                dialog.setContentView(webView);
                dialog.setOnCancelListener(d -> latch.countDown());
                dialog.show();
            });

            try { latch.await(5, TimeUnit.MINUTES); } catch (InterruptedException ignored) {}

            if (cookieHolder[0] != null) {
                Request newRequest = request.newBuilder()
                    .header("Cookie", "__sv_tk=" + cookieHolder[0])
                    .build();
                return chain.proceed(newRequest);
            }
        }
        return response;
    }
}

iOS — Swift (URLSession + WKWebView)

ChallengeHandler.swift
import UIKit
import WebKit

/// 人机挑战处理器(URLSession + WKWebView)
class ChallengeHandler: NSObject {

    static let shared = ChallengeHandler()

    /// 发起请求,自动处理挑战
    func request(url: URL, completion: @escaping (Data?, URLResponse?, Error?) -> Void) {
        var req = URLRequest(url: url)
        // 携带已有的挑战 Cookie
        if let cookie = savedChallengeCookie() {
            req.setValue("__sv_tk=(cookie)", forHTTPHeaderField: "Cookie")
        }

        URLSession.shared.dataTask(with: req) { data, response, error in
            guard let httpResponse = response as? HTTPURLResponse,
                  httpResponse.statusCode == 403,
                  httpResponse.value(forHTTPHeaderField: "X-Challenge-Required") == "true",
                  let challengeUrl = httpResponse.value(forHTTPHeaderField: "X-Challenge-URL")
            else {
                completion(data, response, error)
                return
            }

            // 需要弹出挑战 WebView
            DispatchQueue.main.async {
                self.presentChallenge(url: URL(string: challengeUrl)!) { token in
                    guard let token = token else {
                        completion(nil, nil, NSError(domain: "Challenge", code: -1))
                        return
                    }
                    // 携带 Cookie 重试
                    self.saveChallengeCookie(token)
                    var retryReq = URLRequest(url: url)
                    retryReq.setValue("__sv_tk=(token)", forHTTPHeaderField: "Cookie")
                    URLSession.shared.dataTask(with: retryReq, completionHandler: completion).resume()
                }
            }
        }.resume()
    }

    private func presentChallenge(url: URL, completion: @escaping (String?) -> Void) {
        let vc = ChallengeWebViewController(url: url, completion: completion)
        UIApplication.shared.keyWindow?.rootViewController?
            .present(vc, animated: true)
    }

    private func savedChallengeCookie() -> String? {
        UserDefaults.standard.string(forKey: "wafbi_sv_tk")
    }
    private func saveChallengeCookie(_ token: String) {
        UserDefaults.standard.set(token, forKey: "wafbi_sv_tk")
    }
}

// WebView 控制器
class ChallengeWebViewController: UIViewController, WKNavigationDelegate {
    private let url: URL
    private let completion: (String?) -> Void
    private var webView: WKWebView!

    init(url: URL, completion: @escaping (String?) -> Void) {
        self.url = url
        self.completion = completion
        super.init(nibName: nil, bundle: nil)
        modalPresentationStyle = .fullScreen
    }
    required init?(coder: NSCoder) { fatalError() }

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .black
        webView = WKWebView(frame: view.bounds)
        webView.navigationDelegate = self
        view.addSubview(webView)
        webView.load(URLRequest(url: url))
    }

    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        // 读取 Cookie
        WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
            if let token = cookies.first(where: { $0.name == "__sv_tk" })?.value {
                DispatchQueue.main.async {
                    self.dismiss(animated: true) { self.completion(token) }
                }
            }
        }
    }

    @objc func dismiss() { dismiss(animated: true) { self.completion(nil) } }
}

Flutter — Dart (Dio + WebView)

💡
需要添加依赖:diowebview_flutterwebview_cookie_manager
challenge_interceptor.dart
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

/// Dio 拦截器:自动处理人机挑战
class ChallengeInterceptor extends Interceptor {
  final BuildContext Function() contextBuilder;
  String? _cachedToken;

  ChallengeInterceptor({required this.contextBuilder});

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    // 携带已有的挑战凭证
    if (_cachedToken != null) {
      options.headers['Cookie'] = '__sv_tk=$_cachedToken';
    }
    handler.next(options);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    final response = err.response;
    if (response?.statusCode == 403 &&
        response?.headers.value('X-Challenge-Required') == 'true') {

      final challengeUrl = response?.headers.value('X-Challenge-URL');
      if (challengeUrl == null) { handler.next(err); return; }

      // 弹出挑战 WebView
      final token = await _showChallengeWebView(challengeUrl);
      if (token != null) {
        _cachedToken = token;
        // 携带 Cookie 重试
        final retryOptions = err.requestOptions;
        retryOptions.headers['Cookie'] = '__sv_tk=$token';
        try {
          final retryResponse = await Dio().fetch(retryOptions);
          handler.resolve(retryResponse);
          return;
        } catch (e) { /* 重试失败,正常抛出 */ }
      }
    }
    handler.next(err);
  }

  Future<String?> _showChallengeWebView(String url) async {
    final context = contextBuilder();
    return await showModalBottomSheet<String>(
      context: context,
      isScrollControlled: true,
      backgroundColor: Colors.black,
      builder: (ctx) => SizedBox(
        height: MediaQuery.of(ctx).size.height * 0.9,
        child: _ChallengeWebView(
          url: url,
          onCompleted: (token) => Navigator.pop(ctx, token),
        ),
      ),
    );
  }
}

class _ChallengeWebView extends StatefulWidget {
  final String url;
  final ValueChanged<String> onCompleted;
  const _ChallengeWebView({required this.url, required this.onCompleted});

  @override
  State<_ChallengeWebView> createState() => _ChallengeWebViewState();
}

class _ChallengeWebViewState extends State<_ChallengeWebView> {
  late final WebViewController _ctrl;

  @override
  void initState() {
    super.initState();
    _ctrl = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..setNavigationDelegate(NavigationDelegate(
        onPageFinished: (url) async {
          // 读取 Cookie
          final result = await _ctrl.runJavaScriptReturningResult(
            "document.cookie"
          );
          final cookieStr = result.toString();
          final match = RegExp(r'__sv_tk=([^;]+)').firstMatch(cookieStr);
          if (match != null) {
            widget.onCompleted(match.group(1)!);
          }
        },
      ))
      ..loadRequest(Uri.parse(widget.url));
  }

  @override
  Widget build(BuildContext context) => WebViewWidget(controller: _ctrl);
}

Python (requests)

⚠️
Python 脚本无法完成需要真实用户操作的人机挑战。以下示例适用于:检测到挑战时打印提示,引导用户用浏览器完成后手动提取 Cookie。
wafbi_client.py
import requests
import webbrowser

class WafBiSession(requests.Session):
    """
    自动检测 waf.bi 人机挑战并提示用户完成验证
    """
    def request(self, method, url, **kwargs):
        response = super().request(method, url, **kwargs)

        if (response.status_code == 403 and
                response.headers.get("X-Challenge-Required") == "true"):

            challenge_url = (
                response.headers.get("X-Challenge-URL") or
                response.json().get("challenge_url", "")
            )

            print("
[waf.bi] 需要完成人机验证")
            print(f"请在浏览器中打开以下链接完成验证:
{challenge_url}")
            webbrowser.open(challenge_url)

            token = input("
验证完成后,请输入浏览器 Cookie 中 __sv_tk 的值:").strip()
            if token:
                self.cookies.set("__sv_tk", token)
                return super().request(method, url, **kwargs)

        return response


# 使用示例
session = WafBiSession()
session.headers.update({
    "User-Agent": "MyApp/1.0",
    "Accept": "application/json",
})

response = session.get("https://your-domain.com/api/data")
print(response.json())

Node.js / TypeScript (Axios)

💡
服务端 Node.js 与 Python 类似——无法执行 JS Challenge。以下示例用于检测挑战并给出明确错误,方便上层业务处理。如需完整自动化,请使用 playwrightpuppeteer 控制真实浏览器。
wafbi-client.ts
import axios, { AxiosInstance, AxiosResponse } from 'axios'

export class ChallengeRequiredError extends Error {
  constructor(public challengeUrl: string) {
    super(`需要完成人机验证:${challengeUrl}`)
    this.name = 'ChallengeRequiredError'
  }
}

/**
 * 创建带有挑战检测的 Axios 实例
 */
export function createWafBiClient(baseURL: string): AxiosInstance {
  const client = axios.create({ baseURL, withCredentials: true })

  client.interceptors.response.use(
    (response) => response,
    (error) => {
      const res: AxiosResponse | undefined = error.response
      if (
        res?.status === 403 &&
        res.headers['x-challenge-required'] === 'true'
      ) {
        const challengeUrl =
          res.headers['x-challenge-url'] || res.data?.challenge_url || ''
        throw new ChallengeRequiredError(challengeUrl)
      }
      return Promise.reject(error)
    }
  )

  return client
}

// ── 使用示例 ──────────────────────────────────────────────
const api = createWafBiClient('https://your-domain.com')

async function fetchData() {
  try {
    const { data } = await api.get('/api/data')
    return data
  } catch (err) {
    if (err instanceof ChallengeRequiredError) {
      console.error('需要人机验证,请在浏览器打开:', err.challengeUrl)
      // 在浏览器环境下:window.open(err.challengeUrl)
      // 在 Electron 应用中:shell.openExternal(err.challengeUrl)
    }
    throw err
  }
}

Go (net/http)

client.go
package main

import (
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "net/http/cookiejar"
    "net/url"
)

// ErrChallengeRequired 表示需要人机验证
type ErrChallengeRequired struct {
    ChallengeURL string
}
func (e *ErrChallengeRequired) Error() string {
    return fmt.Sprintf("waf.bi: 需要人机验证,请访问 %s", e.ChallengeURL)
}

// WafBiClient 带挑战检测的 HTTP 客户端
type WafBiClient struct {
    http *http.Client
    jar  http.CookieJar
}

func NewWafBiClient() (*WafBiClient, error) {
    jar, err := cookiejar.New(nil)
    if err != nil {
        return nil, err
    }
    return &WafBiClient{
        http: &http.Client{Jar: jar},
        jar:  jar,
    }, nil
}

func (c *WafBiClient) SetChallengeCookie(rawURL, token string) error {
    u, err := url.Parse(rawURL)
    if err != nil {
        return err
    }
    c.jar.SetCookies(u, []*http.Cookie{{Name: "__sv_tk", Value: token, Path: "/"}})
    return nil
}

func (c *WafBiClient) Do(req *http.Request) (*http.Response, error) {
    resp, err := c.http.Do(req)
    if err != nil {
        return nil, err
    }
    if resp.StatusCode == 403 && resp.Header.Get("X-Challenge-Required") == "true" {
        challengeURL := resp.Header.Get("X-Challenge-URL")
        if challengeURL == "" {
            var body struct{ ChallengeURL string `json:"challenge_url"` }
            if data, _ := io.ReadAll(resp.Body); len(data) > 0 {
                json.Unmarshal(data, &body)
                challengeURL = body.ChallengeURL
            }
        }
        resp.Body.Close()
        return nil, &ErrChallengeRequired{ChallengeURL: challengeURL}
    }
    return resp, nil
}

// 使用示例
func main() {
    client, _ := NewWafBiClient()

    req, _ := http.NewRequest("GET", "https://your-domain.com/api/data", nil)
    resp, err := client.Do(req)
    if err != nil {
        var challengeErr *ErrChallengeRequired
        if errors.As(err, &challengeErr) {
            fmt.Println("请在浏览器完成验证:", challengeErr.ChallengeURL)
            // 引导用户完成后,通过 client.SetChallengeCookie() 注入 Cookie
        }
        return
    }
    defer resp.Body.Close()
    io.Copy(os.Stdout, resp.Body)
}

Java (HttpClient / OkHttp)

WafBiClient.java
import okhttp3.*;
import org.json.*;

/**
 * Java / OkHttp 接入示例(适用于桌面应用、后端服务)
 */
public class WafBiClient {

    private final OkHttpClient http;
    private String challengeCookie = null;

    public WafBiClient() {
        this.http = new OkHttpClient.Builder()
            .cookieJar(new JavaNetCookieJar(
                new java.net.CookieManager(null, java.net.CookiePolicy.ACCEPT_ALL)
            ))
            .build();
    }

    public Response request(Request request) throws Exception {
        Request.Builder builder = request.newBuilder();
        if (challengeCookie != null) {
            builder.header("Cookie", "__sv_tk=" + challengeCookie);
        }

        Response response = http.newCall(builder.build()).execute();

        if (response.code() == 403 &&
            "true".equals(response.header("X-Challenge-Required"))) {

            String challengeUrl = response.header("X-Challenge-URL");
            if (challengeUrl == null) {
                String body = response.body().string();
                challengeUrl = new JSONObject(body).optString("challenge_url");
            }
            response.close();

            // 桌面应用:在系统浏览器中打开挑战页
            java.awt.Desktop.getDesktop().browse(java.net.URI.create(challengeUrl));

            // 等待用户完成,提示输入 Cookie
            System.out.println("[waf.bi] 请在浏览器完成人机验证后,粘贴 __sv_tk Cookie 值:");
            String token = new java.util.Scanner(System.in).nextLine().trim();
            if (!token.isEmpty()) {
                this.challengeCookie = token;
                // 携带 Cookie 重试
                return http.newCall(
                    request.newBuilder().header("Cookie", "__sv_tk=" + token).build()
                ).execute();
            }
        }
        return response;
    }

    // Android 环境请参考 Android Kotlin/Java 示例(使用 WebView)
}

授信头白名单(仅限内部工具)

⚠️
重要:授信头白名单仅适用于内部工具,不适用于终端用户 App 客户端。 携带授信头的请求将与 IP 白名单完全等同——直接放行,绕过全部安全检测。请妥善保管头值,定期轮换,不得泄露给终端用户。对于 VPN App、移动应用等终端用户客户端,请使用 WebView 挑战集成方案。

在站点设置 → 授信头白名单中配置后,携带指定 Header 的请求与 IP 白名单等同,跳过 CC 防护、Bot 检测、速率限制等检测直接转发至源站。注意:WAF 防护不受影响,始终生效——即使是授信头请求,WAF 规则(SQL 注入、XSS 等)依然正常拦截。适用场景:内部监控工具、健康检查 Agent、CI/CD 自动化测试等可信内部服务。

配置项示例值说明
头名称X-Internal-Token自定义名称,避免使用标准协议头名称
头值prod-monitor-2026建议 32 位以上随机字符串,定期轮换
放行效果跳过 CC/Bot/限速,WAF 仍生效等同于 IP 白名单,WAF 拦截不受影响

响应状态码参考

状态码含义X-Challenge-Required建议处理方式
200正常响应正常处理响应体
403 + X-Challenge-Required: true需要人机验证trueApp 打开 WebView 完成挑战;浏览器自动处理
403 (无挑战头)IP 封禁 / WAF 拦截 / 地区封锁告知用户访问被拒,无法通过 WebView 解除
429速率超限(已通过挑战的客户端)遵循 Retry-After 头,延迟后重试
503节点 QPS 告警,服务降级等待后重试

常见问题

QWebView 完成验证后凭证一直获取不到?
确认 WebView 开启了 JavaScript 和 DOM Storage。Android 需设置 settings.domStorageEnabled = true;iOS 需使用 WKWebView(不要用 UIWebView,它不同步 Cookie Store)。
Q验证通行凭证有效期是多久?
有效期由站点管理员在保护模板中配置。凭证过期后,下次请求将重新触发验证。
QApp 用户的 IP 在不同请求之间变化(移动网络切换),会重新触发挑战吗?
会。挑战凭证绑定 IP,IP 变化后需重新验证。建议在网络切换事件触发时,预先打开 WebView 完成刷新验证。
Q同一用户多个请求并发,会重复触发多次挑战弹窗吗?
建议在 HTTP 客户端层面实现"挑战锁":第一个请求触发挑战时,阻塞后续请求队列,待挑战完成后批量重试。参考示例中的 CountDownLatch / Coroutine 实现。
Q我的站点是纯 API 服务(无浏览器访问),是否可以只使用速率限制而不启用挑战?
可以。在控制台将站点模板设置为 bypass 或 default,不启用 JSChallenge 选项。速率限制和 IP 封禁仍然生效,触发超限时返回 429 而不是挑战页。
Q挑战页面的内容看起来每次都不一样,是 Bug 吗?
这是正常的安全设计行为,系统会定期对挑战内容进行动态变换,以对抗针对固定模式的破解尝试。App 使用 WebView 集成时无需关心内部变化,浏览器引擎会自动处理。
还有其他问题?

联系我们的技术支持团队,获取专属集成方案

免费注册 · 立即体验