给内网一台应用服务器上的两个 HTTP 服务(8089 静态前端、5001 Flask API)做了来源 IP 白名单收敛。最痛的两点:白名单里有"域名"来源(会换 IP、且调用方分布在多台机器),以及后端进程直绑端口可以绕过 nginx。本文给出"脚本动态解析生成 include 片段 + nginx 接管对外端口"的完整方案,附 5 个真实踩坑(含 return 指令绕过白名单这种隐蔽坑)。
1. 背景与目标
一台内网应用服务器上用 nginx 挂了多个服务:8089 反代一个前端静态站,5001 是一个 Flask + gunicorn 的业务 API。此前全网可达,安全上要求收敛来源,只允许:
- 本机(nginx 自己、本机进程调用)
- 一个用户网段(/24)
- 一台指定主机(http/https 均放行------源 IP 判定天然协议无关)
- 两个内网域名背后的调用方(对方平台会主动调用我们的 API)
约束条件:
- 域名会换 IP,静态写死会失效;
- 客户端 URL 不能变(5001 的调用方有 MCP 客户端、后端服务、命令行脚本等,改不起);
- gunicorn 直绑
0.0.0.0:5001且宿主机 firewalld inactive------光在 nginx 上加白名单没用,直连端口就绕过去了; - 域名解析失败(内网 DNS 抖动)不能导致白名单误封。
成功标准:白名单外来源全部 403;白名单内来源业务无感知;域名换 IP 后 10 分钟内自动生效;日志按月落盘、超 3 个月自动清理。
2. 技术选型
| 维度 | A. allow/deny 静态写死 | B. geo + map $host | C. 脚本动态解析生成 include | D. firewalld/iptables | E. 应用层鉴权 |
|---|---|---|---|---|---|
| 支持域名来源 | ❌ 仅 IP/CIDR | ⚠️ 检查的是 Host 头 | ✅ 解析成网段 | ❌ 仅 IP | 视实现 |
| Host 头伪造风险 | --- | ❌ 可伪造,形同虚设 | ---(按真实源 IP) | --- | --- |
| 防直连绕过 nginx | ✅(在 nginx 层就拦) | ✅ | ✅ | ❌ 需另封后端端口 | ✅ |
| 域名换 IP 自适应 | ❌ | ❌ | ✅ cron 定时刷新 | ❌ | --- |
| 配置校验/回滚 | 手动 | 手动 | ✅ 脚本内置 | 手动 | 手动 |
| 改动量 | 小 | 小 | 中(一个脚本) | 中 | 大 |
选型结论:C 为主 ------nginx allow/deny(ngx_http_access_module)在 access 阶段按真实来源 IP 判定,语义正确且对应用零侵入;域名来源用脚本定时解析成 /24 网段写进一个被 include 的片段文件,变化时自动 nginx -t + reload。方案 B 看似优雅,但 map $host 检查的是请求的 Host 头(目的地)而非来源,且 Host 头客户端随便填,是安全漏洞不是白名单(坑 1 详述)。方案 D 只在防火墙层做,粒度和可运维性都差;E 改造成本最高。
3. 架构设计
3.1 总体架构
用户网段 ──────┐
指定主机 ──────┤ ① HTTP :8089(前端静态)
平台域名网段 ──┤ ② HTTP :5001(业务 API)
▼
┌──────────────────── 应用服务器 ────────────────────┐
│ nginx │
│ server :8089 ── include whitelist.inc ──┐ │
│ server :5001 ── include whitelist.inc ──┤ │
│ │ ③ proxy_pass ▼ │
│ └──────────▶ 127.0.0.1:5002 deny all │
│ │ (其余 403) │
│ gunicorn(Flask) 绑 127.0.0.1:5002 ◀─┘ │
│ (仅本机可达,绕不过白名单层) │
│ │
│ update-nginx-whitelist.sh ④(cron 每 10 分钟) │
│ getent 解析域名 → 生成 whitelist.inc │
│ → diff 无变化即退出 / 有变化 nginx -t+reload │
└────────────────────────────────────────────────────┘
其他任意来源 ──────▶ 403
| 组件 | 载体/端口 | 职责 |
|---|---|---|
| whitelist.inc | /etc/nginx/whitelist.inc | 共享白名单片段,被两个 server 块 include |
| 动态解析脚本 | /usr/local/sbin/update-nginx-whitelist.sh | 域名→网段解析、变更检测、校验回滚、日志 |
| 前端 server | nginx :8089 | 静态站 + include 白名单 |
| API 反代 server | nginx :5001 | include 白名单 + proxy_pass 5002 |
| 后端进程 | gunicorn 127.0.0.1:5002 | Flask 业务,仅本机可达 |
端到端数据流走读:一个 MCP 客户端调用业务 API------请求 ② 打到 :5001,nginx 在 access 阶段 用 whitelist.inc 判定源 IP(放行则继续,否则直接 403,不进 location);放行后 ③ proxy_pass 到 127.0.0.1:5002,gunicorn(1 worker / 32 线程)处理返回。域名变更流:④ cron 每 10 分钟触发脚本,getent 解析域名,生成的片段与现文件 cmp 相同则零动作退出(绝大多数运行都是这条路径,不空转 reload);不同则备份→写入→nginx -t→reload,校验失败自动回滚旧文件。
3.2 组件 1:whitelist.inc 共享片段
nginx
# 本文件由 /usr/local/sbin/update-nginx-whitelist.sh 自动生成,勿手改
# 静态来源:本机回环/本机IP/用户网段/指定主机
allow 127.0.0.1;
allow 198.18.7.4;
allow 198.51.100.0/24;
allow 192.0.2.177;
# 动态域名来源(脚本解析,放行解析 IP 所在 /24 网段,解析失败时保留旧网段)
allow 203.0.113.0/24; # kb.example.internal (203.0.113.1)
allow 192.0.2.0/24; # sso.example.internal (192.0.2.113)
deny all;
(文中 IP 均为 RFC 5737/2544 文档保留段示例,替换成自己的即可。)
| 设计点 | 决策 | 理由 |
|---|---|---|
| 独立片段文件而非写进各 conf | 两个 server 块 include 同一份 |
白名单来源单点维护,新增受控服务一行 include 即接入 |
| 注释里带"域名 (解析IP)" | 生成时写入 | 事后排障一眼能看出某条 allow 是哪个域名、当时解析到哪,不用翻脚本日志 |
| 文件头标注"勿手改" | 首行注释 | 防止有人手改后被脚本下次运行覆盖,或误以为可手改 |
deny all 收尾 |
显式 | nginx access 模块顺序匹配,缺省放行是事故源头 |
3.3 组件 2:动态解析脚本
完整脚本(可直接抄,改 DOMAINS 和 STATIC_ALLOWS 即可用):
bash
#!/bin/bash
# 解析域名并放行其所在 /24 网段,生成 nginx 白名单片段;
# 网段变化时备份+nginx -t+reload(校验失败自动回滚);
# 解析失败保留旧网段不误封;日志按月写入并清理 3 个月前旧日志。
set -u
WHITELIST_FILE=/etc/nginx/whitelist.inc
LOG_DIR=/etc/nginx/log
DOMAINS="kb.example.internal sso.example.internal"
STATIC_ALLOWS="127.0.0.1 198.18.7.4 198.51.100.0/24 192.0.2.177"
LOG_FILE="$LOG_DIR/nginx-whitelist-$(date +%Y%m).log"
mkdir -p "$LOG_DIR"
log() { echo "[$(date '+%Y/%-m/%-d %H:%M:%S')] $*" >> "$LOG_FILE"; }
# 清理 3 个月(92 天)前的旧日志
find "$LOG_DIR" -name 'nginx-whitelist-*.log' -mtime +92 -delete
TMP=$(mktemp /tmp/whitelist.XXXXXX) || exit 1
{
echo "# 本文件由 update-nginx-whitelist.sh 自动生成,勿手改"
for ip in $STATIC_ALLOWS; do
echo "allow $ip;"
done
for d in $DOMAINS; do
ip=$(getent hosts "$d" | awk '{print $1}' | head -1)
if [ -n "$ip" ]; then
subnet=$(echo "$ip" | awk -F. '{print $1"."$2"."$3".0/24"}')
echo "allow $subnet; # $d ($ip)"
log "resolved $d -> $ip, allow $subnet"
else
# fail-safe:解析失败时从旧文件回填该域名的旧网段
old=$(grep "# $d (" "$WHITELIST_FILE" 2>/dev/null | tail -1)
if [ -n "$old" ]; then
echo "$old"
log "WARN resolve $d failed, keep old entry: $old"
else
log "WARN resolve $d failed, no old entry to keep"
fi
fi
done
echo "deny all;"
} > "$TMP"
if [ -f "$WHITELIST_FILE" ] && cmp -s "$TMP" "$WHITELIST_FILE"; then
log "whitelist unchanged, skip"
rm -f "$TMP"
exit 0
fi
cp "$WHITELIST_FILE" "$WHITELIST_FILE.bak" 2>/dev/null
cp "$TMP" "$WHITELIST_FILE"
rm -f "$TMP"
if nginx -t >> "$LOG_FILE" 2>&1; then
nginx -s reload
log "whitelist changed, nginx reloaded"
else
cp "$WHITELIST_FILE.bak" "$WHITELIST_FILE" 2>/dev/null
log "ERROR nginx -t failed, whitelist rolled back"
exit 1
fi
关键设计点逐条(决策 + 理由 + 不这么选会怎样):
| 设计点 | 决策理由 | 不这么选会怎样 |
|---|---|---|
getent hosts 而非 nslookup/dig |
getent 走系统 NSS 解析链,和 nginx 所在进程看到的解析行为一致,且最小安装必带 | nslookup 只测 DNS 服务器,可能和 /etc/hosts、NSS 结果不一致,生成"理论上对实际不对"的网段 |
| 放行 /24 网段而非单 IP | 平台类域名的调用方分布在同网段多台机器(坑 3 的血泪) | 单 IP 一上线上就误伤业务,403 排查成本高 |
| 解析失败回填旧条目(fail-safe) | DNS 抖动是常态,白名单宁可陈旧不可误封 | 一次 DNS 超时就把业务网段删掉,reload 后全量 403 |
cmp 无变化即退出 |
绝大多数 cron 运行无变化,避免无意义 reload | 每 10 分钟 reload 一次,worker 反复重启,长连接/上传被打断 |
写入前备份 + nginx -t 失败回滚 |
生成的片段进了主配置链,语法/引用错误会波及所有站点 | 脚本 bug 直接把 nginx 打挂,且是无人值守时段 |
日志按月文件 + -mtime +92 -delete |
每月一个文件便于按月归档检索,92 天≈3 个月 | 单文件无限增长;或依赖人工定期清理(永远不会有人做) |
set -u |
未定义变量直接报错退出 | 变量名打错静默生成残缺白名单 |
cron 安装(保留原有任务,别用 crontab -e 覆盖式写入):
bash
( crontab -l 2>/dev/null; echo '*/10 * * * * /usr/local/sbin/update-nginx-whitelist.sh' ) | crontab -
3.4 组件 3:8089 前端 server 接入
nginx
server {
listen 8089;
server_name _;
include /etc/nginx/whitelist.inc; # 唯一改动
root /opt/app/frontend/dist;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|svg|woff2)$ {
expires 7d;
}
}
include 放 server 层 而非 location 层:access 指令在 server 层会对该 server 所有 location 生效,一处覆盖全站;放在某个 location 里则其他 location(比如后来新加的)容易漏防。原先有一个 location = /health { return 200; } 被我整段删除 ------它是个白名单漏洞(坑 2),健康探测改用 /(白名单内拿 index.html 200,白名单外 403,同样能判定存活)。
3.5 组件 4:5001 端口接管(核心难点)
约束是"客户端 URL 不能变 + 后端原来直绑 0.0.0.0:5001"。做法是端口对调:nginx 接管对外 5001,gunicorn 退守本机 5002。
第一步,改 systemd unit 并重启:
bash
cp /etc/systemd/system/app-api.service /etc/systemd/system/app-api.service.bak
sed -i 's/--bind 0.0.0.0:5001/--bind 127.0.0.1:5002/' /etc/systemd/system/app-api.service
systemctl daemon-reload && systemctl restart app-api
ss -tlnp | grep -E '5001|5002' # 确认 5002 只在 127.0.0.1 上
第二步,新增反代配置:
nginx
server {
listen 5001;
server_name _;
include /etc/nginx/whitelist.inc;
client_max_body_size 50m; # 对齐后端图片上传上限
location / {
proxy_pass http://127.0.0.1:5002;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 120s; # 对齐 gunicorn 超时
}
}
| 设计点 | 决策理由 |
|---|---|
| gunicorn 退到 127.0.0.1 而非另一个对外端口 | 只要还监听对外地址,白名单就形同虚设(firewalld inactive,没人帮你封第二层) |
客户端 URL 保持 :5001 |
端口对调后所有调用方(MCP 客户端、后端服务、脚本)零改动,这是整个方案能平滑上线的前提 |
X-Real-IP/X-Forwarded-For 透传 |
后端日志/风控仍能看到真实来源 IP,否则全是 127.0.0.1,排障失明 |
proxy_read_timeout 对齐后端 |
nginx 默认 60s 短于后端 120s 时,慢接口会在代理层被掐断,表现为莫名 504 |
client_max_body_size 显式放大 |
默认 1m,带图片上传的 API 不配必炸 413 |
注意 proxy_pass 及其伴随指令必须写在 location 里 ,写在 server 层 nginx -t 直接 emerg 报错(坑 5)。
3.6 组件 5:验证方案设计
白名单这种"负向功能"(拦截)最怕假阳性/假阴性,验证矩阵要覆盖四个象限:
| 来源 | 位置 | 预期 |
|---|---|---|
| 白名单外机器 | 借一台已知凭据、不在任何白名单网段的跳板机 | 8089 /、8089 /health、5001 全部 403 |
| 白名单内办公机 | 本机(在用户网段内) | 全部 200 |
| 服务器本机 | 127.0.0.1 和本机 IP | 200(本机进程调用不受影响) |
| 真实业务链路 | 经 :5001 冒烟一个带鉴权头的查询接口 | 正常返回业务 JSON |
关键点:"白名单外"必须用真实第三方机器,不能拿白名单内的机器"假装"是外部来源(坑 4)。另外所有 curl 记得清掉代理环境变量(本机常设了失效代理,会干扰判断):
bash
HTTP_PROXY= HTTPS_PROXY= http_proxy= https_proxy= curl -s -o /dev/null -w '%{http_code}\n' http://<服务器IP>:5001/api/health
4. 部署步骤清单
bash
# 1. 上传脚本并首次运行,生成 whitelist.inc
/usr/local/sbin/update-nginx-whitelist.sh
cat /etc/nginx/whitelist.inc # 人工核对网段
# 2. 改 8089 conf(先备份):server 块加 include,删掉 /health 的 return location
cp /etc/nginx/conf.d/frontend.conf /etc/nginx/conf.d/frontend.conf.bak.$(date +%Y%m%d%H%M%S)
# 编辑后:
nginx -t && nginx -s reload
# 3. 端口对调(见 3.5):改 unit → daemon-reload → 重启后端 → 新增 travel 反代 conf → nginx -t && reload
# 4. 装cron(见 3.3)
# 5. 按验证矩阵全量测试(见 3.6)
# 6. 手动再跑一次脚本,确认幂等输出 "whitelist unchanged, skip",
# 并检查 /etc/nginx/log/nginx-whitelist-YYYYMM.log 的时间戳格式
5. 踩坑记录(共 5 个)
坑 1:allow/deny 不支持域名,geo/map $host 方案是伪白名单
现象 :需求是"放行某两个域名的调用",第一反应写 allow kb.example.internal;,nginx -t 直接报错:
text
nginx: [emerg] invalid parameter "kb.example.internal" in /etc/nginx/conf.d/xxx.conf:12
随后想用 geo + map $host 组合"按域名判断",配置写出来语法是通的。
原因 :ngx_http_access_module 的 allow/deny 只接受 IP、CIDR 和 unix socket,根本不支持域名 。而 map $host 检查的是请求头里的 Host 字段------那是"目的地"不是"来源",且客户端可以在请求里随便填一个合法 Host 绕过,用它做访问控制是可伪造的安全漏洞,不是白名单。源 IP 才是 TCP 层不可伪造(在可信网络内)的判定依据。
解决 :域名来源改为"脚本定时 getent 解析 → 写成 allow 网段 → include"(即 3.3 的方案),nginx 侧永远只见 IP/CIDR。
预防:设计白名单时先问一句"这个判定依据客户端能不能伪造?"------Host、Referer、User-Agent 全能伪造,只有源 IP/端口相对可信。涉及域名的需求一律解析成 IP 再进 nginx。
坑 2:location /health { return 200; } 完全绕过白名单
现象 :白名单上线后验证时,白名单外机器访问其他路径全部 403,唯独 /health 返回 200------白名单形同虚设:
bash
$ curl -s -o /dev/null -w '%{http_code}\n' http://<服务器IP>:8089/health
200
原因 :nginx 的 return 指令在 rewrite 阶段 执行,而 allow/deny 在 access 阶段 判定,rewrite 阶段先于 access 阶段。所以任何带 return 的 location(return 200/301/404 都一样)会在白名单检查之前就把请求处理掉,直接短路。这类"阶段顺序"问题 nginx -t 不报错、reload 不报警,只有真实攻击视角的测试能暴露。
解决 :删除整个 location = /health 块,健康探测改用 /------白名单内返回 index.html 200,白名单外 403,存活判定语义不变且同样受白名单保护。
预防:
- 给已有
return的 location 加白名单时,逐个检查是否要先过 access 阶段; - 白名单类变更的验证清单里必须包含"每个已知 location 都从白名单外打一遍",而不是只测
/; - 真正需要无条件健康检查的话,用独立端口/独立 server 块隔离,别和白名单服务混在一个 server 里。
坑 3:单 IP 放行拦掉平台真实调用------调用方分布在同网段多台机器
现象 :白名单首版只放行了域名解析出的单个 IP,上线约半小时后业务侧报 MCP 工具调用失败:
text
PluginInvokeError: MCP 调用失败 (403 Forbidden)
Server: nginx/1.24.0
翻 nginx access log,发现 403 的来源不止一台机器,而是同一 /24 网段内的多台 (末位 .177 调 /api/bridge/chat、.80 调 /api/mcp,时间点正是白名单生效时刻):
text
203.0.113.177 - - [05/Nov/2026:11:41:23 +0800] "POST /api/bridge/chat" 403
203.0.113.80 - - [05/Nov/2026:11:41:47 +0800] "POST /api/mcp" 403
原因 :一个"域名"背后的平台往往是一组集群/网关节点,出口 IP 分布在整个 C 段。DNS 解析出来的 A 记录只是其中之一(比如负载入口),用它当白名单只放行了名字解析到的那台,其余节点全被拦。这类误伤的特征是"部分流量 403、部分正常",比全挂更难发现。
解决 :脚本改为放行解析 IP 所在的 /24 网段 (awk -F. '{print $1"."$2"."$3".0/24"}',见 3.3),改完 reload 后 .177 的调用持续恢复 200。网段粒度在内网安全边界下是可接受的折衷。
预防:
- 白名单来源是"域名"时,默认按网段而非单 IP 规划,除非确认对方是单出口;
- 白名单上线后盯 30~60 分钟 access log 里的 403 记录再离场,把误伤的来源归并进白名单;
- 排查 403 时先看 access log 的
$remote_addr分布,"同段多 IP 被 403"就是这个坑的标准指纹。
坑 4:拿白名单内的机器测"白名单外"行为,永远测不出问题
现象:第一轮验证在本机 curl 白名单服务,预期 403 实际 200,一度以为白名单没生效。
原因 :我的办公机 IP 本来就在被放行的用户网段(/24)里------它就是白名单的目标用户之一,从它发起的测试天然全绿。测试机和目标用户机是同一台时,负向用例(应当被拒)不可能失败。
解决:借了一台已知凭据、不在任何白名单网段的跳板服务器,从它上面 curl 8089/5001,才真实复现 403,确认拦截生效。
预防 :负向验证清单固定加一条"来源机器是否真的在白名单外"(ip addr 现场确认),不凭感觉;有条件时保留一台固定的"外部视角"测试机。
坑 5:proxy_pass 写在 server 层,nginx -t 直接 emerg
现象 :写 travel-api 反代时把 proxy_pass 直接放在 server {} 层级(想着整站转发),nginx -t 报:
text
nginx: [emerg] "proxy_pass" directive is not allowed here in /etc/nginx/conf.d/app-api.conf:5
configuration file /etc/nginx/nginx.conf test failed
原因 :proxy_pass 是 location 级(及 limit_except/if 内)指令,server 层没有转发语义。这是 nginx 指令上下文模型的基础约束,但对习惯"server 级全局配置"的写法的人来说容易踩。
解决 :把 proxy_pass 和它的一组 proxy_set_header/proxy_read_timeout/client_max_body_size 挪进 location / {}(最终形态见 3.5)。
预防 :记住 nginx 指令三档上下文(http/server/location);拿不准时先 nginx -t,emerg 的报错会直接指出不合法的指令和行号,写完就测不要攒。
6. 总结
成果:两个对外服务(8089/5001)完成来源收敛------白名单外全部 403,白名单内及本机业务无感知;客户端 URL 零改动;域名来源每 10 分钟自动刷新(换 IP 10 分钟内生效),DNS 失败不误封;配置变更带自动校验回滚;日志按月落盘、3 个月自动清理。
经验教训(按痛的程度排序):
- 带域名的白名单,本质是"把不可控的域名映射成可控的 IP 集合"的工程------解析、缓存、fail-safe、自动 reload,缺一个都会在无人值守时段出事故;
- nginx 指令的阶段顺序 和上下文约束 是两个隐形雷区:
return短路 access 阶段(坑 2)、proxy_pass不进 location(坑 5),nginx -t都测不出来或直接报错,前者尤其要靠"白名单外全路径扫描"来兜底; - "域名"来源默认按网段放行(坑 3),上线后盯半小时 403 日志再离场;
- 白名单的反向代理化(端口对调)是"客户端零改造"的唯一干净解法,前提是后端必须退守回环地址,否则一切白名单都是纸糊的。
后续计划:为受控服务统一接入模板(一行 include)沉淀成运维文档;评估把白名单判定日志化(403 时记来源与路径)接入告警。