自托管 Sentry 安全加固实录:从 Google 钓鱼告警到 nginx 路径级白名单

自托管 Sentry 安全加固实录:从 Google 钓鱼告警到 nginx 路径级白名单

一句话总结:阿里云 CLB 的 ACL 是绑在监听层的,按客户端 IP 一刀切。对自托管 Sentry 这种「admin 要严格白名单 + SDK 上报路径要公网开放」的混合场景,CLB 拦不住该拦的(搜索引擎、扫描),挡掉了不该挡的(全球客户端 SDK 上报)。正确姿势是把白名单逻辑下沉到 nginx 的 geo 块 + 路径级 ACL,CLB 那条 ACL 直接关掉。下沉过程中两个大坑:阿里云 CLB 源 IP 段 100.64.0.0/10 必须加进 set_real_ip_from;docker bind mount 单文件mv 替换会破坏 mount,必须用 cp

文中所有 IP/域名/CLB ID 都已替换成占位,照抄前请替换成你自己的。

缘起:Google Search Console 来了一条「欺骗性网页」

早上收到 Google Search Console 告警,被举报的 URL 是 https://sentry.example.com/auth/login/sentry,类型是「Deceptive pages(欺骗性网页)」。Google 把这页判成了钓鱼,理由可以猜:

  • 域名不是 sentry.io,但页面长得就是 Sentry 的官方登录 UI(self-hosted 开箱即用就是这个 UI)
  • 没有 robots.txt,登录页被搜索引擎抓到并归类成「冒充 Sentry 品牌」

第一反应是:我 CLB 上不是早就挂了 IP 白名单 ACL 吗?怎么爬到的?

aliyun slb DescribeLoadBalancerHTTPSListenerAttribute \
  --LoadBalancerId <CLB_ID> --ListenerPort 443 --RegionId <REGION>
# AclStatus: "on", AclType: "white"

ACL 是 on,绑了 20 条规则的公司网络白名单。理论上 Google 爬虫 IP(66.249.x.x)不在里面,CLB 应该 reset 它的连接。

结论:告警很可能是历史的,ACL 启用之前 Google 已经爬过一次,告警状态不会自动消除,得手动复审。

到这里我以为这是个简单的「申诉 Google 复审」收尾事项。结果一查 nginx access log,发现了远比 Google 告警严重得多的问题。

真正的炸弹:SDK 上报正在被 CLB 静默丢弃

自托管 Sentry 的核心场景是接收客户端 SDK 上报——你部署在用户设备上的 iOS/Android/Web 应用,把崩溃信息发到 Sentry。这意味着上报源 IP 就是用户终端 IP——家宽、移动网络、各国各地区都有。

但这个 CLB 的白名单只覆盖了:办公网 IP、跳板机 IP、电信联通几个段、香港新加坡几个段。也就是说,绝大部分客户端 SDK 上报根本到不了后端,被 CLB 在传输层直接 RST。SDK 那边只能感受到 connection reset,retry 几次就放弃。

更糟糕的是:

  • Sentry 后端看不到任何记录(连 access log 都没有,CLB 在更前面就丢了)
  • 在 Sentry Stats 面板看到的事件量曲线,是被 CLB 削过的「假数据」
  • 之前曾经看到一些 IP 的上报有正常 200 响应,那是因为这些 IP 恰好落在白名单的某个 /25 段里——纯属巧合

盯着 access log 看:

100.122.19.236 - - [...] "POST /api/9/envelope/ HTTP/1.1" 200 ... "192.0.2.20"

第一字段是 CLB 内网 IP(100.122.x.x),最后字段是真实客户端 IP——这是个白名单内的 IP,CLB 放行了。但 access log 里没有任何用户家宽/移动 IP 的痕迹,因为它们根本到不了 nginx。

排查到这一步,原本的「Google 误判」变成了二级问题。真正要救的是被丢的 SDK 上报

为什么 CLB ACL 解不了这事

需求很清晰:

