02-一个请求在引擎里经历了什么
一个请求在引擎里经历了什么
ShieldWAF 系列文章第二篇,2026-10。第一篇讲了为什么要自研,这篇拆数据面:一条请求从进来到被放行(或拦截)的全过程,以及压测逼出来的三次热路径整改。
假设你把 example.com 的流量切到了 ShieldWAF:DNS 指向引擎,证书托管在平台,源站藏在后面。现在有一个请求打到 443,看它走过的一整条路。
流水线全景
引擎的请求处理是一条顺序管道(pipeline.go,两千行出头),每个阶段做一类检测,可以产出信号、也可以短路直接给判决:
route Host/IP:Port → 应用匹配;未命中 → 503 维护页
app_protection 维护模式、应用启停
parse 请求解析、body 超限 413、gzip 透明解压(100MB 炸弹上限)
tls_fingerprint 自解 ClientHello 算 JA3/JA4,指纹库匹配 → 可 block/challenge
normalize URL 解码等规范化,后续检测统一吃解码态文本
semantic 语义分析(13 种结构突变信号加权) 不短路
api_security 影子 API 发现 + OpenAPI schema 校验 不短路
graphql 查询静态分析(深度/复杂度/别名/批量/内省) 可短路
access_rule_filter 黑白名单八维匹配 可短路
ip_filter 系统级全局 + 应用级 IP 黑白名单 可短路
geo_blocking 国家黑名单(GeoLite2 + 内置 CIDR 兜底) 可短路
threat_intel IP/域名信誉,confidence×0.6 融合 恒 monitor
rate_limit 令牌桶 + 滑动窗口 + 会话维度 + 4xx 信誉衰减 可短路
login_guard 撞库检测(401/403 计失败、2xx 清零) 可短路
bot_detect ∥ rule_engine Bot 三维判定 ∥ Coraza CRS 并行
custom_rules 业务自定义规则(AND/OR/NOT) 可短路
ml_detect 智能防护评分(灰度分流) 不短路
decide 决策融合 → verdict
log 事件发射(延迟到响应后补状态码,异步批量落库)
→ 反向代理转发源站(健康状态机/故障转移/负载均衡)
两条贯穿全局的约束,值得单独说。
第一,阶段产出信号,不产出最终判决。 所有信号——规则命中、Bot 判定、情报信誉、ML 评分、语义分数——最后汇到 decider 融合成一个 verdict:pass / monitor / throttle / block / challenge / redirect。这样有两个自然的结果:防护模式可以按"应用 × 规则类别"逐级配置(观察 → 限速 → 阻断,每类规则取命中类别里的最强档),灰度发布就靠这个;另外 ML 永远不能把规则引擎的 block 降级成 pass,只能把置信区间内的可疑请求升级成挑战——机器学习在决策链上只有建议权。
第二,检测吃规范化后的文本。 攻击串很少明文来,URL 编码是基本操作,双重编码也不稀奇。normalize 之后,后面所有检测器面对的是同一份解码态文本,不用各自解码一遍,也不会漏掉二次编码的变体。但规范化本身会毁证据:把 ../ 清洗掉之后,LFI 遍历特征就看不见了。所以特征抽取同时保留 RawPath 视角——解码前和解码后各看一遍。这个细节是做 ML 特征时才意识到的,第三篇会展开。
挑几个阶段细说
路由。多站点是反向代理型 WAF 的基本盘。我们本来担心站点多了以后通配路由匹配退化成 O(n) 扫描,专门在压测矩阵里放了一档 51 个应用的规模测试——结果零退化,假设证伪。这个量级下线性匹配就是最快的方案,先测量再优化。
Host 没匹配到任何应用时,返回 503 维护页而不是转发到某个默认源站。IP 直扫的流量没有归属,瞎转发等于替扫描器浪费源站资源,还会把扫描响应计进源站日志污染排查。
语义分析。这是对标雷池之后加的检测器:不看关键词,看请求的结构突变——参数里突然出现引号、union、布尔注入的形态组合,13 种信号加权打分。它默认只 monitor 不拦截,定位很明确:给 ML 供特征(语义槽位就是特征契约的第 46-51 维),给人工供线索。让语义引擎直接下判决听起来更"智能",但它的误报模式还没有被充分认识,先观察一段再说。
GraphQL。传统 WAF 对 GraphQL 基本失明——所有查询都长一个样(POST /graphql)。静态分析查六个维度:深度、复杂度、别名滥用、批量、内省、字段黑白名单。block 时返回的是 GraphQL 协议格式的 error,不是 HTML 错误页——对 API 客户端保持协议友好,否则客户端的重试逻辑会把你搅得天翻地覆。
黑白名单。八个维度:IP/CIDR/URL/UA/Referer/Cookie/Param/Domain,动作有 block/allow/monitor/captcha。allow 命中后直接短路后面全部检测——这是误报的逃逸通道,也是"可信爬虫"的通行证,权限给得很大,所以规则带版本管理和回滚。
限流。全局令牌桶、per-app/per-URL 滑动窗口、会话维度三层,再加一层 4xx 信誉衰减——一直打出 404 的客户端会被越限越紧,扫描器自然被掐断。Redis 用 Lua 脚本保证令牌桶的原子性;Redis 挂了降级成进程内实现,单副本语义正确(多副本会各自计数,这个降级损失是有意的、可接受的)。
bot ∥ CRS 并行。这两个是最贵的阶段又互相独立,并行跑。Bot 判定三维:指纹(19 条内置指纹库)+ 行为 + 频率,必要时下发 JS Challenge(SHA-256 计算验证,过了发 Bypass Token Cookie);CRS 走 Coraza v3.8.1。
决策融合与响应路径
decide 融合完,verdict 不是 block 就该转发源站了。转发有自己的健康状态机(Nginx 语义的 up/down),5xx 自动故障转移到备用源站。有个排查经验值得记:毫秒级 502 基本不是源站挂了,是健康门把还没预热完的源站拒绝了——状态机时间参数没配好的典型症状。
响应回来还要过两道:response_guard 做 DLP 脱敏(缓冲式扫描,响应体超限自动回落流式——大文件不能为了扫描全缓冲在内存里),dynamic_protection 做 HTML 内联脚本的加密改写(反爬抓取),两者互斥。最后补安全响应头,返回客户端。
三场热路径硬仗
功能写完只是开始。第一轮真实压测(vegeta 恒定到达率,七种流量 × 七种配置的矩阵)把三个问题按到脸上。
时钟读取占了 47.7% 的 CPU。 阶段计时用 time.Now() 打点,代码看着毫无问题,但压测服务器那个 clocksource 下读钟病态地贵。解法不是删计时,是"边界链":每个阶段读一次钟,这个时间戳既是本阶段的终点、又是下一阶段的起点,单请求钟读取从约 22 次降到 15 次,阶段计时语义不变。教训是性能问题经常不在算法复杂度上,在你以为不要钱的基本操作上。
一把大锁。 限流器和 Bot 频率表都是全表一把锁,8000 RPS 时 P50 劣化 3.3 倍。按 FNV-1a(key) 分 16 片、每片独立锁,惰性清理也改成按片进行——原来清理要持着全局锁扫全表。
一只真正的 P0。 影子 API 发现阶段,schema 没命中的请求每个都向控制面 POST 一条上报。功能测试毫无异常——低并发嘛。压测打到 2000 RPS:引擎 P50 飙到 125ms、CPU 309%、28,000 个 TIME_WAIT。数据面的一次数毛上报,放大成了对控制面的 DDoS。修复是 TTL 去重(60 秒窗口、8192 个 key)。这条教训写进了约定:数据面的上报类逻辑必须自带去重,因为你不知道控制面扛不扛得住数据面的流量放大。
修完之后的底数(32 核单节点实测):
| 场景 | 单请求 CPU | 2000 RPS P99 |
|---|---|---|
| 直连(不过引擎) | — | 1.1ms |
| 纯代理 | 133µs | 3.5ms |
| 纯代理 + CRS | 190µs(增量 57µs) | 6.6ms |
| 全模块开 | — | 4.0ms |
两个档位不是同一次 run 跑出来的,绝对值以各自 run 为准;要点是全模块开仍在个位数毫秒。CRS 开启时饱和吞吐约 8800 RPS;64KB 大响应场景被 DLP 扫描和拷贝压到带宽上限,约 1.7Gbps。CPU 配额维度还测出一个很实际的结论:2000 RPS 至少给 4 核,2 核时 P99 劣化 20 倍,1 核不可用——容量规划不能按"能跑就行"来。
Fail-closed 的边界
最后是失败语义。block 模式下规则引擎故障,引擎返回 502 而不是放行——业务方宁可看到错误页也不能裸奔;monitor 模式 fail-open,但强制全量日志 + CRITICAL 告警。这个开关每个应用可配,毕竟有的业务(比如静态资讯站)可能更愿意选可用性。
压测方法论一句话概括:先挂 pprof 再谈优化。引擎的 /debug/pprof 走诊断 token 保护,生产默认关,出问题再开——测量基础设施平时不占资源,出事时十分钟能定位到热点,这钱花得最值。



浙公网安备 33010602011771号