一、一个灰色的开关
我本来只想干一件小事:给协会那个中转站 api.liuhangyv.top 的登录加一层 Passkey,用指纹或系统密钥代替密码。后台的安全设置页里确实有这么个开关,但它一直是灰的,点不动,底下压着一行提示:
「请先配置有效的 RP ID 与允许的 HTTPS 来源」

第一反应是配置没填。这个判断没错,错的是我以为填完就能收工。
两层开关
这个中转站的后端是开源的 sub2api,我直接去翻它的源码,backend/internal/service/setting_features.go 里藏着答案——这个开关其实是两个。
第一层是 config.yaml 里的 webauthn 段,属于安全边界:RP ID 和允许的 Origin 写在这里,决定这个部署有没有资格谈 Passkey。(RP ID 就是这个部署的域名——WebAuthn 规定密钥只能绑在某个域上;Origin 是允许发起认证的页面来源。这两个后面还会踩坑。)第二层是数据库 settings 表里的 passkey_enabled,属于运行时开关:管理员在后台点一下,就能让它临时下线。
func (s *SettingService) PasskeyEnabled(ctx context.Context) (bool, error) {
if !s.passkeyConfigured() { return false, nil } // 第一层:config.yaml
value, err := s.settingRepo.GetValue(ctx, SettingKeyPasskeyEnabled)
if errors.Is(err, ErrSettingNotFound) {
return true, nil // 第二层「记录不存在」时默认 true
}
return value == "true", nil
}
像保险柜上的两道锁:机械锁对应 config.yaml,钥匙在运维手里,改一次要动文件、重启服务;电子锁对应数据库里那个开关,管理员随时能关,但不可能凭空开。
源码注释说得更直接——the database setting can only disable a valid configured relying party, never replace or weaken it,数据库开关只能收窄,不能放宽。
这个设计存在的理由不难想:动配置要改文件、重启服务,是运维的活;日常开关是运营的活。运营能不能关掉一个已经配好的 Passkey?当然能。但让他们点一下,就把没配好的部署变成「能用」——这就越界了。
安全边界和运营开关是两回事,混在一起,迟早出事。
还有个我没料到的细节:数据库里 passkey_enabled 不是没有记录,而是显式存着 'false'。PasskeyEnabled() 里那个默认 true 只在「记录不存在」时生效,我这条记录是在的,值就是 false。所以配置改好、容器重启,开关不会自己亮。
config.yaml 该怎么写
补上的 webauthn 段长这样:
webauthn:
enabled: true
rp_display_name: "Sub2API"
rp_id: "api.liuhangyv.top" # 只填域名,不带 https:// 和端口
rp_origins:
- "https://api.liuhangyv.top" # 完整 Origin,必须带协议
这两行长得很像,规则却完全不同:rp_id 只填域名,rp_origins 必须带协议。填错服务直接起不来,webauthn_test.go 把这些错法都覆盖了——你填完照着对一遍就行:
| 错误写法 | 报错关键字 |
|---|---|
缺 rp_id | webauthn.rp_id |
rp_id 带 https:// | domain without scheme |
| 非 localhost 用 http | must use HTTPS |
| origin 域与 rp_id 不匹配 | not within relying party ID |
WebAuthn 规范里,RP ID 是「域」的概念,不是地址。浏览器只允许它等于当前页面的有效域,写成 URL,webauthn.New() 在初始化阶段就会失败,服务连门都出不去。rp_origins 反过来,它圈的是「哪些来源的页面可以发起认证」,所以要完整的 Origin,协议、域名一个都不能少。
改完配置、重启容器,那个灰了很久的开关终于能点了。我把它打开。
我本来只是想让登录更安全一点。
二、登录成功,然后立刻被踢出去
能过,但只过一瞬间
开关点亮之后,第一件事当然是登录试试。
Passkey 那一步很顺,验证通过,页面跳进了后台。下一秒,我被弹回登录页,上面写着一行字:「会话已过期,请重新登录」。
再登一次,同样的剧本——进去、出来。刷新呢?刷新只是把同一次失败又执行了一遍。
普通过期是「用着用着才掉」,这里是「刚进去就掉」——不是时间到了,是登录刚完成,就有什么东西被判了死刑。
一个默认关着的开关
回后台翻安全设置,找到一项叫「会话 IP/UA 绑定」的开关,变量名是 session_binding_enabled。
它干什么,代码注释写得比我解释得清楚:开启时会话与登录时的 IP、User-Agent 绑定,任一变化立即失效并撤销该会话。
注释里还专门交代了默认关闭的原因——移动网络 / 多出口 IP 场景下 IP 频繁变化会导致登录后立即掉线。
写这个功能的人早就预见到了这种场景,他只是没预见到,我这边的 IP 会变得这么快。
同一秒里,IP 换了六个
我去翻日志,盯着源 IP 那一列看了很久。
同一秒内发出去的每个请求,源 IP 都不一样:47.123.117.41 / .25 / .35 / .64 / .89 / .99。
绑的是登录那一刻的客户端特征——IP 加 User-Agent 一起算成一个指纹,代码里叫 BindingHash。之后每个请求都重新算一遍,对不上,就认定这个会话已经易主。
思路没错,防会话劫持就该这么做。它只是默认了一件事:客户端的 IP 在一段时间内是稳定的。
像给会话按了把指纹锁,IP 或 UA 一变就锁死。放在固定宽带上,这是道保险;放在我这条链路上,它等于给每次登录都装了个一次性机关——进门时上锁,抬脚就触发。
为什么刷新也救不回来
命中不匹配之后,处理逻辑只有三行:
// 简化后的逻辑
if current != claims.BindingHash {
authService.RevokeSessionFamily(ctx, claims.SessionID) // 连 refresh token 一起吊销
return 401 "SESSION_BINDING_MISMATCH"
}
关键在 RevokeSessionFamily。现在的登录体系大多是双 token:短命的 access token 负责每次请求,长命的 refresh token 负责在它过期时换一张新的,省得你反复输密码。
为了防重放,服务端会把一次登录派生出的所有 token 归进同一个会话家族——refresh token 每换一代,家族就往下续一代,上一代同时作废。这套机制本来就是堵「refresh token 被偷走后能无限续命」这个口子的。
所以 RevokeSessionFamily 不是把手上这一张票作废,是整个票号池一起注销。下一跳的刷新请求带着同一个家族的身份,一并被判无效,前端的自动续期路径被从根上堵死。
这就是「一登录就掉线」而不是「用一会儿才掉」的原因。
62 条记录,一次改库
审计表 audit_logs 里能查到 62 条 auth.session_binding.mismatch,全是同一个账号,IP 在 47.123.117.25 到 .99 之间来回遍历。