路径 期望策略
/api/<projectId>/envelope/ 等 SDK 上报 公网开放,全球各地用户都得能上报
/auth/login/*/organizations/*、根路径 严格白名单,只允许公司网段访问

而阿里云 CLB(传统型负载均衡)的限制:

  • 七层监听(HTTPS:443、HTTP:80)支持按 domain/path 做转发规则
  • ACL 是绑在监听层的,整个监听一套 ACL,不能按 rule 分别配
  • 想做路径级 ACL,要么换 ALB(应用型负载均衡),要么自己在后端做

迁移 ALB 工作量太大(要换 IP、改 DNS、迁证书),而且没必要——Sentry self-hosted 本来就自带 nginx 容器,我直接改它就行

方案:把白名单下沉到 nginx 的 geo + 路径级 deny

self-hosted 26.5.0 自带的 nginx.conf 本来就在 nginx 层把 ingest 和 admin 分流了:

# ingest 路径 → relay 容器
location /api/store/                 { proxy_pass http://relay; }
location ~ ^/api/[1-9]\d*/           { proxy_pass http://relay; }
location ^~ /api/0/relays/           { proxy_pass http://relay; }
# 管理 UI → sentry web 容器
location /                           { proxy_pass http://sentry; }
location /_assets/                   { proxy_pass http://sentry/_static/dist/sentry/; }
location /_static/                   { proxy_pass http://sentry; }

物理路径已经分开了,只需要给 admin location 加 IP 白名单判断。最干净的写法是 geo 模块:

http {
    # 一个变量,命中白名单时为 1,否则 0
    geo $admin_allowed {
        default                0;
        192.168.1.0/24         1;    # 办公网
        # ... 共 20 条占位,替换成你自己的
    }

    server {
        listen 80;

        # ingest 公开,不动
        location ~ ^/api/[1-9]\d*/ { proxy_pass http://relay; }

        # admin 加 deny
        location / {
            if ($admin_allowed = 0) { return 403; }
            proxy_pass http://sentry;
        }
        location /_assets/ {
            if ($admin_allowed = 0) { return 403; }
            proxy_pass http://sentry/_static/dist/sentry/;
        }
        # /_static/ 同理
    }
}

理论上配置完 nginx -s reload 就完事。实际操作时一连踩了两个大坑,下面分别讲。

坑一:阿里云 CLB 源 IP 段必须信任,否则 geo 永远 default

第一次 reload 完做验证测试,发现一个奇怪现象:白名单内的 IP 也被 403。看 access log:

100.122.19.236 - - "POST /api/9/envelope/" 200 ... "192.0.2.20"

$remote_addr 是 CLB 内网 IP 100.122.x.x不是真实客户端 IP。这是因为 nginx 的 real_ip 模块需要 set_real_ip_from 告诉它「哪些源 IP 是可信任的代理,可以从 X-Forwarded-For 里取真实客户端 IP」。

self-hosted 默认 nginx.conf 里只信任了 docker 网桥 IP 和 RFC1918 私网段:

set_real_ip_from 172.17.0.0/16;
set_real_ip_from 172.18.0.0/16;
# ...
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;

没有阿里云 CLB 源 IP 段。CLB 转发到后端时,连接源 IP 落在 100.64.0.0/10(CGNAT 段,阿里云内部回源用的)。这段不在 set_real_ip_from 里,nginx 不信任 CLB 给的 XFF 头,于是 $remote_addr 留在 CLB IP,geo 用 CLB IP 查白名单——全部 default 0——所有 admin 请求 403。

修法一行:

set_real_ip_from 100.64.0.0/10;     # 阿里云 CLB 源 IP 段

加完 reload,access log 立刻变成:

192.0.2.10 - - "GET /auth/login/sentry/" 200 ... "192.0.2.10"

$remote_addr 变成了真实客户端 IP。geo 拿到的就是 192.0.2.10,命中白名单,放行 200。

坑二:docker bind mount 单文件 + mv 会破坏 mount

这个坑更隐蔽。改完 nginx.conf,我用了「干净」的部署流程:

cp nginx.conf nginx.conf.bak-2026-05-28      # 备份
cat > nginx.conf.new <<'EOF' ...EOF           # 写新版
diff -u nginx.conf nginx.conf.new             # 人工 review
docker cp nginx.conf.new <container>:/tmp/    # 容器内 nginx -t 走 docker 网络
docker exec <container> nginx -t -c /tmp/nginx.conf.new   # 通过
mv nginx.conf.new nginx.conf                  # 替换
docker exec <container> sh -c 'nginx -t && nginx -s reload'   # RELOAD_OK

reload 成功。开始验证:

HTTP/1.1 200 OK     # 白名单 IP 访问 admin
HTTP/1.1 200 OK     # 非白名单 IP 访问 admin ← ?!

应该有 admin ACL 拦截才对。开始诊断:

docker exec <container> sha256sum /etc/nginx/nginx.conf
# ae7dc2e70e59a6e90b252c8003b0a8e2e02315d840b7af8f0ddb3cc64119ef49
sha256sum /data/self-hosted/nginx.conf
# 12a8d60e7e1c8bb3e217cd6e0dbdc7e257adec7526c4bd1cd3229a5e51cee9b7

容器看到的 sha 还是旧的,宿主机已经是新的——bind mount 失联了。

根因:docker 的 bind mount 对单个文件绑的是 inode,不是文件路径。mv 在底层调用 rename(),宿主机上 nginx.conf 这个 dentry 指向了一个新 inode(即 nginx.conf.new 原来的那个 inode)。旧 inode 没被删除(被容器 mount 持有),但宿主机上没人引用了;容器仍然只能看到旧 inode 的内容。

整理一张对照表(验证过):

操作 inode 改变? 容器立即看到新内容? reload 行不行
vi nginx.conf 原地保存 ❌ 不变 ✅ 立即 ✅ 行
sed -i ... nginx.conf ❌ 不变 ✅ 立即 ✅ 行
cp newfile nginx.conf ❌ 不变(truncate dest + write) ✅ 立即 ✅ 行
cat newfile > nginx.conf ❌ 不变 ✅ 立即 ✅ 行
mv newfile nginx.conf 变了 ❌ 旧 inode 还在 mount 上 必须 restart 容器

修复有两条路:

  • docker compose restart nginx(重建 mount,但 2-3 秒服务中断)
  • 后续编辑改用 cp(保 inode),reload 就能生效,0 中断

我当时已经用 mv 了,没有其他干净的修法,先 restart 容器,恢复 bind mount。后续每次编辑都强制用 cp,并加自检:

stat -c %i /data/self-hosted/nginx.conf
docker exec <container> stat -c %i /etc/nginx/nginx.conf
# 两个 inode 相同 → bind mount 健康

验证:模拟 Googlebot 视角

改造完成、CLB ACL 关掉(AclStatus=off)之后,做了一次端到端验证。最重要的是模拟 Googlebot 来爬,看看 Search Console 复审的 reviewer 一旦再爬一次会看到什么:

curl -sSI \
  -H 'X-Forwarded-For: 66.249.66.1' \
  -H 'User-Agent: Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' \
  http://127.0.0.1:9000/auth/login/sentry/
# HTTP/1.1 403 Forbidden
# Content-Type: text/html; charset=utf-8
# Content-Length: 849

返回的不再是 Sentry 登录页,而是自己加的 403 说明页:

<h1>访问被拒绝 / Access Denied</h1>
<p>您的 IP <code>66.249.66.1</code> 不在白名单内。</p>
<p>如需访问,请将此 IP 提供给管理员添加白名单。</p>

这个错误页是用 error_page + named location 实现的,$remote_addr 自动注入访问者真实 IP:

error_page 403 = @denied_admin;
location @denied_admin {
    default_type "text/html; charset=utf-8";
    return 403 '<!DOCTYPE html><html lang="zh-CN">...<p>您的 IP <code>$remote_addr</code> 不在白名单内。</p>...</html>';
}

外加 robots.txt:

location = /robots.txt {
    add_header Content-Type text/plain;
    return 200 "User-agent: *\nDisallow: /\n";
}

Googlebot 抓 robots.txt 会拿到 Disallow: /,应该会主动跳过;即使没跳过,登录页也已经是无登录表单的纯说明页,不可能再被判钓鱼。

加固尝试:admin 限流(后面会被回滚)

CLB ACL 关掉后,全公网都能打 admin 路径(虽然返回 403)。加了限流保险:

http {
    limit_req_zone $remote_addr zone=adminreq:10m rate=10r/s;
}
server {
    location / {
        limit_req zone=adminreq burst=20 nodelay;
        if ($admin_allowed = 0) { return 403; }
        proxy_pass http://sentry;
    }
}

需要注意 nginx 阶段顺序if 在 REWRITE 阶段,limit_req 在 PREACCESS 阶段,REWRITE 在前。所以非白名单 IP 在 if 阶段就被 403 了,根本走不到 limit_req。限流实际只对白名单内的客户端生效——但这恰好是真正想保护的(防止白名单内某个失控/被攻陷的客户端把 Sentry web 后端打挂)。非白名单的扫描器,nginx 返回 403 的成本极低,不需要限流。

关掉 CLB ACL 后,业务量级真相浮现

最后一步关掉 CLB 监听级 ACL:

aliyun slb SetLoadBalancerHTTPSListenerAttribute \
  --LoadBalancerId <CLB_ID> --ListenerPort 443 \
  --AclStatus off --RegionId <REGION>
aliyun slb SetLoadBalancerHTTPListenerAttribute \
  --LoadBalancerId <CLB_ID> --ListenerPort 80 \
  --AclStatus off --RegionId <REGION>

ACL 实体保留(还绑着其他 LB),只关掉这两个监听的开关。

效果立刻出来:access log 里源 IP 一片陌生面孔,墨西哥、印尼、巴西、东南亚各国——都是之前被 CLB 静默丢的真实用户。Sentry stats 事件量曲线上扬。改造前我们看到的「正常事件量」其实是被 CLB 削过的「假数据」

统计 90 秒窗口的 access log 源 IP 分布:

docker logs --since 90s <container> 2>&1 \
  | awk '{print $1}' \
  | sort | uniq -c | sort -rn | head -20

输出:

100  196.32.86.X
 64  68.2.67.X
 33  172.56.212.X
 32  107.116.168.X
 31  174.227.249.X
 ...

每个 IP 都是不同国家不同运营商的真实终端,之前一个都看不见

后记:限流被回滚(rate limit 对 SPA 不友好)

改完两小时后再扫一遍 access log,3 分钟窗口出现了 65 个 503,分布如下:

2 503 /_static/dist/sentry/chunks/93767.accf0d8eae2cf4a1.js
2 503 /_static/dist/sentry/chunks/79339.3e3c52b7c353192e.js
2 503 /_static/dist/sentry/chunks/71005.45618ed343d17813.js
... (多个 JS chunk)

被限流的是 Sentry 后台 UI 自己的 JS chunk

根因:Sentry web UI 是个重型 SPA,打开后台时浏览器并发请求 30~50+ 个 JS chunk。burst=20 rate=10r/s 这个配置对一次页面加载就直接被打穿——前 20 个 chunk 抢到桶里的令牌,其余的 chunk 全被 503。

也就是说,限流误伤了正常使用后台的白名单内管理员。本来想防的是「内部某个客户端失控把 Sentry web 打挂」,结果反过来挂了自己。

回滚很简单——之前留的 v3 备份正好就是「加限流之前」的状态:

cp /data/self-hosted/nginx.conf.bak-...-v3 /data/self-hosted/nginx.conf
docker exec <container> sh -c 'nginx -t && nginx -s reload'
# RELOAD_OK

再跑 50 个并发 chunk 请求测试:全部 200,没有任何 503。

教训

  • 限流对 SPA 不友好。现代前端架构动辄几十个 chunk 并发加载,limit_req 这种按请求数算的限流很容易误伤
  • 真要加,应该排除 /_static//_assets/ 这种静态资源,只在 API/页面入口路径限
  • 或者干脆只在 SDK 上报路径加限流(按 project_id 维度),admin 路径不限——逻辑上更通顺
  • 「先观察再加固」永远是对的,看到风险再针对性加,不要预防性加一个全局限流

后来把限流整段摘掉,nginx.conf 里只留 geo 白名单当主防线。limit_req 等以后真有 admin 滥用迹象再单独加 location 处理。

验证脚本

顺手也看一下 5xx 有没有异常(关键是不能因为放开后端被打挂):

# 状态码分布
docker logs --since 15m <container> 2>&1 \
  | awk '{for(i=1;i<=NF;i++) if($i ~ /^HTTP\//) {print $(i+1); next}}' \
  | sort | uniq -c | sort -rn

# 只看 5xx 来源
docker logs --since 15m <container> 2>&1 \
  | awk '$9 ~ /^5/ {print $1, $9}' \
  | sort | uniq -c | sort -rn | head

我跑下来 5xx 几乎为 0,只剩自己测试限流时主动触发的几条 503,干净。

快速参考

nginx.conf 关键骨架(替换占位即可用)

http {
    # ↓ 阿里云 CLB 源 IP 段(一定要加!)
    set_real_ip_from 100.64.0.0/10;
    # docker 默认网段(self-hosted 自带,保留)
    set_real_ip_from 172.17.0.0/16;
    set_real_ip_from 172.18.0.0/16;
    set_real_ip_from 10.0.0.0/8;
    real_ip_header X-Forwarded-For;
    real_ip_recursive on;

    geo $admin_allowed {
        default          0;
        192.168.1.0/24   1;        # 你的办公网/跳板机段,占位
    }

    upstream relay  { server relay:3000; keepalive 2; }
    upstream sentry { server web:9000;   keepalive 2; }

    server {
        listen 80;

        error_page 403 = @denied_admin;
        location @denied_admin {
            default_type "text/html; charset=utf-8";
            return 403 '<h1>Access Denied</h1><p>Your IP <code>$remote_addr</code> is not whitelisted.</p>';
        }

        location = /robots.txt {
            add_header Content-Type text/plain;
            return 200 "User-agent: *\nDisallow: /\n";
        }

        # 公网开放:SDK 上报
        location ~ ^/api/[1-9]\d*/ { proxy_pass http://relay; }
        location /api/store/       { proxy_pass http://relay; }
        location ^~ /api/0/relays/ { proxy_pass http://relay; }

        # 白名单:管理 UI(参考「后记」一节,不建议在这里加 limit_req)
        location / {
            if ($admin_allowed = 0) { return 403; }
            proxy_pass http://sentry;
        }
        location /_assets/ {
            if ($admin_allowed = 0) { return 403; }
            proxy_pass http://sentry/_static/dist/sentry/;
        }
        location /_static/ {
            if ($admin_allowed = 0) { return 403; }
            proxy_pass http://sentry;
        }
    }
}

bind mount 单文件编辑铁律

  • 编辑用 vi / sed -i / cp newfile target —— 不改 inode,容器立即看到新内容,reload 即可生效
  • 绝不用 mv —— 改 inode,bind mount 失联,必须 docker restart 重建
  • 编辑完跑三层自检:
# 1. inode 层
stat -c %i /path/on/host/nginx.conf
docker exec <container> stat -c %i /etc/nginx/nginx.conf
# 两者相同 → bind mount 健康

# 2. 文件内容层
sha256sum /path/on/host/nginx.conf
docker exec <container> sha256sum /etc/nginx/nginx.conf
# sha 相同 → 容器看到新内容

# 3. nginx 进程层
docker exec <container> nginx -T | grep <你刚加的 IP>
# 能 grep 到 → reload 已生效

关闭阿里云 CLB 监听级 ACL

aliyun slb SetLoadBalancerHTTPSListenerAttribute \
  --LoadBalancerId <CLB_ID> --ListenerPort 443 \
  --AclStatus off --RegionId <REGION>
aliyun slb SetLoadBalancerHTTPListenerAttribute \
  --LoadBalancerId <CLB_ID> --ListenerPort 80 \
  --AclStatus off --RegionId <REGION>

ACL 实体可以保留(如果还绑在其他 LB 上),只关掉这两个监听的开关即可。

几条易错点

  • set_real_ip_from 100.64.0.0/10 必须加(阿里云 CLB 内部回源段),否则 geo 用的是 CLB 内网 IP,白名单永远 default
  • nginx 阶段顺序if(REWRITE)在前,limit_req(PREACCESS)在后;想限流非白名单 IP 在 nginx 这层做不到(也没必要)
  • set_real_ip_from 不要把 127.0.0.0/8 加进去——会让容器内任何进程都能伪造客户端 IP;测试用的 curl 应该从宿主机发起(源 IP 是 docker 网桥 IP,已经在 set_real_ip_from 里了)
  • 公开发布前一定先模拟 Googlebot UA + 非白名单 XFF 自测一次:返回页应该是纯说明、无登录表单、无品牌 logo
posted @ 2026-05-28 12:37  Hello_worlds  阅读(26)  评论(0)    收藏  举报