对 WordPress 站点来说,/wp-login.php 往往比首页更值得单独看管:它不能被缓存、每次请求都要拉起 PHP-FPM、失败尝试还会写入数据库。但登录入口同时也是办公出口、运维跳板和真实编辑者共用的路径,一旦限速过头,先受影响的往往不是攻击者,而是自己人。因此在动手写 limit_req 之前,先明确这次变更会影响到哪些业务路径:后台登录与退出、密码重置、wp-admin 内的 AJAX 心跳、媒体上传、插件更新,以及由 wp-cron.php 触发的计划任务。本文只讨论 Nginx 层的请求速率限制,不涉及 WAF 规则编写和插件验证码,也不承诺单靠限速就能阻断撞库;它的作用是压住单一来源的高频尝试,为其他防护手段争取时间。

一、先界定影响范围:哪些请求会被同一条规则影响
limit_req 是按 key(通常是客户端 IP)计数,作用范围取决于你把它放在哪个 location。放错位置比参数写错更难排查。
| 请求路径 | 实际用途 | 套用严格限速的后果 | 建议 |
|---|---|---|---|
POST /wp-login.php | 登录提交、密码重置提交 | 高频失败尝试被压制,符合预期 | 主目标,单独一条严格规则 |
GET /wp-login.php | 渲染登录表单 | 反复刷新登录页的编辑者会被拦 | 与 POST 共用同一 zone 一般可接受 |
/wp-admin/admin-ajax.php | 后台心跳、部分前台功能 | 后台操作卡顿、编辑器提示连接中断 | 不套用登录 zone |
/wp-cron.php | 计划任务、备份与更新钩子 | 备份中断、定时发布延迟 | 单独温和限制或保持现状 |
/xmlrpc.php | 部分客户端与历史插件 | 若有正常使用会被误伤 | 先确认是否在用,再决定单独处理 |
| 静态资源 | 图片、CSS、JS | 页面加载异常 | 不要加限速 |
一个容易被忽略的前提:如果站点前面有 CDN 或反向代理,$binary_remote_addr 拿到的是边缘节点地址,此时限速会把所有访客当成同一个来源,等于全站限速。确认方法是在 http 块配置 set_real_ip_from 与 real_ip_header 后,看日志里记录的客户端 IP 是否真实;配置前先小范围验证一条请求的 $remote_addr 取值。
二、盘点:用现有日志还原真实登录来源
不要凭印象定阈值。执行位置是 Web 服务器的 shell,读取的是 Nginx access log,预期现象是能看出“哪些 IP 高频、集中在什么时段、状态码分布如何”。
先看登录路径的来源 IP 排名:
sudo awk '$7 ~ /^/wp-login.php/ {print $1}' /var/log/nginx/access.log
| sort | uniq -c | sort -rn | head -30再看登录提交的结果分布。WordPress 登录成功后通常返回 302 跳转,失败则返回 200 并带回错误页,所以下面的状态码比例可粗略当作成功率:
sudo awk '$6 ~ /POST/ && $7 ~ /^/wp-login.php/ {print $9}' /var/log/nginx/access.log
| sort | uniq -c | sort -rn需要注意的组合指标:
- 单个 IP 在 1 分钟内对登录路径的请求数分布,决定
rate的量级; - 有多少 IP 落在同一网段(办公出口、公司 NAT、云厂商机房),决定白名单粒度;
- 真实用户从打开登录页到提交表单的间隔,决定
burst至少要留几个位置; - 日志覆盖时长是否包含早高峰和内容发布时段,否则阈值会偏松。
如果日志里已经出现 429 或 503,先确认来源:503 可能来自 PHP-FPM 排队而非限速,两者混在一起会让后续判断失真。
三、判断:阈值和突发容量怎么取
limit_req 用的是漏桶模型:rate 是稳态放行速度,burst 是允许临时超出的队列长度,nodelay 决定超出部分立即处理还是延迟排队。三者要一起定,单独调 rate 意义不大。
| 参数 | 含义 | 起手取值思路 | 依据 |
|---|---|---|---|
rate | 每 IP 稳态放行速率 | 先按“正常用户最快操作速度”取,例如 10–20 次/分钟 | 盘点中真实用户单页面的登录相关请求数 |
burst | 瞬时允许的超出量 | 3–5,覆盖“刷新一次 + 提交一次 + 重试一次” | 浏览器重试与用户手滑的实际次数 |
nodelay | 超出 burst 立即拒绝而非排队 | 登录路径建议加上 | 排队会让用户看到长时间白屏,比直接报错更难解释 |
limit_req_status | 拒绝时返回的状态码 | 显式设为 429 | 默认 503 会与后端过载混淆 |
limit_req_log_level | 写入 error log 的级别 | warn | 默认 error,量大时噪音明显 |
zone 容量 | 存储 key 状态的内存 | 10m 通常足够中小站点 | 每 MB 约存一万六千个状态 |
登录页的请求体量远小于接口,不需要照搬“每秒几十次”的通用值;对单人使用场景,每分钟个位数到十几次之间往往就够用。真正需要放宽的是多人共用一个出口 IP 的办公网络——这类地址更应该进白名单,而不是靠调大 rate 来迁就。
四、整改:白名单与小步上线
白名单的正确做法不是写一堆 allow 再 deny,而是让被豁免的请求“不进入计数”。把可信来源的 key 变成空字符串,limit_req_zone 就不会为这些请求建立状态。
在 http 块中定义(执行位置:/etc/nginx/nginx.conf 或 conf.d/ 下的独立文件):
geo $login_trusted {
default 0;
127.0.0.1 1;
10.20.30.0/24 1; # 办公出口
198.51.100.7 1; # 运维跳板
}
map $login_trusted $login_key {
0 $binary_remote_addr;
1 "";
}
limit_req_zone $login_key zone=wp_login:10m rate=12r/m;
limit_req_status 429;
limit_req_log_level warn;在 server 块中作用于登录路径。如果站点里已经有 location = /wp-login.php,只加一行 limit_req;如果没有,新建的精确匹配块必须把你现有的 fastcgi_params、fastcgi_pass、SCRIPT_FILENAME 一并写全,否则会直接 404 或 502:
location = /wp-login.php {
limit_req zone=wp_login burst=5 nodelay;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}上线前固定三步:
- 备份待改文件,文件名带时间戳,例如
cp wp-login-limit.conf wp-login-limit.conf.20250101-0900.bak; sudo nginx -t,预期输出syntax is ok与test is successful;sudo systemctl reload nginx,用 reload 而不是 restart,避免连接被硬切断。
如果使用的是较新版本的 NGINX,可以先用 limit_req_dry_run on; 跑一轮,只记录命中而不真正拒绝;老版本没有该指令,可先只对单个测试 server 生效。具体是否支持,用 nginx -V 看版本后对照官方 ngx_http_limit_req_module 文档确认,不要凭记忆。
几个容易踩的细节:
- 白名单网段写错一位,等于把办公网推进限速;上线后务必从白名单 IP 和普通 IP 各测一次;
- IPv6 下同一用户可以在一个
/64内更换地址,若日志里 IPv6 占比高,需要考虑按前缀聚合 key,否则计数会被稀释; xmlrpc.php若仍在用,不要和登录路径共用同一 zone;- 修改
rate或burst后需要 reload 才生效,观察窗口应重新起算,不要拿变更前后的数据直接对比。
五、验证:把正常业务路径跑一遍
在非高峰时段上线,逐项确认。下表每一行都应能被“看到现象”,而不是“觉得没问题”。
| 检查项 | 操作 | 预期现象 |
|---|---|---|
| 登录表单 | 浏览器打开 /wp-login.php | 200,页面完整渲染 |
| 正常登录 | 输入正确账号密码 | 302 跳转后台,无 429 |
| 登录失败 | 故意输错一次密码 | 返回错误提示页,不是 429 |
| 密码重置 | 走 ?action=lostpassword 发一封 | 邮件正常发出,页面无报错 |
| 后台操作 | 编辑文章、上传图片、切换页面 | 无卡顿、无“连接已丢失”提示 |
| 计划任务 | 触发一次 wp-cron.php | 备份与定时任务正常执行 |
| 限速生效 | 从普通 IP 连续请求登录页 10 次 | 前若干次正常,之后返回 429 |
连续请求可以用一条命令模拟,执行在任意能访问站点的机器上:
for i in $(seq 1 10); do
curl -o /dev/null -s -w "%{http_code}n" https://example.com/wp-login.php
done预期是前面几次返回 200,随后出现 429。若一次都不出现,说明 burst 太大或规则没生效;若第一次就出现,说明命中了你没预料到的计数来源,先回去看 error log 里的 limiting requests 记录。

