一个越来越难用的防火墙
如果你用过 Cloudflare 的防火墙,你可能经历过这样的困境:想同时根据 IP 和 URI 拦截某个请求,发现做不到。想说"来自某个 AS 号、且访问路径包含 /wp-admin"的请求才拦截,也做不到。
这不是功能没有,而是架构本身的限制。这篇博客完整讲述了 Cloudflare 是如何一步步从"每个维度单独一套规则"演进到"支持任意组合的表达式防火墙"的,以及为什么最终选择用 Rust 来构建核心匹配引擎。
原文博客: https://blog.cloudflare.com/how-we-made-firewall-rules/
旧系统是怎么工作的
Cloudflare 最早的防火墙能力非常原始:只能针对 IP 地址进行封禁。
if request IP equals 203.0.113.1 then block
随着需求增加,逐渐加入了 CIDR 范围、ASN、国家、User Agent、URI 匹配,还有 Rate Limiting、Zone Lockdown 等功能。但这些功能在实现层面是相互独立的,每一个都只处理单一维度。
到 2017 年,这个防火墙的能力可以被一句话总结:
你可以按任何条件拦截流量,前提是你只挑一个条件。
这些规则在实现上分为两类:
Lookup 匹配 :针对 IP、CIDR、ASN、国家、User Agent 这类字段,构造一个 KV 键(比如 zone:www.example.com_ip:203.0.113.1)去全局分布式存储里查。O(1) 复杂度,性能极好,但只能查单一字段的值。如果要组合两个字段,就需要把所有可能的组合都写进 KV,键的数量会爆炸。
正则匹配:针对 URI 的 Page Rules,把所有规则合并成一个大正则:
^(?<block__1>(?:.*/wp-admin/index.php))|(?<block__2>(?:.*/xmlrpc.php))$
正则的命名捕获组被用来编码动作类型。这个方案在 URI 匹配场景下出乎意料地好用,但一旦要同时匹配 URI 加 IP 范围,就没有自然的扩展方式。
灵感:Wireshark
工程师们很早就意识到,新方案的核心应该是一个表达式语言。最初的方案是用 JSON 来表达 DSL:
json
{
"And": [
{ "Equals": { "host": "www.example.com" } },
{ "Or": [
{ "Regex": { "path": "^(?:.*/wp-admin/index.php)$" } },
{ "Regex": { "path": "^(?:.*/xmlrpc.php)$" } }
]}
]
}
计算机处理没问题,但人看起来费力。在把这个 JSON 翻译成"人类语言"时,工程师意识到这个结构非常眼熟------这不就是 Wireshark 的过滤器语法吗?
Wireshark 是网络协议分析工具,它的 Display Filter 语法长这样:
http.host eq "www.example.com" and (http.request.path ~ "wp-admin" or http.request.path ~ "xmlrpc.php")
简洁,人类可读,机器可解析,而且对安全工程师来说几乎零学习成本------他们每天排查攻击时就在用 Wireshark。
Cloudflare 决定借鉴这套语法,但不做 Wireshark 那样的离线数据包分析。他们要的是实时过滤:HTTP 请求进来,毫秒内判断是否匹配,给出处理动作。
核心引擎:wirefilter
基于这个思路,Cloudflare 用 Rust 实现了一个名为 wirefilter 的库(名字向 Wireshark 致敬)。
它做几件事:
- 定义字段及其类型,比如
ip.src是 IP 类型,http.host是字符串类型 - 给定一张请求属性表(由 HTTP 服务器填充)
- 解析和校验表达式语法,检查字段名是否合法、操作符是否适用于该字段类型
- 将表达式应用到请求属性表,返回 true/false
请求属性表的字段覆盖了一条 HTTP 请求的完整信息:
| 字段 | 示例值 |
|---|---|
| http.host | www.example.com |
| http.request.uri.path | /articles/index |
| http.request.method | GET |
| ip.src | 203.0.113.1 |
| ip.geoip.country | GB |
| ip.geoip.asnum | 64496 |
| ssl | true |
wirefilter 被集成到了两个地方:用 Go 写的 REST API(负责校验用户输入的表达式),以及用 Lua 写的边缘代理(负责在请求进来时实际执行匹配)。
Go 侧的集成大致是这样:
go
var scheme = filterexpr.Scheme{
"http.host": filterexpr.TypeString,
"http.request.uri": filterexpr.TypeString,
"ip.src": filterexpr.TypeIP,
"ip.geoip.country": filterexpr.TypeString,
"ssl": filterexpr.TypeBool,
}
expressionHash, err := filterexpr.ValidateFilter(scheme, expression)
Lua 侧则负责在每次请求时填充属性表,然后拿 wirefilter 来做实际的匹配。
为什么选 Rust
这个问题的答案比较直接:需要保证 API 侧和边缘代理侧的行为完全一致。
如果分别用 Go 和 Lua 各写一套实现,任何微小的差异都可能被攻击者利用------比如在 Go 侧判断为合法的规则,在 Lua 侧匹配逻辑略有不同,就可能绕过防火墙。
用一个共享库来封装匹配逻辑,Go 和 Lua 都通过 FFI 调用它,能从根本上消除这种不一致。
在候选语言里,C 和 C++ 有内存安全问题,Go 和 Lua 已经被排除(正是问题所在),JavaScript Worker 方案在性能和集成上有额外复杂度。Rust 在性能、内存安全、低内存占用、以及可以被其他产品复用(比如 Spectrum)这几个维度上综合表现最优。
规则优先级:一个云环境特有的问题
新系统支持复杂表达式后,随之而来的是一个新问题:怎么确定规则之间的执行顺序?
传统防火墙(iptables、家用路由器)用的是显式顺序:规则 1 到规则 N,匹配到第一条就停止。每次改动都重新发布全部规则,顺序是确定的。
但在云端这不可行。一个大客户可能有几十万条规则,每次改动都要重新发布全部规则代价太高,而且在分布式环境下,两条规则同时发布时可能出现竞态条件。
Cloudflare 的解决方案是 priority 值,是一个 int32,数字越小优先级越高。两条规则优先级相同时,再按动作类型排序:
- Log(最高优先级)
- Allow
- Challenge(CAPTCHA)
- JavaScript Challenge
- Block(最低优先级)
这套设计有几个好处:
- 单条规则独立发布,发布速度不受规则总数影响
- 优先级不要求唯一,可以给一批规则设置相同的优先级值,起到分组的效果
- 如果你有一个已有的顺序化规则系统,可以直接把顺序号映射成 priority 值导入进来
字段命名的远见
注意到 wirefilter 的字段都有 http. 前缀了吗?这遵循了 Wireshark Display Filter Reference 的命名约定,而不只是一个风格习惯。
这意味着这套引擎从设计上就不是 HTTP 专属的。只要定义了对应协议的字段(比如 smtp.from、dns.query),同一套匹配引擎就可以用于 SMTP、DNS 或者 Layer 4 的流量过滤。Cloudflare 的 Spectrum 产品(支持任意 TCP/UDP 协议的代理)就是这套架构向前延伸的方向。
小结
这篇文章描述了一个典型的工程演进路径:从一堆各自独立、能用但不能组合的功能,到一套统一的、基于表达式的过滤引擎。
几个值得记住的设计决策:
把核心逻辑下沉为库,而不是在每个调用方分别实现。这是保证多语言环境下行为一致的唯一可靠手段,也是选 Rust 写 wirefilter 的直接动因。
表达式语言借鉴成熟工具的语法。Wireshark Display Filter 对安全工程师是零学习成本,从调查工具到防护工具的语法迁移是自然的。
云环境的顺序问题不能用传统方式解决。priority 值而非显式顺序,单条独立发布而非全量重发,是针对分布式环境做的专门设计。
字段命名是架构意图的一部分 。http.host 而不只是 host,这个前缀埋下了未来扩展到其他协议的伏笔。