确保 Cloud Run 流量通过 Cloudflare 的三层防护
公共 run.app 网址允许任何人绕过您的 CDN 并直接访问 Cloud Run。三个层面可弥合此差距:入站流量限制、负载均衡器、密钥标头。
将 Cloudflare 置于 Cloud Run 服务之前,您将获得缓存、WAF 和 DDoS 吸收能力。如果源站仍可通过其默认的 run.app URL 访问,那么这一切都无济于事,因为找到该 URL 的攻击者可以轻易绕过您设置的所有保护措施。修复方法并非单一设置。它包含三个独立的层,每一层自身都能实现故障关闭:网络边缘的锁定入口、成为唯一公共入口的负载均衡器,以及一个用于证明请求确实来自 Cloudflare 的秘密标头。本文将逐一介绍这三层、每一层的配置,以及何时整个堆栈会超出您的需求。
缺陷:run.app 默认是公开的
当你部署 Cloud Run 服务时,Google 会给你一个类似 https://my-service-abc123-an.a.run.app 的 URL。默认情况下,该 URL 直接为互联网提供服务。然后,你将一个域名指向 Cloudflare,Cloudflare 会代理到你的服务,流量通过 CDN 流动。这看起来很安全。但事实并非如此。
问题在于 run.app URL 仍然有效。任何知道它的人——而这些 URL 是可猜测的、会被记录的,并且会在 referrer 标头和错误页面中泄露——都可以直接向源站发送请求。这条直接路径会绕过你的缓存,因此每个请求都是一次冷命中,会消耗你的计算成本。它会绕过你的 WAF,因此注入和机器人过滤永远不会运行。而且它会绕过 Cloudflare 的速率限制,因此单个脚本就可以猛烈攻击该服务,直到你的账单或延迟不堪重负。
隐藏 URL 并非一种防御措施。一旦该字符串出现在某个日志聚合器中,通过隐藏来实现安全的做法就会失败。源站需要主动拒绝任何不是从正门进入的请求,而正门需要是唯一的入口。
第一层:将 Cloud Run 入口锁定到负载均衡器
Cloud Run 有一个入口设置,用于控制哪些网络可以访问该服务。默认值为 all,这是公开的 run.app 行为。您需要的值是 internal-and-cloud-load-balancing,它会丢弃所有直接流量,只接受通过 Google Cloud 负载均衡器或来自 VPC 内部的请求。
gcloud run services update my-service \
--region=asia-northeast3 \
--ingress=internal-and-cloud-load-balancing
进行此更改后,对原始 run.app URL 的请求会在到达您的容器之前,从 Google 的边缘返回 404。该服务不再位于公共互联网上。它只能通过您即将构建的负载均衡器访问,而这正是您想要的扼制点。
这一个设置完成了大部分工作,但有必要了解为什么它本身还不够。入口控制是一个网络事实:它根据请求的入口位置进行过滤,而不是根据请求是否合法进行过滤。一旦您在前面放置一个负载均衡器并将其公开,该负载均衡器本身就是公开的。任何到达负载均衡器的请求都会到达您的服务。因此,第一层将入口缩小到单个均衡器,而第二层和第三层则决定该均衡器允许转发哪些内容。
第二层:在 Cloudflare 后面放置一个负载均衡器
在入口锁定的情况下,唯一能访问 Cloud Run 的是 Google Cloud 负载均衡器。您需要构建一个外部 HTTPS 负载均衡器,其后端是一个指向该服务的无服务器网络端点组 (Serverless Network Endpoint Group, NEG)。NEG 是一个适配器,它让负载均衡器能够将没有固定 IP 或实例组的无服务器产品作为目标。
# 1. A serverless NEG that points at the Cloud Run service
gcloud compute network-endpoint-groups create my-service-neg \
--region=asia-northeast3 \
--network-endpoint-type=serverless \
--cloud-run-service=my-service
# 2. A backend service that uses the NEG
gcloud compute backend-services create my-service-backend \
--global \
--load-balancing-scheme=EXTERNAL_MANAGED
gcloud compute backend-services add-backend my-service-backend \
--global \
--network-endpoint-group=my-service-neg \
--network-endpoint-group-region=asia-northeast3
然后,您需要附加一个 URL 映射、一个带证书的 HTTPS 代理以及一个带静态 IP 的全局转发规则。该 IP 就是 Cloudflare 代理的目标。在 Cloudflare 仪表板中,您将域名的 DNS 记录设置为该 IP,并开启代理状态(橙色云朵)。现在,公共路径是:用户,然后是 Cloudflare,然后是负载均衡器 IP,然后是 NEG,最后是 Cloud Run。
此时,您有两扇门,而其中只有一扇应该是打开的。Cloudflare 是预期的那扇门。负载均衡器 IP 是第二扇门,它仍然是公共的,因为全局转发规则会响应整个互联网。解析出您域名的真实源站 IP 或扫描地址范围的人,可以直接与负载均衡器通信,再次绕过 Cloudflare。第二层将暴露面从 run.app 转移到了一个原始 IP,这个 IP 更难找到,但并未关闭。这正是第三层要关闭的。
第三层:只有 Cloudflare 才能添加的秘密标头
最后一层证明了请求通过了 Cloudflare,它在应用层而非网络层实现这一点。Cloudflare 会在其代理的每个请求中注入一个秘密标头,源站会拒绝任何未携带正确值的请求。直接访问负载均衡器 IP 的攻击者无法伪造此标头,因为他们不知道这个秘密。
您可以通过 Cloudflare 转换规则(规则,然后是转换规则,然后是修改请求标头)添加该标头。该规则在所有传入请求发往源站之前,为其设置一个自定义标头:
Rule: Add origin secret
When: (all incoming requests)
Then: Set static header
Header name: X-Origin-Secret
Value: <a long random string from Secret Manager>
然后,源站会对每个请求进行两项检查:标头是否存在且匹配,以及 Host 是否是您期望的域名。Host 检查会捕获那些携带了过时或复制的标头但目标是错误虚拟主机的请求。在 Go 中,这个中间件很小:
func requireCloudflare(secret, wantHost string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if subtle.ConstantTimeCompare([]byte(r.Header.Get("X-Origin-Secret")), []byte(secret)) != 1 {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if r.Host != wantHost {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
使用恒定时间比较,以免检查通过时序泄露秘密。从 Secret Manager 加载秘密,而不是将其烘焙到镜像中,这可以使其不出现在您的源代码和容器层中,并允许您轮换它。轮换是一个两步过程:在源站上将新值添加为第二个可接受的秘密,将转换规则切换到新值,然后在流量排空后删除旧值。在切换过程中,不会有任何请求被拒绝。
完整的请求路径
一旦所有三个层都部署到位,完整的流程如下。每一跳都有其任务,而每个拒绝点都是一个层在失效时关闭。
| 跳 | 组件 | 任务 | 拒绝条件 |
|---|---|---|---|
| 1 | Cloudflare | 缓存、WAF、速率限制、添加 X-Origin-Secret |
机器人或攻击签名与 WAF 匹配 |
| 2 | GCP 负载均衡器 | 终止 TLS,通过 URL 映射进行路由 | (转发;本身不是安全门) |
| 3 | 无服务器 NEG | 适配 Cloud Run | 不适用 |
| 4 | Cloud Run 入口 | 丢弃非负载均衡器流量 | 请求未通过负载均衡器或 VPC 到达 |
| 5 | 源中间件 | 验证标头和 Host | 标头缺失、错误或 Host 不匹配 |
合法的访问者会通过所有五个步骤。对原始 run.app URL 的请求在第 4 跳处终止。跳过 Cloudflare 直接访问负载均衡器 IP 的请求会通过第 4 跳,但在第 5 跳处终止,因为它没有密钥标头。没有任何一条路径可以在不经过 Cloudflare 的情况下到达您的处理程序。
为什么是三层而不是一层
一个显而易见的问题是,当仅凭密钥标头似乎就能关闭旁路时,为什么还要费心使用所有三层。答案是,每一层都弥补了其他层的不同故障,而真正的事件源于故障,而非理想路径。
Ingress 控制是一种网络保障,它不依赖于你的代码是否正确。如果你发布了一个在某些路由上跳过标头中间件的 bug,ingress 仍然会使原始的 run.app URL 无法访问。密钥标头是一种应用保障,它不依赖于 Google 的网络配置是否正确。如果有人在进行不相关的更改时不小心将 ingress 设置改回 all,标头检查仍然会拒绝直接访问。Host 验证捕获的是一个更窄范围的情况:一个有效的标头被重放到错误的服务上,或是一个被错误路由的请求。
它们是独立的,因为它们的失效方式也是独立的。单一控制意味着一次失误,一次错误的点击或一个遗漏的代码路径,就会让你的防御降至零。三个控制意味着一次失误只会让你从三层防护降至两层,而在你发现并修复它的过程中,你仍然受到保护。纵深防御的要点不在于任何一层是薄弱的。而在于所有层同时出错的可能性远低于其中一层出错的可能性。
成本,以及何时属于过度设计
这些都不是免费的。外部 HTTPS 负载均衡器有固定的按小时收费,在处理任何请求之前,每月费用约为 18 美元,此外还有按规则和数据量计算的费用。对于一个业余爱好性质的服务,这笔账单可能会让其所保护的计算资源费用相形见绌。此设置还包含更多活动部件:一个 NEG、一个后端服务、一个 URL 映射、一个代理、一个证书、一个转发规则、一个 Transform Rule,以及为保持运行而进行的密钥轮换。这意味着有更多可能被错误配置的地方,并且在出现问题时需要推断分析的内容也更多。
有一些更轻量级的选项,对于许多服务来说,它们是正确的选择。Cloudflare 自家的 Origin Rules 加上 authenticated origin pulls(Cloudflare 与你的源站之间的 mutual TLS)可以在完全不使用 GCP 负载均衡器的情况下证明连接来自 Cloudflare,不过在 Cloud Run 上,你仍然需要采取措施来阻止 run.app URL 响应请求。仅使用密钥标头而不进行入站流量锁定,可以阻止随意的绕过,但会让源站保持公开可访问状态,并完全依赖于你的代码是否正确。这些方案中的每一种都是用一层深度来换取更低的成本和更少的设置工作。
当威胁程度低且爆炸半径小时,使用完整的技术栈就属于过度设计。一个位于身份代理之后的内部工具、一个低流量的个人项目,或者一个直接访问只会造成几美分而不是几美元损失的服务,都不需要这三层保护。当直接访问源站会造成实际损害时,才应寻求完整的三重保护:例如,当绕过缓存意味着真金白银的损失时,当 WAF 在执行有意义的工作时,或者当一个坚决的攻击者找到你的源站 IP 是一个可能发生的事件而不是理论上的可能时。将保护层数与突破前门会给你带来的损失相匹配,并止步于此。