「加了鉴权」不等于「受保护」:一次授权边界的自我审计
「加了鉴权」不等于「受保护」:一次授权边界的自我审计
站内搜索
直接问 AI

「加了鉴权」不等于「受保护」:一次授权边界的自我审计

给一台自建的文件管理器加上双因子登录网关之后,我在收尾清单上打了个勾,然后顺手问了自己一个问题:这个网关到底挡住了什么?

答案让那个勾变得很难看。它挡住了浏览界面,没挡住文件本身。

这篇记录那次自查:先说这个洞是怎么形成的,再说我在验证它的过程中发现网关其实根本不在请求路径上,最后是补洞时踩到的两个坑,和一个补完之后仍然漏了一年的地方。

同一份字节,两条路径

这台文件管理器的根目录,指向的是博客的上传目录。当初这么配是图省事:网盘里放的东西大多本来就要发到博客上,两边共用一份存储就不用来回拷。

问题在于,博客的 nginx 本来就把上传目录当静态资源公开服务——那是它的本职工作,图片、附件、下载包都从那儿出去。于是同一个文件在磁盘上只有一份,却有两条 URL 可以拿到它:

# 走网盘域名:被网关拦住,302 到登录页
curl -sI https://prairie.example.com/wp-content/uploads/private.pdf | head -1

# 走博客域名:同一份字节,200
curl -sI https://www.example.com/wp-content/uploads/private.pdf | head -1

我拿一个 5.5 MB 的 PDF 实测过,两条命令的结果就是上面写的那样:一条 302,一条 200 并且完整下载。网关一行配置都没错,它只是管不到另一条路径。

这里值得停一下,因为直觉在这件事上是反的。加鉴权的时候,脑子里想的是「我保护了这些文件」;但鉴权实际作用的对象是**请求路径**,不是磁盘上的数据。数据有几条路径可达,就要各自审计几遍——配置文件读得再仔细也看不出这一点,因为另一条路径写在另一个 vhost 里,而且它本来就是对的。

网关根本不在请求路径上

更尴尬的发现是在验证阶段。我想确认网关这一侧确实拦住了,于是去翻它的 nginx 访问日志,想看看刚才那次 302 长什么样。

日志里除了我自己在服务器上敲的几条环回 curl,什么都没有。

这台机器的公网入口是一条托管隧道,它的 ingress 规则不在服务器上,而在服务商的控制台里。那条规则指向的是 127.0.0.1:8091——文件管理器本体的端口。我精心配了 auth_request 的那个 nginx vhost,监听在另一个端口上,从来没有被真实流量经过。

换句话说,在我发现文件可以从博客域名下载之前,网盘域名本身也是不设防的。我以为的两道门,实际一道都没关上。

这个坑之所以隐蔽,是因为从浏览器访问网盘域名时,登录页确实弹出来了——那是文件管理器自己的登录页,不是网关的。两个登录页长得不一样,但当时没人会去比对。

后来我把这条写成了一个固定的检查动作:在假设某个 vhost 生效之前,先看它的 access log 里有没有非环回的客户端 IP。一个从没被外部访问过的 vhost,无论配置写得多完备,都只是一份没有执行的文档。

awk '{print $1}' /var/log/nginx/prairie.access.log \
  | sort -u | grep -v '^127\.' | head

没有输出,就说明这个 vhost 不在路径上。修法是把端口换过来:文件管理器挪到一个新端口,nginx 接管隧道原本指向的那个端口。改服务器比改控制台可靠——控制台的配置不在版本控制里,也不会随部署走。

代理鉴权把信任交给了一个请求头

端口换过来之后,还要解决一件事:用户不该登录两次。

文件管理器支持代理鉴权模式,配上之后它自己不再校验密码,而是信任 nginx 传来的 X-Auth-User 请求头。这个模式很好用,但它把「谁是合法用户」这个判断完全外包给了请求头,于是有两条约束必须同时成立:

location / {
    auth_request /__auth;
    auth_request_set $authuser $upstream_http_x_auth_user;
    proxy_set_header X-Auth-User $authuser;   # 只能来自网关的响应
    proxy_pass http://127.0.0.1:8090;
}

location ^~ /share/ {
    proxy_set_header X-Auth-User "";          # 免鉴权路径必须显式清空
    proxy_pass http://127.0.0.1:8090;
}

第一条是这个头只能由 auth_request_set 从网关的响应里取,不能透传客户端发来的同名头。第二条更容易漏:分享链接那类免鉴权的路径也必须显式把这个头清空。少了第二条,任何人只要在请求分享链接时自己带上 X-Auth-User: admin,就直接以管理员身份进去了——免鉴权路径成了鉴权路径的后门。

同样属于这一类的还有一个细节:网关在签发 cookie 时要拒绝用户名里的换行和不可打印字符,因为这个值最终会流进响应头。

校验通过,不等于登录完成

网关本身也有三个状态问题,都出在「校验」和「完成」之间那条线上,和上面那个洞是同一类思维错误的不同表现。

