摘要:维护模式(Maintenance Mode)的逻辑一句话就能说完:访客看 503 维护页,管理员和白名单 IP 照常浏览。但这一句话里每个字都可能出错,而且出错的方向全都是静默的 —— 站长本人一定处于登录态,测试时一切正常;只有换个浏览器、换个网络再去看,才知道维护模式根本没拦住人(形同虚设),或者反过来把所有人都拦住了。本文拆五个坑:一个会变成安全后门(信任 X-Forwarded-For)、一个会让定时任务停摆(放行集合漏判)、一个会把站长自己锁在门外(开关读反)、一个让搜索引擎收录维护页(返回 200 而非 503)、还有一个最容易被忽略:开了维护模式,游客仍然看到正常页面(缓存比维护层更早命中)。附零依赖交付代码与 145 项离线单测。
一、为什么这个功能特别容易「测不出来」
维护模式的验收条件只有一句:「游客看到维护页,我能正常浏览」。问题在于,做这件事的人永远处于「我能正常浏览」的那一侧:
- 你打开后台开开关 —— 后台本来就在放行名单里,看不到任何变化;
- 你打开前台看一眼 —— 你登录着,而「登录用户可访问」默认是开的,依然看不到维护页;
- 于是你只能靠”我应该配对了”来确认。
这就是这个模块的风险结构:它的失败态你几乎永远看不到。所以下面五个坑,全都是”代码看起来对、评论也写得对、只有真实访客能感受到”。
对这类模块,唯一可靠的验证方式是「用一个干净的、未登录的、来自白名单之外的身份去请求」—— 而不是”我自己刷新了一下没问题”。
二、坑①:白名单判定信任了 X-Forwarded-For —— 维护模式变成一扇永远开着的门
这是本文最严重的一个。先看一段看起来很合理的 IP 获取代码:
<?php
function my_get_client_ip() {
// 优先从 X-Forwarded-For 取「真实访客 IP」(反代后面 REMOTE_ADDR 是代理 IP)
if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
$parts = explode( ',', $_SERVER['HTTP_X_FORWARDED_FOR'] );
return trim( $parts[0] );
}
return $_SERVER['REMOTE_ADDR'] ?? '';
}
这段代码的动机是对的:站点跑在反向代理 / CDN 后面时,PHP 看到的 REMOTE_ADDR 确实是代理的 IP,真实访客 IP 只能从这类头里读。所以”优先读 XFF”看起来是在修正一个 bug。
但 X-Forwarded-For 是客户端可以随便写的请求头。在非反代环境(绝大多数自建站、宝塔裸机、直连 IP 的站)下,上面这段代码等价于:
curl -H 'X-Forwarded-For: 192.168.3.240' https://example.com
# ↑ 服务器认为访客 IP 是 192.168.3.240 → 命中白名单 → 放行
# 维护模式对你完全无效,而你完全不知道
攻击面还不止”绕过维护模式”:只要白名单里写了 127.0.0.1 或内网段,任何外部请求都能伪装成内网来源,去命中那些”只允许内网访问”的判定。
正确顺序:REMOTE_ADDR 优先,XFF 只在它为空时回落
正确的规则不需要更多代码,只需要把顺序倒过来:
<?php
$ip = isset( $_SERVER['REMOTE_ADDR'] )
? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) )
: '';
// 🔒 只在 REMOTE_ADDR 为空时回落 —— 这行是安全边界,改顺序 = 开后门
if ( '' === $ip && isset( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
$forwarded = explode( ',', $_SERVER['HTTP_X_FORWARDED_FOR'] );
$ip = trim( (string) reset( $forwarded ) );
}
注意判定用的是 '' === $ip 而不是 empty() 或 ! isset():REMOTE_ADDR 存在但为空串时,同样应该回落。这个细节在单测里专门覆盖了(见第八章)。
那真在反代 / CDN 后面怎么办?答案不是”信 XFF”,而是显式指定一个你信任的头:
<?php
// 只信 Cloudflare 的 CF-Connecting-IP(该头由 CF 边缘覆盖,客户端伪造无效)
define( 'MY_IP_HEADER', 'HTTP_CF_CONNECTING_IP' );
if ( '' !== MY_IP_HEADER && isset( $_SERVER[ MY_IP_HEADER ] ) ) {
$trusted = sanitize_text_field( wp_unslash( $_SERVER[ MY_IP_HEADER ] ) );
if ( '' !== $trusted ) {
return trim( $trusted );
}
}
差别在于「谁写的这个头」:X-Forwarded-For 是通用约定,任何客户端都能加;而 CF-Connecting-IP 由 CDN 边缘节点覆盖写入,经过 CDN 的请求里客户端改不动它。信任的前提是这个头不由客户端控制,不是”它看起来更专业”。
一句话记住:X-Forwarded-For 不是”更真实的 IP”,而是”客户端提交的 IP”。凡是客户端能控制的值,都不能作为放行依据。
三、坑②:放行集合漏判 —— 维护完,数据反而更旧了
维护模式要放行的不只是后台。常见写法只判两条:
<?php
if ( is_admin() || wp_doing_ajax() ) {
return; // 后台和 AJAX 放行
}
// 其它请求一律维护页 ...
漏掉任意一条的后果都是具体的、且不报错的:
| 漏掉的条件 | 后果 |
|---|---|
is_admin() | 后台全 503 —— 你只能改数据库把自己救出来 |
wp_doing_ajax() | 前台所有 AJAX 挂掉:登录、评论、点赞、加载更多 |
wp_is_json_request() | 带 JSON Content-Type 的接口返回 503 |
REST_REQUEST | GET 型 REST 请求不带 JSON 头 → 单独 503(最常漏的一条) |
WP_CLI | 维护期间所有 CLI 作业失败:备份、发布、批量迁移 |
DOING_CRON | 定时任务全停 → 维护结束,数据反而比维护前更旧 |
最后一条特别值得强调。维护模式的使用场景通常是“我要改数据结构 / 清缓存 / 迁移”,而这些都是不想让定时任务同时插手的操作 —— 但如果顺手把 Cron 也拦了,你就得到了一次”干净的维护”和一堆延迟的、积压的定时任务。等到恢复访问,几十个积压任务一起跑,才是真正的问题。
还有一个反直觉的结论:不用为登录页开后门
既然担心”把自己锁在门外”,很自然会想给 wp-login.php 加白名单。实际上不需要:
wp-login.php不经过template_redirect。所以”先开维护模式、再登录后台”这条路本来就是通的 —— 这正是”先关站再登录”能走得通的原因。
这条结论的价值不在于省了几行代码,而在于它阻止了一次不必要的放行。维护模式下每一个”放行”都是一次风险敞口,能不放的就不放。
四、坑③:开关读反 —— 站长被自己的维护模式锁在门外
「登录用户可访问」这个开关的语义是”允不允许登录用户绕过“,默认开。判定必须写成「允许 并且 已登录」:
<?php
// ✅ 正确:开关打开 且 已登录 → 放行
if ( my_allow_login() && is_user_logged_in() ) {
return false; // 不拦截
}
// ❌ 错误:把「允许」读成了「禁止」
if ( ! my_allow_login() && is_user_logged_in() ) {
return false; // 结果是「关闭该开关,登录用户反被拦」
}
这两种写法结构上几乎一样,只差一个 !,但语义是镜像的。而错误版本的表现恰好是:站长打开维护模式、想进前台看一眼、结果自己被 503 拦住。
更麻烦的是这个 bug 会诱导你做出更糟的修复:既然”登录用户被拦了”,那就去关掉那个开关试试 —— 而错误版本里关掉它反而恢复正常,于是在代码里留下”这个开关要关着才对”的错误认知,把 bug 升级成了一条口口相传的经验。
所以单测里专门有一条断言覆盖它(第七章):关闭该开关后,登录用户必须被拦截。这条断言的作用不是”验证功能”,而是把小写的 ! 错误钉死在测试里。
五、坑④:返回 200 而非 503 —— 维护页被搜索引擎当内容收录
把维护页渲染出来只做了一半。下面三个 HTTP 层面的东西经常被漏掉:
status_header( 503 )—— 告诉搜索引擎”这页临时不可用”,而不是”这页就是这样的内容”。返回 200 的话,Google 会把「站点维护中」当成本页正文索引,恢复后要靠爬虫重抓才消失,中间这段时间搜索结果是维护页;nocache_headers()—— 否则浏览器或 CDN 会把维护页缓存下来,维护结束后访客仍然看到它(又一个”幽灵维护”);Retry-After—— 标准的”何时再来”提示,搜索引擎和客户端都会读它。
另外维护页本身是独立整页(不走 wp_head),这带来一个具体的写法约束:
在这页里不能写
var(--brand)这类 CSS 变量。父主题输出的自定义属性在这里根本不存在 —— 你必须在 PHP 侧把实际色值取出来拼进 style。写变量的结果不是报错,而是”样式静默不生效”。
<?php
$brand = get_theme_brand_color(); // 例如 '#6e7bf7'
$deep = my_darken( $brand, 0.25 ); // '#4d5ce8'
$grad = 'linear-gradient(135deg,' . $brand . ',' . $deep . ')';
// ✅ 拼的是实际色值,不是变量名
// ❌ background: var(--brand) → 这一页没有这个变量,等于没写
还有一处细节:色值如果来自配置项,它会被拼进 CSS。单测里专门用 red;} body{display:none} .x{ 这种带 CSS 注入的非法值去攻击这个位置,验证白名单校验生效、非法值回落到内置默认色(第八章)。
六、坑⑤:缓存比维护层更早命中 —— 「我开了维护模式,游客还是看到正常页面」
这是五个坑里最不像代码问题、最难查的一个。先看两个钩子的时机:
| 层 | 挂钩时机 | 行为 |
|---|---|---|
| 页面缓存 | init | 命中 → 直接输出文件 → exit |
| 维护拦截 | template_redirect | 请求进入主题渲染阶段后才执行 |
init 远早于 template_redirect。所以缓存命中时,请求根本走不到维护判断就已经结束并返回了旧页面。
于是你看到的现象是:
- 游客访问 → 看到维护前的正常页面(缓存命中);
- 你访问 → 看到维护页(你登录着,不命中缓存);
- 换个游客身份再试 → 又是正常页面。
这个现象非常容易被误判成”维护模式没生效”,于是你去翻维护模块的代码 —— 而那里完全正确。真正的修法在另一边:把维护判定导出一个纯函数,让缓存层在输出前先问一句。
修法:把判定收敛成一个纯函数
<?php
/**
* 当前请求是否应被维护模式拦截(纯判定,无副作用)
* ★ 不 echo、不 exit、不写任何东西,只回答「该不该拦」
*/
function my_maintenance_active() {
if ( ! my_maintenance_enabled() ) {
return false;
}
if ( my_is_bypass_request() ) { // 后台/AJAX/REST/CLI/Cron
return false;
}
if ( my_allow_login() && is_user_logged_in() ) {
return false;
}
if ( my_ip_allowed( my_get_client_ip() ) ) {
return false;
}
return true;
}
然后缓存模块在「命中输出」之前插入一句:
<?php
// 缓存命中分支里,输出文件之前:
if ( function_exists( 'my_maintenance_active' ) && my_maintenance_active() ) {
return; // 放弃本次缓存,把请求交给后面的维护拦截
}
把这个判定收敛成一个函数(而不是在缓存里重写一遍放行逻辑)是这里的关键。因为一旦有两处判定,它们迟早会不一致 —— 而”两处判定不一致”的表现,正是同一个请求在两个层里得到相反结论这种最难复现的 bug。
顺带一个可迁移的设计原则:判定与副作用要分开。凡是被”别人”需要的判断(缓存层、其它模块、测试探针、CLI 工具),都必须能只问不做地调用。把
exit埋在判定里,下游就永远只能选择”自己重写一遍”。
七、交付代码:零依赖单文件
下面这份代码把五个坑逐条修掉,并额外支持 CIDR 网段(放行整个内网/办公网)和「维护页跟随品牌色」。单文件、零依赖,直接放进主题或 mu-plugins 即可。
关键 README:
- 部署前先自检:访问
?zdw_mm_check=1(需管理员权限)查看「识别到的客户端 IP / 白名单解析结果 / 当前判定」,确认你不会被自己拦住; - 没有 URL 旁路:不做
?preview=1这类后门 —— URL 旁路任何人加个参数就能用,与”维护中”的目的直接冲突。预览请用登录态或白名单; - 反代 / CDN:改文件顶部的
ZDW_MM_IP_HEADER指定可信头,不要放开 XFF; - 与缓存模块配合:把
zdw_mm_active()接进缓存的”命中输出”分支(见第六章)。
九、验证:145 项离线单测,全部不依赖 WordPress
这个模块的失败态你自己看不到(第一章),所以测试必须**不依赖”我去点一下看看”**。下面 145 条断言全部在一个无 WordPress 的环境中跑,php _zdw_mm_test.php 即可执行。
测试覆盖十二节,重点看章节标题里带★的那几条 —— 每一条都对应一个”我第一版写错过”的地方。
9.1 三个必须证伪的攻击面
第一条尤其重要,因为它是本文的核心论点,不能只靠”我改了顺序”来证明,必须构造出伪造请求并断言它被拒绝:
| 断言 | 构造方式 | 期望 |
|---|---|---|
| ★ 伪造 XFF 不能绕过 | REMOTE_ADDR=203.0.113.7X-Forwarded-For=192.168.3.240白名单= 192.168.3.240 | 仍然拦截 |
| ★ 伪造 XFF 命中网段也不放行 | 同上,白名单=192.168.3.0/24 | 仍然拦截 |
| ★ 空 IP 默认拒绝 | my_ip_allowed( '', '1.2.3.4' ) | false |
第三条是个容易忽略的方向:拿不到 IP 时,安全的方向是”拒绝”。如果写成”列表为空就放行”或”IP 为空就不判定”,维护模式在异常环境下会自己敞开。
9.2 测试自己踩的两个坑(比被测代码更有价值)
写这 145 条断言的过程中,我自己踩了两个坑,都值得单独记下来 —— 因为它们的共同点是“测试写错了,却看起来像代码错了”。
坑 A:`define()` 不可撤销,整进程有效。
为了测「REST_REQUEST 常量应放行」,我在第六节里 define( 'REST_REQUEST', true )。结果第七节(判定链测试)整组”应该拦截”的断言全部失败 —— 因为此后每一次调用 my_maintenance_active() 都因这个常量而返回 false。
表面现象是”维护模式全都不拦截了”,看起来像模块彻底坏掉;实际是测试状态泄漏。修法是把常量判定挪进独立子进程:
# ❌ 不行:同进程里 define 之后无法撤销
define( 'REST_REQUEST', true );
assert( my_is_bypass_request() === true );
# → 之后所有"应拦截"的断言全部假失败
# ✅ 可行:常量判定在子进程里跑,主进程不 define
shell_exec( PHP_BINARY . ' _child_rest.php' );
assert( ! defined( 'REST_REQUEST' ) ); // 复述:主进程干净
顺带一条判据:「哪些常量被 defined() 守卫」要数清楚再断言。我第一版断言”6 个常量判定都套了 defined()“,实际只有 4 个 —— 因为 is_admin()、wp_doing_ajax()、wp_is_json_request() 是函数调用,根本不需要 defined() 守卫。这是口径错误而非代码缺陷;断言写错数字,比不写断言更糟 —— 因为它会训练你”忽略这一条”。
坑 B:`header()` 不能 stub。
想抓取 Retry-After 头,我第一反应是写一个 function header($h){ ... } 收集器。结果是直接 Fatal error:
Fatal error: Cannot redeclare header() in _zdw_mm_test.php on line 103
header() 是 PHP 内建函数,不能重新声明。凡是想拦截内建函数(header、setcookie、date…)的测试都要换思路。这里的替代方案是源码文本断言:
// ✅ 断言「源码里确实有这一行,且用的是可配置常量」
has( $SRC, "header( 'Retry-After: ' . ZDW_MM_RETRY_AFTER );", '★ Retry-After 头' );
has( $SRC, "define( 'ZDW_MM_RETRY_AFTER', 3600 )", 'Retry-After 值可配置' );
源码断言的可信度低于运行时断言,所以它只用于”运行时无法观测”的位置,并且要配上更严的 needle(整行、含常量名),避免”只要出现关键词就算过”。
9.3 一个永真式断言的教训
我写了一条”amount > 1 被 clamp 到 1″的断言,形式是 darken('#ffffff', 2.5) === darken('#ffffff', 0.25) —— 它失败了,于是我改成对比 2.5 与 1.0。改完通过了,但这条断言其实是永真式:2.5 和 1.0 都 clamp 到 1.0,两者相等是必然的 —— 就算 clamp 被删掉,它也可能照样通过。
修法是加一条对照断言,用”必然不同的值”证明测试本身有分辨力:
// ✅ clamp 断言 + 对照组
$over = darken( '#ffffff', 2.5 ); // 越界
$at1 = darken( '#ffffff', 1.0 ); // 边界
$under = darken( '#ffffff', 0.5 ); // 正常值
ok( $over === $at1, '★ amount > 1 被 clamp:与 1.0 一致' );
ok( $over !== $under, '★ 对照:clamp 结果与 0.5 确实不同(证明有分辨力)' );
这是一个通用习惯:写完断言要反问”它在什么情况下会失败?”如果答不上来,它就是永真式(或永假式)。上一篇文章里的”参数写反 = 永真式”是同一个病。
9.4 验证矩阵
| 章节 | 覆盖内容 |
|---|---|
| 一 | 17 个函数全部定义齐全 |
| 二 | ★ IP 获取顺序(含伪造 XFF 的源码顺序断言) |
| 三 | 白名单解析:分隔符、连续分隔符、空条目过滤 |
| 四 | CIDR 判定:/24 /12 /8 /32 /0、非法输入、位数越界 |
| 五 | 白名单命中:精确 + 网段 + 混合 + 空 IP 默认拒绝 |
| 六 | ★ 放行七条通道(含独立子进程验常量) |
| 七 | ★ 判定链五分支(含”关闭开关后登录用户被拦”) |
| 八 | ★ 渲染页:503 / nocache / noindex / 转义 / CSS 注入 / 无 CSS 变量 |
| 九 | 自检端点只读性 + 权限在读取配置之前 |
| 十 | 挂钩优先级 + 5 个设置项 + 白名单清洗(丢弃非法网段) |
| 十一 | darken():短写展开、clamp、非法输入不崩 |
| 十二 | 零依赖(剥注释后无主题前缀)+ 前缀一致性 + 四处 v2 差异 |
结果:145 PASS / 0 FAIL。
十、速查表
10.1 五个坑与修法
| 坑 | 表现 | 修法 |
|---|---|---|
| ① 信任 XFF | 形同虚设:加个请求头就能绕过 | REMOTE_ADDR 优先;仅在为空时回落 XFF;反代用可信头 |
| ② 放行漏判 | 维护期间 REST/CLI/Cron 失败或停摆 | 七条通道逐条列全,REST_REQUEST 单独判 |
| ③ 开关读反 | 站长被自己的维护模式拦在门外 | 判定写成「允许 并且 已登录」,别把语义读反 |
| ④ 返回 200 | 搜索引擎收录维护页;维护页被缓存 | 503 + nocache_headers + Retry-After + noindex |
| ⑤ 缓存抢先 | 开了维护,游客仍看到正常页 | 导出纯判定函数,缓存输出前先问它 |
10.2 维护模式的完整放行清单
| 放行对象 | 判定 | 缺了会怎样 |
|---|---|---|
| 后台 | is_admin() | 后台全 503 |
| AJAX | wp_doing_ajax() | 前台交互全挂 |
| JSON 请求 | wp_is_json_request() | JSON 接口 503 |
| REST | REST_REQUEST | GET 型 REST 单独 503 |
| CLI | WP_CLI | CLI 作业全失败 |
| 定时任务 | DOING_CRON | Cron 停摆、任务积压 |
| XML-RPC | XMLRPC_REQUEST | XML-RPC 客户端异常 |
| 登录用户 | 开关(默认开) | 站长自己进不去(或反之,见坑③) |
| 白名单 IP | 精确 / CIDR | 无法放行运维网段 |
| 登录页 | 无需处理 | wp-login.php 不走 template_redirect |
附:三个可迁移的结论
- 判定与副作用必须分开。凡是被下游(缓存、其它模块、探针、CLI)需要的判断,都要能”只问不做”地调用。把
exit埋在判定里,下游只能选择自己重写一遍 —— 而两份独立的判定迟早会不一致。 - 「我自己测了没问题」对这类模块无效。维护模式、权限、白名单、缓存这类模块,失败态恰好是操作者身份看不到的那个状态。必须用”干净身份”去验证,或者写成离线断言。
- 测试写错时,现象和代码坏掉一模一样。`define()` 泄漏、”永真式断言”、把函数调用数进常量、`header()` 重声明 —— 这四个都让我先怀疑代码,再发现是自己写错。所以每次断言失败,第一步是先证明 needle / 口径是对的,再去看被测代码。
📚 zibll 子主题开发实录(共 22 篇) · 系列总目录与阅读路径 →
① 前端性能与加载
② 排版与样式系统
- 用 alt 后缀驱动的 WordPress 密集墙排版:从 float 到 flex-wrap 与纵向叠列
- 当主题模板没有过滤器:把「字数+阅读时间」并进它的 meta 行
- 设计令牌的最小实现:一个颜色变量驱动全站(附阅读进度条)
③ 功能实现与边界
- 一个 81 行的朗读按钮改了三轮:三个静默失效的坑
- 删数据类功能怎么写才不闯祸:一个「删文清附件」的六条边界
- 令牌模型与 lost update:一个只在并发时出现的计数 bug
- 「我填了 1 天,它却按 7 天算」:文章过期提示的四个边界
- 「历史上的今天」为什么永远是空的:三个不报错的死写法,与两个时区坑
- 文章系列卡片:一个 PHP 8 致命错误,和三处「不报错但白干」的写法
- 友链申请表单:一次越权写入,和一处「写了但不发」的死角
④ 系统与运维
- 404 监控的四个坑:计数翻倍、键名推导、点路径取值、与「自己看不到数据」
- 评论反垃圾的四个坑:先被自己拦住、节流伪装失败、空内容归属、与「WP 原生背锅」
- 页面缓存一次都没命中:六个静默失效,与一条 nonce 结构性保证
- 维护模式:五个「看起来在工作、其实开了后门」的写法 (本文)
- WebP 自动转换:三个「上传成功、图片没变」的静默失效
- 安全扫描器:五个「扫了但没扫到」的坑,和一个实测确认的路径逃逸
⑤ 成就与活动系统









暂无评论内容