开发者文档
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 需要:
- 检测响应头
X-Challenge-Required: true - 从响应头
X-Challenge-URL获取挑战地址 - 在应用内弹出 WebView 加载该 URL(浏览器引擎会自动执行验证流程)
- 用户在 WebView 内完成人机交互验证
- 验证通过后,从 WebView Cookie Store 提取安全凭证 Cookie
- 后续请求携带该 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)
💡
需要添加依赖:
dio、webview_flutter、webview_cookie_managerchallenge_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。以下示例用于检测挑战并给出明确错误,方便上层业务处理。如需完整自动化,请使用
playwright 或 puppeteer 控制真实浏览器。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 | 需要人机验证 | true | App 打开 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 集成时无需关心内部变化,浏览器引擎会自动处理。