用 Cloudflare Access 替代应用的弱认证
隧道只解决了网络暴露,认证责任仍默认落在应用自身,而自托管应用的门锁往往很弱。本文比较应用层、反代层与边缘层三种认证位置的能力差异,给出用 curl 首跳 302 判断 Access 是否生效的方法,并划清它不能替代应用授权的边界。
问题的形状
服务经隧道发布之后,网络暴露这一层是干净的:源站不监听对外端口,公网 IP 不出现在 DNS 里,边缘终结 TLS。但认证这一层没有跟着变。
通道修好了,门锁还是原来那把。而自托管应用自带的门锁通常很弱:
- HTTP Basic 单口令。用一个环境变量把用户名和口令塞进去,例如
SB_USER=<用户名>:<口令>。整个服务只有一个身份,口令换一次要重建容器。 - 默认凭据。首次启动的初始口令往往简单到可以直接猜,装上就跑、忘了改是常态。
- 干脆没有。把认证交给反代,或者交给「反正没人知道这个域名」。
这些门锁在内网里不算严重问题;一旦挂上 example.com 的子域、经边缘发布到全网可路由,性质就变了。所有人都在同一个入口排队试口令,而应用自身通常没有速率限制、没有封禁、没有二次验证。
先厘清一件事:隧道的收益是 网络层 的。它回答「谁能连到源站」,不回答「谁能登录应用」。访问控制要单独设计,而设计的第一件事是选它落在哪一层。
三层可以把关的位置
| 认证层 | 典型实现 | 二次验证 | 审计 | 设备策略 | 策略统一 | 源站是否运行认证逻辑 |
|---|---|---|---|---|---|---|
| 应用自身 | 应用内置用户体系、Basic 口令、默认凭据 | 多数没有 | 多数只有零散的登录日志 | 没有 | 逐个应用配置 | 是 |
| 反代层 | nginx / Caddy 的 Basic Auth、htpasswd |
没有 | 访问日志(需自己脱敏) | 没有 | 一份口令文件可共享,但仍要按 vhost 挂 | 是(反向代理仍在源站侧) |
| 边缘层 | Cloudflare Access:邮箱 OTP、SSO、服务令牌 | 有(取决于身份源) | 有(边缘侧统一记录) | 有(配合 WARP 的客户端姿态) | 有(策略对象可复用) | 否 |
三行的差别不是「好一点 / 差一点」,是能力上的有无:Basic 这一类口令认证,无论放在应用里还是放在反代里,都天然没有二次验证、没有身份、没有审计、没有设备信息。它只知道「口令对 / 不对」。而边缘层接入的是身份源,验证的是「这个人是谁、他的设备是什么状态、这条策略允许不允许」。
反代层仍有它的位置:在没有身份源、也不想引入第三方的情况下,「反代加 Basic 加 fail2ban」明显强过应用自带弱口令,至少把尝试次数管住了。但它有天花板,别把它当成终点。
为什么倾向放在边缘
请求在到达源站之前就被拦下。 未认证的请求根本不会出现在应用日志里,也不会进入应用的任何代码路径。认证前的攻击面被抬到了边缘:认证绕过、未认证远程执行、解析器漏洞,都需要先过 Access 这一关才谈得上够到。这一点是「认证逻辑不部署在源站」带来的,而不是靠更严的口令换来的。
策略可以统一。 邮箱域、身份源里的组、来源国家、会话时长,都是一次定义、挂到多个应用上。加一个新服务时认证是复用已有的策略对象,而不是又去建一套用户。对比之下,应用层意味着每加一个服务就多一套独立账号体系(以及多一处可能忘记加固的登录页)。
会话与凭据分开管理。 浏览器用户走 OTP 或 SSO,机器客户端走服务令牌(service token),两者的生命周期、轮换方式、吊销路径都不一样,在边缘层可以各管各的。应用层往往只有一种方式,于是「给 CI 用的账号」就是「人手一个的账号」。
怎么判断一个域名套没套 Access
一条命令就够。关键是看首跳的状态码与 Location,不要跟随重定向:
curl -s -o /dev/null -D - https://<主机>/ | grep -Ei '^(HTTP/|location:)'
套了 Access 的输出形如:
HTTP/2 302
location: https://<团队名>.cloudflareaccess.com/cdn-cgi/access/login/<主机>?kid=<十六进制>&redirect_url=%2F
判据:
- 首跳是 302,且
Location的主机是*.cloudflareaccess.com→ Access 生效,请求被边缘拦下了。 - 首跳直接 200 → 这个 hostname 没有挂 Access 应用。注意:这说明它此刻只由应用自身的认证保护,与隧道是否正常无关。
- 首跳 401,或 302 到应用自己的
/login→ 是应用自己在把关,同样不是 Access。
几个容易误判的点:
别跟随重定向。 curl -sIL 的最终状态码是登录页的状态码(也是 200),看起来和「裸奔」一模一样。判断依据是首跳的 Location 主机,不是最后一个状态码。
用无 cookie 的会话测。 浏览器里已经登录的会话会带着 Access 会话 cookie 直接拿到 200,因此浏览器地址栏看不出策略是否生效。curl 天然没有 cookie,这正是它适合这个用途的原因。
探测只能证伪。 一次 200 说明「这条路径上没有强制认证」,但也可能来自 Access 里刻意配置的按路径 / 按来源放行策略。要结论准确,得连策略一起看。
Access 只作用于经过边缘的流量。 如果某个服务的 DNS 是直连源站(灰云)而不是走隧道,那条路径完全不经过 Access。隧道场景下 CNAME 必须处于代理状态,这一点通常自动满足;但同一台机器上「有的服务走隧道、有的服务直接挂 A 记录」时,后者就没有这层保护。
它解决不了什么
它回答「你是谁」,不回答「你能做什么」。 应用内部的多用户权限、只读分享、按目录的可见性,仍然必须由应用实现。把 Access 当授权用只有两个结果:要么所有通过认证的人看到全部内容,要么你在边缘重新实现一套权限模型——那是在错误的层做事。
它不修复有漏洞的服务。 认证通过之后的攻击面完全不变:越权、注入、文件读取漏洞都在应用里。Access 消除的是认证前的暴露面,让一个「未认证就能打」的漏洞够不着;但只要有一个合法身份能进来,漏洞就还在。单人自托管尤其要意识到这点:唯一那个合法身份就是你自己,你的一次误点仍然可以触发漏洞。
对公开内容用它属于过度设计。 给博客、公开文档加 Access,会挡掉访客、搜索引擎与订阅器,每次访问还多一跳登录页。判断依据很简单:内容是「谁看都行」还是「只给特定人」。前者不需要认证层,加了只是给自己找麻烦。
它有运维成本。 新加的 hostname 忘了建 Access 应用就等于裸奔(隧道通了、看着很正常);服务令牌是长期有效的密钥,要和隧道 token 一样当机密文件管,并留轮换方案;身份源出问题时,你自己的管理入口也会一起进不去,所以运维通道(例如 SSH 密钥登录)应当独立于 Access 存在。
决策表
| 服务类型 | 建议的认证层 | 说明 |
|---|---|---|
| 公开内容站(博客、公开文档) | 不需要 | 公开即公开;加 Access 会挡掉访客与搜索引擎 |
| 个人笔记 / 知识库 | 边缘层 | 访问者有明确范围;OTP 一次配置多设备可用,且应用本身通常只有单口令 |
| 管理面板(容器面板、路由器、监控、机器人 WebUI) | 边缘层(必要) | 这类面板的认证最弱、漏洞代价最高;同时不该把认证逻辑留在源站 |
| 文件分享站 | 边缘层 + 应用内授权 | Access 控制「谁能进」,应用控制「能看哪个目录」,两层各司其职 |
| 对外 API 服务 | 边缘层 + 服务令牌,或应用自身令牌 | 机器客户端做不了浏览器 OTP;服务令牌要按密钥管理并定期轮换 |
落到配置上的第 4 步
「加一条共享网络、加一条 ingress、加一条 DNS」是发布一个服务的三步。用 Access 的话还有第 4 步:为这个 hostname 建 Access 应用并挂上策略。这一步漏掉不会报错,服务照常工作——只是所有人都在敲应用那把门锁。
配套的几点:
- 策略默认拒绝,只放行明确需要的邮箱域或组;不要用「所有人」。
- 会话时长按敏感度分档:管理面板短一些,笔记类可以长一些。短的代价是 OTP 次数变多。
- 机器客户端一律用服务令牌,不要为了省事给某个机器人账号开 OTP。
- 发布新服务后立刻用上面的
curl复查首跳,把「套没套」变成一次可见的检查,而不是一个假设。