问题摆清楚了,处置反而最简单——后台已经进不去,只好绕过去改库。(顺带一提,这台机器上的 PostgreSQL 容器名叫 postgresq,少一个字母,敲的时候得盯着点。)
UPDATE settings SET value='false', updated_at=now() WHERE key='session_binding_enabled';
改完重启容器,再登录,一切正常。
事情到这儿本该结束。可我关掉日志前又回头看了一眼那串地址:47.123.117.41、.25、.35、.64、.89、.99,同一秒里换了六个,换得极有规律,不像随机漂移,也不像任何一个人的固定出口。
这些 IP 到底是谁的?
三、真实 IP 一直在,只是没人用
会话绑定处理完了,日志里那串每秒都在变的 IP 还扎在那儿。我想弄清楚它们到底是谁的,于是去翻了 OpenResty 的 access.log。日志格式是 $remote_addr ... "$http_x_forwarded_for",两列并排摆着:
# $remote_addr "$http_x_forwarded_for"
47.123.117.64 ... "223.104.x.x"
↑ ↑
直连对端(在变) 用户真实 IP(稳定)
左边这列就是审计表里那串一秒一个样的地址,右边这列从头到尾没动过。真实 IP 一直在,只是没人用。
这两个字段压根不是一回事。$remote_addr 是「谁跟我建的这条 TCP 连接」,属于传输层的事实;X-Forwarded-For(下称 XFF)是「谁在请求里留了句话说自己从哪来」,属于应用层的一串字符。一个回答的是「谁连的我」,另一个说的是「谁自称是谁」。
前台是谁
左列那串地址是谁,直接问服务器最快:
curl -sI https://api.liuhangyv.top/login | grep -iE 'server|via'
# Server: ESA
# via: ens-cache31.l2eo166-17[24,0,DP], ens-cache26.cn8564[...]
Server: ESA 说明站点前置了阿里云 ESA(边缘安全加速)。我这条链路是:用户 → ESA 边缘节点 → 这台机器上的 OpenResty(反代到 127.0.0.1:8080)→ sub2api。请求先落到 ESA 的边缘节点,再由边缘节点回源,而回源时的出口 IP 在 47.123.117.0/24 里轮询,所以左列一直在变。1Panel 生成的站点配置里从来没有 set_real_ip_from 和 real_ip_header 这两行,nginx 也就不知道该去 XFF 里把真实 IP 取出来。