第一个是动态口令的消耗时机。防重放要求每个口令只能用一次,实现方式是记下已用过的计数器,要求后续的口令必须严格递增。我第一版把这个递增写在了校验口令的函数里——口令一对上就标记为已用。

于是出现了这样的行为:用户口令输对了、密码输错了,登录失败,但那个口令已经被烧掉了。用户改正密码重试,同一个 30 秒窗口里的口令被判为重放,只能干等下一个窗口。

正确的拆法是把校验和消耗分开:校验函数只做判断并返回计数器,标记为已用的动作放在整个登录成功之后的那条路径上。

counter = check_totp(code)        # 只判断,无副作用
if counter is None or not verify_password(user, pw):
    return unauthorized()
commit_totp(counter)              # 全部通过后才消耗
return issue_cookie(user)

这条规则可以推广:带副作用的状态推进,要放在成功路径上,不能放在校验路径上。校验函数应该是可以重复调用而不改变系统状态的。

第二个是这个 bug 留下的一个意外好用的诊断手段。登录失败时,网关的报错文案是刻意含糊的——不告诉你是密码错了还是口令错了,免得帮攻击者缩小范围。但这就意味着我自己排查时也拿不到信息。

状态文件补上了这个缺口:如果计数器推进了、请求却返回 401,那就是口令对、密码错。反过来计数器没动,说明口令那一关就没过。对外含糊、对内可查,两者并不冲突,只是需要你想清楚可查的信息落在哪儿。

第三个是登出。应用自己的登出按钮清的是它自己签发的会话凭证,但在代理鉴权模式下,它下一个请求又会从 X-Auth-User 里重新认出这个用户——等于没登出。登出必须打到网关的登出接口,清掉网关签发的那个 cookie。这一条在改成代理鉴权的那一刻就成立了,但界面上那个「退出」按钮看起来一切正常,不测是发现不了的。

补洞时踩的两个坑

真正的修复是在博客的 nginx 里加一份允许列表:上传目录下只有几个明确的子目录对外公开,其余一律 404。这个 location 必须排在静态扩展名那条正则之前,否则请求会先被后者匹配走。

写这段配置时踩了两个坑,都不在配置的逻辑上。

第一个是 nginx 的语法。允许列表要匹配形如 2024/2025/ 的年份目录,我写了 \d{2} 这样的正则,结果 nginx 把这条 location 从大括号处截断了——大括号在 nginx 配置里是块分隔符,正则里出现 {} 必须给整个模式加引号。不加引号不会报语法错误,只会静默地把模式截短成另一个意思。

location ~ "^/wp-content/uploads/(20\d{2}|published)/" { }

第二个坑更值得记。我的 reload 脚本长这样:

if nginx -t | tail -1 | grep -q successful; then systemctl reload nginx; fi

看起来是「测试通过才 reload」,实际上管道的退出状态是最后一个命令的状态。nginx -t 失败时它的错误信息照样会流过管道,而 grep 只要在里面匹配到字样就返回 0。这个组合真的 reload 过一次坏配置——nginx 保留了旧配置所以没出故障,但那道守卫等于不存在。

正确的写法是直接判断 nginx -t 自己的退出状态:

if nginx -t; then systemctl reload nginx; fi

源站封了,边缘还在发

最后一层是我事后才验证的:在源站上把文件封掉,不会让已经进了 CDN 边缘缓存的副本消失。

那些在修复之前被公开抓取过的文件,边缘缓存里带着 max-age=31536000, immutable,还会继续以 cf-cache-status: HIT 发出去,有效期接近一年。源站这时候返回什么已经不重要了,请求根本到不了源站。

验证的时候要注意,用普通 URL 去测会看到边缘的缓存副本,得出「还没修好」的错觉;带一个随机 query 参数才能绕开缓存看到源站的真实行为。两个结果都要看:随机 query 看修复是否生效,原始 URL 看缓存是否还在发。

还有个连带的麻烦:我手上那个 DNS 验证用的 API token 只带 DNS 权限,清不了缓存,调用会直接返回鉴权错误。清缓存要么去控制台,要么单独签一个带缓存清除权限的 token。这件事最好在需要它之前就确认,而不是在事故当中。

可以带走的判据

这次自查真正的收获不是补上了那个洞,而是三个可以复用的动作。

第一,审计的对象是到达同一份数据的所有 URL,不是某一份配置。方法很笨但有效:拿一个确定应该私有的文件,把你能想到的每个域名都拼上它的路径试一遍。配置读得再熟也代替不了这一步,因为漏洞恰恰长在「每一份配置单独看都是对的」的地方。

第二,在相信一段配置生效之前,先证明它在请求路径上。access log 里的非环回客户端就是这个证明。

第三,任何「免鉴权例外」都要反过来问一次:这条例外路径会不会把鉴权路径依赖的某个输入(一个请求头、一个 cookie、一个查询参数)原样放进去。

顺带一提,这次之后我给存储做了分层:真正私有的东西不再放在有公网 web 服务器指着的那块盘上。这不是不信任那份允许列表,而是因为允许列表是白名单,白名单会随着业务增长被人加条目,而「这块盘前面根本没有 web 服务器」这个事实不会。

发表回复

向下探索