把站点当靶子打了一遍
先说结论:扫描器没打进来,问题都出在自己身上。
测出四个真实缺陷——来源地址可以伪造、限速只在应用层、不存在的路径全返回 200、任意域名都能访问到这台机器;另有五处是加固过程中自己给自己制造的故障,包括加了安全策略直接白屏、封禁规则加了却不生效。
这篇把九处逐个拆开,每处都给证据和修法。想给单机站点做一轮体检的,可以当清单用。
缺陷一:来源地址取错了那一段
反向代理传来源用的是 X-Forwarded-For,而它是个链式头:客户端可以自己带一个值,网关在它后面追加真实地址,最终形态是
X-Forwarded-For: <客户端自带的>, <真实来源>
代码取的是 split(',')[0],也就是最左段——完全由请求方决定。
后果不是日志记错,是限速被架空:每个伪造值都算一个新访客,配额无限。实测时把伪造值轮换着打,限速一次都没触发。
修法一行:从右往左取第一个非空段。网关追加的那一段永远在最右边,客户端改不了它。改完再轮换 15 个伪造值,第 13 次起开始 429。
缺陷二:限速只挡在应用层
应用层的限速都在,但它有个前提:请求已经进来了,连接建立、头部解析、必要时查过库,才轮到它判断。
所以网关层又加了一档,按来源地址分三档:
| 目标 | 速率 | 突发 | 理由 |
|---|---|---|---|
| 认证接口 | 1 r/s | 5 | 猜密码的成本最高 |
| 搜索 | 5 r/s | 10 | 每次都要扫全表 |
| 其余页面 | 10 r/s | 20 | 正常浏览够用 |
burst 不是拍脑袋定的,是按「一次正常页面加载会并发几个请求」量出来的:首页 9 个、文章页 10 个、日志页 8 个。突发值必须不小于这个数,否则打开一个页面就会被自己的限流打成 429。
两层不是重复。应用层知道「这个接口的业务成本有多高」,网关层管得住「根本不该被处理的请求」。
实测:连打 15 次认证接口,第 9 次开始 429——应用层先于网关层触发,说明两层都在。
缺陷三:不存在的路径全都返回 200
前端是单页应用,路由在客户端。服务端的兜底逻辑是「找不到文件就回退到首页」,于是任何路径都能拿到 200。
这直接废掉了 404 的信息价值。扫描器看到 /.env、/.git/config、/wp-json/wp/v2/users/me 全是 200,会判定「这些文件存在」,然后继续深挖。
改成白名单:路径不在已知前端路由里,就直接 404。改完的第二天,访问日志里 65% 的记录是 404,单个地址两小时内打了 148 次,全部落空。
缺陷四:任意域名都能访问到这台机器
网关只配了站点自己那几个域名,没有默认站点。结果任何解析到这台机器的域名都会命中主站。
补了两个默认 server(80 与 443),TLS 握手阶段直接拒绝:
ssl_reject_handshake on;
return 444;
用占位自签证书即可,反正握手不会完成。
内容安全策略:加了头就白屏
script-src 'self' 一上,页面直接白屏——站点里有两段内联脚本:一段在首屏套用主题(晚了会闪白),一段给入口页拆分标题。
内联脚本和严格策略是互斥的。两条路:给它算哈希或随机串,或者挪出去。选了后者,两段各自抽成独立文件,首屏那段用同步加载保住「不闪」。
还有内联事件属性。一个按钮的 onclick 跳转,改成链接加事件委托。
改完策略才能收紧到只信任本站脚本。之后逐个页面冷启动验证,CSP 违规 0。
五处自伤
加固本身也会把服务弄坏,这几条都是当场发生、当场回滚的:
- 清空防火墙规则把 fail2ban 的链一起清了。 封禁记录还在写,但那条链再没有人跳转进去,等于没封。修法是让 fail2ban 重建一次。
pkill -f chromium把自己杀了。-f匹配完整命令行,而执行它的那条命令里就含chromium。要用-x精确匹配进程名。- 网关的
add_header没有继承。 只要 location 里写了一条add_header,同一层 server 里的那几条就全部失效。安全头抽成片段被每个 location 显式引入,才不会有漏。 - 改完配置 reload 没立刻验证。 第一次 curl 拿到的是旧头,误判为配置没生效。reload 与请求之间要留一点时间。
- 同一批改动被追加了两次,导致标识符重复声明、服务连续启动失败五次,触发启动频率限制,必须先
reset-failed才能再启动。
fail2ban:加了规则但不生效
配置看着全对,fail2ban-client set <jail> banip <地址> 也返回成功,但防火墙里什么都没有。
日志里的一句话说明了原因:
ERROR ... exec: nft add table inet f2b-table
stderr: '/bin/sh: 1: nft: not found' returned 127
默认的封禁动作走 nftables,而机器上只装了 iptables 的兼容层,没有 nft 这个二进制。返回码 0 是「命令下发成功」,不是「规则已写入」。
改成 banaction = iptables-multiport 之后立刻见效。
过滤规则本身用 fail2ban-regex 对真实日志跑过一遍:10227 行里 6025 行命中,命中最多的几个地址正是之前识别出的扫描器。
另外这个工具默认忽略本机流量(ignoreself),而「从机器上访问自己的域名」会解析到公网地址再绕回来,于是本机制造的 404 永远不会被封。不是缺陷,但意味着端到端封禁没法自己测,只能靠日志里的 Found <地址> 来确认检测环节在工作。
后来:日志又抓出两处
安全加固做完之后,访问日志成了最诚实的东西——它把「我以为做对了」的地方一条条指出来。
一、爬虫的预检被当成非法请求
日志里出现一批 405:某几个蜘蛛对首页发 OPTIONS 请求,全被拒了。
实测逐个方法:GET 200、HEAD 200、OPTIONS 405、其余方法 405。
原因很直接——服务端只认 GET 和 HEAD,其它一律「方法不允许」退回去。对静态资源来说这没错,但 OPTIONS 是个例外:它是跨域预检,正常客户端和一部分爬虫都会先发一次。
补一条分支,回 204 并声明允许的方法:
if (method === 'OPTIONS') {
res.writeHead(204, {
'Allow': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
});
return res.end();
}
改完 OPTIONS 回 204;POST 仍然是 405——那才是真的不该允许。
二、防御写得太宽,误伤了自己的验证
更麻烦的一条:搜索引擎的验证代理请求 /?cachebuster=750534648 拿到了 404。
这个 404 是我自己造成的。前面为了挡扫描器,写过一条「根路径带查询串一律 404」——因为正常用户访问首页不会带参数,而扫描器很喜欢 /?rsd、/?add-ajax-woocommerce-carts= 这种。
规则没错,但边界写粗了。验证代理带 cachebuster 是为了绕过 CDN 缓存,属于完全正当的请求。
改成白名单,只放行这一种形态:
if (p === '/' && url.search.length > 1) {
if (!/^\?cachebuster=\d+$/.test(url.search)) return notFound(res);
}
放行之后,/?rsd 和 /?utm_source=test 这些依然 404。
这两处的共同点是:防御规则本身没问题,问题出在「一刀切」。 一刀切能挡住绝大部分噪音,但也会切到少数正当请求,而那些请求往往来自你最需要的访客。
结果
- 来源地址伪造:不再能绕过限速
- 认证接口:第 9 次起 429,伪造头无效
- 常规并发:60 个并发请求里 13 个被网关拦下
- CSP:启用后 12 个页面零违规
- 扫描:全部 404,无突破