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