◈ 夜宵吧

把站点当靶子打了一遍

先说结论:扫描器没打进来,问题都出在自己身上。

测出四个真实缺陷——来源地址可以伪造、限速只在应用层、不存在的路径全返回 200、任意域名都能访问到这台机器;另有五处是加固过程中自己给自己制造的故障,包括加了安全策略直接白屏、封禁规则加了却不生效。

这篇把九处逐个拆开,每处都给证据和修法。想给单机站点做一轮体检的,可以当清单用。

请求链:公网到反向代理,再到应用和数据;代理层管照单全收的请求,应用层管业务代价;命中黑名单的请求在应用层被拒,统一返回 404 或 405 或 429
四段各管一段责任。拒绝时少说话——不告诉对方「这里真有东西」。

缺陷一:来源地址取错了那一段

反向代理传来源用的是 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。

五处自伤

加固本身也会把服务弄坏,这几条都是当场发生、当场回滚的:

  1. 清空防火墙规则把 fail2ban 的链一起清了。 封禁记录还在写,但那条链再没有人跳转进去,等于没封。修法是让 fail2ban 重建一次。
  2. pkill -f chromium 把自己杀了。 -f 匹配完整命令行,而执行它的那条命令里就含 chromium。要用 -x 精确匹配进程名。
  3. 网关的 add_header 没有继承。 只要 location 里写了一条 add_header,同一层 server 里的那几条就全部失效。安全头抽成片段被每个 location 显式引入,才不会有漏。
  4. 改完配置 reload 没立刻验证。 第一次 curl 拿到的是旧头,误判为配置没生效。reload 与请求之间要留一点时间。
  5. 同一批改动被追加了两次,导致标识符重复声明、服务连续启动失败五次,触发启动频率限制,必须先 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,无突破