六、观察窗口:用指标判断要不要回退
建议观察窗口覆盖一个完整业务周期(24–72 小时),至少包含一次早高峰和一次内容发布时间。观察期内只盯四个指标:
| 指标 | 采集方式 | 处置参考 |
|---|---|---|
| 登录路径 429 占比 | 按状态码统计登录请求 | 持续高于 1%–2% 且伴随成功数下降,先放宽再看 |
| 登录成功率 | 302 与 200 的比例变化 | 相对基线明显下滑,视为误伤信号 |
| 白名单 IP 的 429 | 单独过滤可信网段 | 出现即为配置错误,立即修正 |
| 后端 5xx | Nginx 与 PHP-FPM 日志 | 与限速无关,按原流程排查 |
error log 里的命中记录能直接回答“是谁被拦了”:
sudo grep "limiting requests" /var/log/nginx/error.log | tail -50如果被拦的 IP 集中在少数几个网段,且这些网段有真实用户反馈,优先补白名单或放宽该 zone,而不是整体取消规则。如果被拦的 IP 极其分散、每个只出现一两次,说明阈值偏紧,把 rate 上调一档。
七、回滚清单:三档力度,从轻到重
出问题时不要凭紧张操作,按顺序执行,每一档都先 nginx -t 再 reload。
- 放宽:把
rate上调一档(如 12r/m → 30r/m),burst加 2,观察 30 分钟; - 摘掉规则:注释掉 location 里的
limit_req一行,保留limit_req_zone定义(不产生流量影响),reload 后限速立即消失; - 整体禁用:把配置文件名改为
.disabled后缀或整段注释,reload,回到变更前状态; - 还原文件:用步骤四的备份覆盖,
sudo nginx -t && sudo systemctl reload nginx; - 回滚后确认:重复第五节的连续请求测试,预期全部返回 200;再核对一次 error log 中不再新增
limiting requests。
回滚过程中如果 nginx -t 报错,说明改动引入了语法问题,此时不要 reload,直接还原备份文件再测试。切记 systemctl reload nginx 失败时旧配置仍在运行,站点不会立刻中断,这给了你排查的时间。
八、复盘:把这次变更记录下来
限速规则不是一次配完就结束的东西,站点流量结构、办公出口 IP、CDN 接入方式都会变。建议在变更记录里留下四项内容:生效时间与观察窗口、最终的 rate/burst/zone 名、白名单网段及其负责人、以及回滚用的备份文件路径。下次调整时,这些就是判断依据,而不是重新从日志里猜一遍。
最后需要明确边界:按 IP 计数的限速对分布式来源效果有限,它压住的是单点高频,不是有组织的撞库。它的价值在于把明显的机器流量挡在 PHP 之外,同时把真实攻击留在日志里可追溯。登录安全仍需与强口令、双因素、失败锁定等措施配合,限速只是其中一层,且必须是可验证、可回滚的一层。
- it2021
- it2021.com
- nginx
- shell
- test
- WordPress
- 登录安全





1F
办公出口IP真得先白名单,不然编辑先炸