维护模式:五个「看起来在工作、其实开了后门」的写法

维护模式:五个「看起来在工作、其实开了后门」的写法-知集
维护模式:五个「看起来在工作、其实开了后门」的写法
此内容为付费阅读,请付费后查看
¥1
立即购买
您当前未登录!建议登陆后购买,可保存购买订单
付费阅读
阅读约 89 分钟26,607 字

摘要:维护模式(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_REQUESTGET 型 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.7
X-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
AJAXwp_doing_ajax()前台交互全挂
JSON 请求wp_is_json_request()JSON 接口 503
RESTREST_REQUESTGET 型 REST 单独 503
CLIWP_CLICLI 作业全失败
定时任务DOING_CRONCron 停摆、任务积压
XML-RPCXMLRPC_REQUESTXML-RPC 客户端异常
登录用户开关(默认开)站长自己进不去(或反之,见坑③)
白名单 IP精确 / CIDR无法放行运维网段
登录页无需处理wp-login.php 不走 template_redirect

附:三个可迁移的结论

  • 判定与副作用必须分开。凡是被下游(缓存、其它模块、探针、CLI)需要的判断,都要能”只问不做”地调用。把 exit 埋在判定里,下游只能选择自己重写一遍 —— 而两份独立的判定迟早会不一致。
  • 「我自己测了没问题」对这类模块无效。维护模式、权限、白名单、缓存这类模块,失败态恰好是操作者身份看不到的那个状态。必须用”干净身份”去验证,或者写成离线断言。
  • 测试写错时,现象和代码坏掉一模一样。`define()` 泄漏、”永真式断言”、把函数调用数进常量、`header()` 重声明 —— 这四个都让我先怀疑代码,再发现是自己写错。所以每次断言失败,第一步是先证明 needle / 口径是对的,再去看被测代码。

📚 zibll 子主题开发实录(共 22 篇) · 系列总目录与阅读路径 →

① 前端性能与加载

② 排版与样式系统

③ 功能实现与边界

④ 系统与运维

⑤ 成就与活动系统

© 版权声明
THE END
喜欢就支持一下吧
点赞8 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容