名单里没有,它就不认
像公司前台代收快递,包裹一路送到门口,寄件人地址写得清清楚楚,可前台签收时按自己的规矩贴了张一次性工牌,登记簿上记的是工牌号,不是寄件人。收件的人只看登记簿,自然以为快递是前台送来的。寄件人那一栏其实一直贴在包裹上,没被撕掉,只是没人往下翻。真实 IP 对应那个始终在包裹上的寄件人,回源 IP 对应那张工牌。
nginx 的 real_ip 模块本来能撕开这张工牌,但它有个前提:只信你明确告诉它要信的人。只有请求的来源 IP 落在 set_real_ip_from 声明的可信网段里,nginx 才会拿头里的值去改写 $remote_addr;不在这个名单里,那个头就只是一串普通字符串,没有任何模块会多看一眼——门禁只认登记过的工牌。
所以反向代理链路上「谁是谁」这件事,必须由运维显式声明,nginx 不会自己猜。光看流量推不出来:每个 IP 长得都一样,你说谁是代理,谁才是代理。
再往深一层,HTTP 头本来就是这么个东西:可追加、可伪造、无签名。协议里没有任何字段能证明「这条头是某个可信节点写的」,中间节点能加一条,客户端也能自己塞一条。所以一个头可不可信,协议保证不了,只能由你在 nginx 里配置决定。
不报错,也不告警
客户端 IP 一旦失真,依赖它的功能会一起失灵:审计日志记的是机房地址,风控和限流按机房地址分桶,API Key 的 IP 白名单形同虚设。这类问题最危险的地方是不报错、不告警、界面完全正常,只有你主动去比一句「这 IP 是不是我」,才会发现。
前面几篇里我一直把 ESA 当加速器写,接上之后访问确实快了。但我没意识到,它还顺手改掉了服务器眼里的「你是谁」。
四、修了三次才对
原因找到了,剩下的是怎么修。这件事改了三版,每一版都是被上一版的问题逼出来的。
第一版:直接写进站点配置
最自然的做法是在站点配置的 location / 里加两行:
set_real_ip_from 47.123.117.0/24;
real_ip_header X-Forwarded-For;
能用,但两个隐患都埋在里面:这份站点配置是 1Panel 生成的,下次重新生成时这两行就没了;/24 是我自己从日志里那串地址推出来的,纯属猜的。当时想着先凑合,回头看,第二条才是真问题。
第二版:挪到全局,换成官方名单
ESA 控制台里「安全防护 → 源站防护」给了官方回源 IP 列表。我把它写进一个新文件 /opt/1panel/www/conf.d/99-esa-realip.conf,一共 258 条,149 个 IPv4 段加 109 个 IPv6 段。
不继续放站点配置里,是因为 conf.d/*.conf 是在 nginx 的 http {} 块内部被 include 进去的,而 set_real_ip_from 和 real_ip_header 本来就是 http 上下文的合法指令——它们的作用域天生就是「所有站点」。
这样做有两个好处:1Panel 重新生成站点配置时碰不到这个文件;以后再有别的域名接上 ESA,也不用再配一遍。像改总闸,而不是挨个房间换开关。
顺带纠正一个精度问题。官方列表里的第一条是 47.123.117.0/25,不是我猜的 /24。/25 覆盖 128 个地址,/24 覆盖 256 个,多出来的那 128 个根本不在官方名单上,等于照着我猜的名单多信了一批地址。反过来更要命:set_real_ip_from 绝不能写 0.0.0.0/0,那等于允许任何人伪造 XFF,只要请求能连到源站,你随便填个头,就能把自己写成任意 IP。
第三版:换一个伪造不了的头
第二版还是建立在 XFF 上的,而 XFF 是通用事实标准,客户端自己就能塞一条进来。ESA 控制台「转换规则 → 托管转换 → 添加真实客户端 IP 标头」打开之后,边缘节点会注入一个私有头,于是把配置改成读它:
real_ip_header ali-real-client-ip;
real_ip_recursive on;
real_ip_recursive on 管的是头里带了多个地址的情况:从右往左跳过可信网段里的地址,取第一个不在名单上的,才算真实客户端。
ali-real-client-ip 和 XFF 的区别不在于格式,在于可伪造性。XFF 谁都能写,nginx 之所以敢认它,靠的全是 set_real_ip_from 那份名单——名单要是写宽了,头就是假的。ali-real-client-ip 由 ESA 边缘节点生成,客户端预先塞一个同名头,到边缘节点也会被覆盖掉。XFF 的信任有个前提,是我假设这条头是代理写的;ali-real-client-ip 不需要假设,这条头只可能是代理写的。


怎么确认它真的生效了
改完别只看文件,要拿真实请求在日志里验一遍:
curl -s -o /dev/null -w "%{http_code}" "https://api.liuhangyv.top/login?probe=check-$(date +%H%M%S)"
sudo grep -a 'check-<时间>' /opt/1panel/www/sites/api.liuhangyv.top/log/access.log | awk '{print $1}'
带时间戳是为了让这次请求在日志里唯一可定位,awk '{print $1}' 取的是第一列,也就是 nginx 眼里的 $remote_addr。打出来是我自己的公网 IP,才算真的对上。
这里有个小坑:日志必须用 grep -a。access.log 里含二进制字节,普通 grep 会把它判定成二进制文件,只甩一句 binary file matches 就拒绝输出,本来查得到的结果反而看不见了。-a 是强制按文本处理。
修到这儿我才反应过来,X-Forwarded-For 到底能不能信,其实取决于另一件事:
所有基于「可信头」的机制,都建立在一个前提上:源站不可被绕过。
五、顺手一查,撞见一个能绕过白名单的漏洞
真实 IP 还原好了,探针也验过了。按说该收工,我却顺手多看了一步:应用侧到底是怎么判断「这个请求从哪来」的。
然后就撞见了更麻烦的东西。
一张有顺序的信任清单
打开 sub2api 的 internal/pkg/ip/ip.go,客户端 IP 的取值逻辑就几行:
// CF-Connecting-IP > X-Real-IP > X-Forwarded-For ← 命中即返回
if forwarded := normalizeValidIP(c.GetHeader("CF-Connecting-IP")); forwarded != "" {
if !isPrivateIP(forwarded) { return forwarded }
}
关键在那四个字:命中即返回。它不是候选列表,是一张有顺序的信任清单——排第一的头一旦有值,后面两个根本不读。谁排第一,等于把「请求从哪来」的判定权交给了谁。
覆盖了两个,漏了第三个
回头翻 1Panel 生成的站点配置,proxy_set_header 一共两行:
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 覆盖了
proxy_set_header X-Real-IP $remote_addr; # 覆盖了
# CF-Connecting-IP —— 一行都没有
nginx 会用自己的值盖掉客户端传来的 XFF 和 X-Real-IP,它们到应用手里都是真的;CF-Connecting-IP 从没被覆盖过,客户端发来什么,应用就读到什么。
1Panel 的模板想不到 Cloudflare 这个私有头,不奇怪;奇怪的是应用把它排在了第一位。这个头本来是给 Cloudflare 准备的,我这条链路上根本没有节点会去写它——于是它退化成了任何客户端都能自己填的字段。
两条 curl,两次都成功
直接打两条:
# 路径一:经 ESA
curl -H "CF-Connecting-IP: 1.2.3.4" https://api.liuhangyv.top/api/v1/settings/public
# → 应用记录 client_ip = "1.2.3.4" ✗
# 路径二:绕过 ESA,直连源站(源站 IP 此处以占位符示出)
curl -k --resolve api.liuhangyv.top:443:<源站IP> \
-H "CF-Connecting-IP: 5.6.7.8" https://api.liuhangyv.top/api/v1/settings/public
# → 应用记录 client_ip = "5.6.7.8" ✗
两条全过。ESA 不管 CF-Connecting-IP,它原样透传。扎人的是那个对照组:同一时刻 nginx 侧记下的 $remote_addr 是准的,刚配好的 ali-real-client-ip 也带着真实 IP——nginx 是对的,是应用主动采信了客户端塞进来的那条头。
X-Forwarded-For 没什么好说的,它本来就是个谁都能填的普通请求头。CF-Connecting-IP 更糟:它本该由某个柜台盖章之后才写上去,可这个柜台压根不存在——于是谁写的就是谁的。
这不只是日志脏一点。API Key 的 IP 白名单、黑名单直接作废——伪造个名单内的 IP 就能过。审计日志的 IP 被污染,真出事溯不到源。限流按假 IP 分桶,换个头就是换一个新桶——固定一个伪造值打满次数触发限流后,轮换下一个就又能接着来。拿它去撞别人的密码,等于不限流。
两行配置,一个取舍
站点配置的 location / 块里补两行:
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr; # 由「追加」改为「覆盖」
第一行负责把那个没人管的头填成真实 IP。第二行是个取舍。原来的 $proxy_add_x_forwarded_for 是「追加」:把客户端传来的 XFF 原样保留,再补上自己的值,为的是保住整条转发链。可那前提是链路上有多跳你信得过的代理。我这条链路只有 ESA → nginx 一跳,真实 IP 已经躺在 $remote_addr 里,XFF 链留着没用,还会把脏数据带进应用,所以直接覆盖——追加还是覆盖,本来就看链路上有几跳是你自己人。
复测:三个头一起伪造
三个头一起伪造——CF-Connecting-IP、X-Forwarded-For、X-Real-IP,经 ESA 和直连源站各打一遍,应用记录下来的都是真实 IP。
nginx 的活是还原可信 IP,应用的活是照着用。应用不该替运维决定该信哪个头。 它多读一个客户端能随意填的头,前面所有还原就全白做。
修好一层,才发现底下还压着一层。
六、把这段时间的数据盘一遍
复测全对之后,我把这段时间攒下的数据翻出来盘了一遍,先看审计表。
1687 条记录里,1681 条是错的
总计 :1687 条
记录为 ESA 机房 IP:1681 条(99.6%)
时间范围 :2026-09-03 20:42 ~ 2026-10-01 18:41
修复后新增 :6 条,全部为真实 IP
1687 减 1681,正好是修复之后新增的那 6 条。剩下的 1681 条记的全是 47.123.117.x 这类回源 IP,跟真正发请求的人没有半点关系。
从站点部署到修复前,审计日志里没有一条 IP 是真实的。
这些错 IP 也补不回来。ESA 回源 IP 和真实用户 IP 之间没有任何映射关系,信息在回源那一跳就丢了:重放一遍能看出链路长什么样,看不出这条请求是谁发的。所以审计日志得隔段时间抽查一遍,看里面记的 IP 是不是真的。
ESA 实际注入了哪些头
顺手把托管转换那三个开关实测了一遍,头名跟官方文档对不上(末行的 Tls-Hash 是 JA3 指纹——把 TLS 握手的参数拼起来算的哈希,同一个客户端换个 IP 也不会变,用来在不看流量内容的前提下认设备):
| 开关 | 实际头名 | 实测值 |
|---|---|---|
| 添加真实客户端 IP 标头 | ali-real-client-ip | 真实 IP |
| 添加访问者位置标头 | ali-ip-country、ali-ip-city | CN、<城市> |
| 添加安全请求头 | Tls-Hash | 32 位 MD5 / JA3 指纹,<32 位指纹> |
文档里写的 Tls-Ja3、Tls-Ja4 源站一条都收不到,实际注入的头名是 Tls-Hash。三个开关我都对着源站收到的头名核过一遍,没照文档抄。
伪造测试留下的两个结论
经 ESA 的时候,伪造值会被强制覆盖掉;绕开 ESA 直接打源站 IP,伪造值就原样透传进应用。两条放在一起,就是这条链路上所有可信头机制的前提:源站不能被绕过。
阿里云控制台一直在提示「请先限制源站访问再信任它们」,我之前只当是句例行提醒。彻底加固还差一步——开「源站防护」,把安全组 443 入方向的来源限制成 ESA 回源 IP。这条我记进了待办。
前后一共改了 5 个地方
| # | 位置 | 改动 |
|---|---|---|
| 1 | data/config.yaml | 追加 webauthn 段(594 → 799 字节) |
| 2 | PostgreSQL settings 表 | session_binding_enabled: true → false |
| 3 | conf.d/99-esa-realip.conf | 新建,258 条 set_real_ip_from + ali-real-client-ip |
| 4 | conf.d/api.liuhangyv.top.conf | 补 CF-Connecting-IP 覆盖,XFF 改为覆盖 |
| 5 | ESA 控制台 | 开启真实 IP 标头 / 访问者位置标头 / 安全请求头 |
第 3 条那 258 条里,一半以上是 IPv4 段。第 4 条是易失的:1Panel 下次重新生成站点配置,它就没了。接完 CDN 记得验一次源站拿到的是不是真 IP,set_real_ip_from 别写 0.0.0.0/0——这两条是这次唯一能带走的。
回到后台那个开关上。第一次看见它的时候,它底下压着一行「请先配置有效的 RP ID 与允许的 HTTPS 来源」,点不动。我最初也只是想让登录更安全一点。这个开关得亲手点一下才会亮——我按了,用指纹登进去,没有被弹回登录页。
点完翻出那份改动清单,在第 4 条后面画了个记号——五处改动里,只有这一处是临时的。
评论交流
欢迎留下你的想法