摘要:「友链申请」是每个站都要做的功能:前台表单(游客可填)→ 后台审核 → 通过后写进链接管理器 + 邮件通知申请人。需求很直白,但把它写对要处理四类互不相同的问题 —— 其中一个是安全漏洞:审批动作只验 nonce 和权限,不验「这个 ID 是不是友链申请」,于是可以把状态 meta 写到任意文章上,还会把那篇文章的标题当「友链名称」写进 links 表。另一个是逻辑死角:拒绝邮件写好了却因为外层条件永远发不出去。本文逐条拆开,附零依赖交付代码与 109 项离线单测。
一、需求本身很直白
一个小功能,四件事:
- 前台给一张表单,游客也能填(这是关键前提,后面所有安全问题都从这里推导出来);
- 提交后落一条待审核记录,站长在后台「友链申请」菜单里看到;
- 站长点「通过」,就把这条记录写进 WordPress 的链接管理器(父主题的友链区读的就是这张表),并发邮件通知申请人;
- 点「拒绝」就只改状态 + 发一封拒绝通知。
听起来没有任何难度。但把这四件事写对,要处理四类性质完全不同的问题:一类是安全(越权写入)、一类是逻辑(写了但发不出去)、一类是运营(被刷)、一类是环境交互(nonce 被缓存固化)。而且其中三个完全不报错。
「游客可提交」这四个字,会把一个纯 CRUD 功能变成「公开写接口」。一旦是公开写接口,nonce 就不够了 —— nonce 只防 CSRF,不防刷。
二、坑①:一次越权写入(本文件最严重的问题)
先看「通过」这个动作的常见写法。它挂在 admin_post_ 上,请求形如 admin-post.php?action=approve_link&post_id=123&_wpnonce=xxx:
<?php
function my_link_approve() {
if ( ! isset( $_GET['post_id'] ) || ! isset( $_GET['_wpnonce'] ) ) {
wp_die( '参数错误' );
}
$post_id = (int) $_GET['post_id'];
// ① nonce 校验(绑定了 post_id,看起来已经足够严)
if ( ! wp_verify_nonce( $_GET['_wpnonce'], 'approve_link_' . $post_id ) ) {
wp_die( '安全验证失败' );
}
// ② 权限校验
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( '权限不足' );
}
// ③ 直接写 meta
update_post_meta( $post_id, '_link_status', 'approved' );
// ④ 写进链接管理器
wp_insert_link( array(
'link_name' => get_the_title( $post_id ),
'link_url' => get_post_meta( $post_id, '_link_url', true ),
'link_visible' => 'Y',
) );
wp_safe_redirect( admin_url( 'edit.php?post_type=link_apply' ) );
exit;
}
nonce 绑定了 post_id、权限也查了 —— 看起来已经很严密。但这两步加起来只证明了一件事:「这个请求是某个管理员发的」。它们完全没有证明「post_id 指向的是一条友链申请」。
而 post_id 是请求方完全可控的。于是:
update_post_meta()会把_link_status等 5 个 meta 写进任意文章/页面上;- 更糟的是
wp_insert_link()—— 它会把那篇文章的标题当成「友链名称」写进 links 表,并且link_visible = 'Y',立刻前台可见。
现象是什么?后台多了一条莫名其妙的友链。没有报错、没有日志、没有任何提示。你只会想「这谁加的?」,而很难想到「这是审批接口写进去的」。
修法:在最早处加一步类型校验
把校验收敛成一个公共前置函数,顺序刻意如此:
<?php
function my_link_guard( $action ) {
$nonce_action = 'zhiji_' . $action . '_link';
$post_id = isset( $_GET['post_id'] ) ? absint( $_GET['post_id'] ) : 0;
$nonce = isset( $_GET['_wpnonce'] )
? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
if ( ! $post_id || '' === $nonce ) {
wp_die( '参数错误', 400 );
}
// ① nonce 最先:未通过就先拒,不对未授权请求做任何数据库查询
if ( ! wp_verify_nonce( $nonce, $nonce_action . '_' . $post_id ) ) {
wp_die( '安全验证失败,请返回重试', 403 );
}
// ② 权限次之:current_user_can 要读角色,成本高于 nonce
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( '权限不足', 403 );
}
// ③ 类型最后:这一步才真的去查这条 post,成本最高,放在最少触发的位置
if ( 'link_apply' !== get_post_type( $post_id ) ) {
wp_die( '友链申请不存在', 404 );
}
return $post_id;
}
顺序不只是「成本递增」的优化,还有一个正确性理由:如果把类型校验放到权限校验之前,一个没有权限的人也能通过返回码差异(404 还是 403)探测出「这个 ID 是不是友链申请」—— 这就成了一个信息泄露点。
另外 nonce 必须绑定 post_id(zhiji_approve_link_{$id}),只绑动作名的话,从 A 申请拿到的 nonce 可以直接用到 B 申请上。这一点常见的写法是对的,但值得在单测里显式写一条断言。
四个返回码,各代表一层
| 场景 | 返回码 | 含义 |
|---|---|---|
缺 post_id / nonce | 400 | 请求格式就不对 |
| nonce 错、或 nonce 是为别的 ID 签发的 | 403 | 不能证明请求来源 |
nonce 对,但没有 manage_options | 403 | 不能证明身份 |
| nonce 对 + 是管理员,但 ID 指向普通文章 | 404 | 越权尝试,被拦截 |
这四个码在单测里逐个断言,把「越权拦截」变成一条可回归的测试,而不是依赖某天有人记得去读那段代码。
三、坑②:拒绝邮件写好了,但永远发不出去
这是一个纯粹的逻辑死角。看这段(简化自真实代码):
<?php
// 拒绝通知:兜底 HTML 已经写好了
$email = get_post_meta( $post_id, '_link_email', true );
if ( $email && zhiji_contract_available( 'Template' ) ) {
$subject = '【知集】友链申请未通过';
$body = '<p>尊敬的站长:</p><p>很遗憾,您提交的友链申请未通过审核。</p>';
zhiji_mail_deliver( $email, $subject, $body );
}
注意外层条件里的 zhiji_contract_available( 'Template' )。这个分支压根没用到模板引擎 —— 它发的是纯手写 HTML。但条件里却要求模板引擎可用,于是模板引擎不可用时这封邮件根本不发。
现象:通过邮件正常送达,拒绝邮件静默消失。申请人以为石沉大海,站长以为系统已经通知过了。双方都以为对方知道了,实际上没人知道。
「写了但用不上」是最难自查的一类代码 —— code review 时你会看到一堆合理的 HTML,很容易跳过最上面那行条件。
修法不是「补一个 else 分支」,而是去掉这个前置条件:通知的发送不应以「模板引擎可用」为前提。模板引擎是排版方式的选择,不是发送能力的前提。
<?php
$email = (string) get_post_meta( $post_id, '_link_email', true );
if ( $email ) {
$body = my_mail_body( 'rejected', $name, '' );
my_mail( $email, '【本站】友链申请审核结果', $body );
}
顺带:把发信失败留个痕
wp_mail() 返回 false 时经常一点痕迹都没有(SMTP 插件吞掉、发信被限流、对方拒收)。抽一层薄封装,至少在调试模式下留下日志:
<?php
function my_mail( $to, $subject, $body_html ) {
if ( ! is_email( $to ) ) {
return false;
}
$headers = array( 'Content-Type: text/html; charset=UTF-8' );
$ok = wp_mail( $to, $subject, $body_html, $headers );
if ( ! $ok && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( '[my-fla] mail failed: ' . $subject . ' -> ' . $to );
}
return (bool) $ok;
}
四、坑③:公开写接口没有限频
这个端点是游客可提交的(wp_ajax_nopriv_ 也挂)。在这种情况下,nonce 只防 CSRF,不防刷 —— 任何写脚本的人都能从表单页面拿走一个合法 nonce,然后拿它循环提交。
结果:数据库里瞬间多出几万条待审核申请,后台列表卡到打不开,真正有用的申请淹在里面。
拦截顺序:每一层的顺序都有理由
这是本站的做法,顺序不是随意的:
<?php
// ① 限频(放最前)
$key = 'my_fla_rl_' . md5( $ip );
$cnt = (int) get_transient( $key );
if ( $cnt >= 5 ) {
wp_send_json_error( array( 'message' => '提交过于频繁,请稍后再试。' ) );
}
// ② 必填 ③ 邮箱格式 ④ URL 格式 ⑤ 长度 ⑥ 重复 ⑦ 落库
// ...
限频放在字段校验之前的理由:让机器人连「格式错误」都刷不动。如果把字段校验放前面,那么一次格式全错的请求照样会走到后面的get_posts() 去重查询 —— 攻击者用垃圾数据就能消耗你的数据库。
★ 计数只在成功落库之后 +1
这个细节我单独拎出来说,因为它直接决定用户的观感:
<?php
// ❌ 错误:进来就 +1
set_transient( $key, $cnt + 1, HOUR_IN_SECONDS );
// ... 然后是各种校验 ...
// ✅ 正确:只在成功落库后 +1
$post_id = wp_insert_post( $args, true );
if ( is_wp_error( $post_id ) ) { /* ... */ }
// ... 写 meta ...
set_transient( $key, $cnt + 1, HOUR_IN_SECONDS );
如果进来就 +1,那么用户打错一个字也扣一次额度。填错 5 次就被自己锁在外面一小时 —— 这是最招骂的一种限频实现。单测里我专门写了一条:连续 5 次校验失败的提交之后,第 6 次正确的提交仍然必须放行,并且计数器的值应当是 1 而不是 6。
顺带:FILTER_VALIDATE_URL 拦不住伪协议
这一点很容易漏。看下面这个字符串:
javascript:alert(1)
filter_var( $url, FILTER_VALIDATE_URL ) 认为它是合法 URL(因为它符合 URI 语法)。但它被写进数据库、再被渲染到前台 href 里的时候,就是一个可点击的 XSS 载体。
所以必须再卡一次协议白名单:
<?php
$scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) );
if ( ! filter_var( $url, FILTER_VALIDATE_URL )
|| ! in_array( $scheme, array( 'http', 'https' ), true ) ) {
wp_send_json_error( array( 'message' => '网站URL格式不正确(需 http/https 开头)' ) );
}
五、坑④:nonce 被整页缓存固化(跨模块问题)
表单里的 nonce 是服务端生成、写进 HTML 的:
window.ZDW_FLA_CFG={"nonce":"a1b2c3d4e5"};
一旦这个页面被整页缓存,所有访客拿到的是同一个 nonce。而 WordPress 的 nonce 有寿命(12–24 小时),于是:
- 缓存期内的所有访客共享一个 nonce(这本身已经是 CSRF 防护的降级);
- 过期之后,所有访客的提交全部失败,报「安全验证失败」;
- 而页面看起来完全正常 —— 表单渲染得好好的。
这类问题的排查成本极高:你会去查 nonce 逻辑、查权限、查 AJAX 路由,最后才发现是缓存层把 nonce 一起缓存了。
修法:由「产出方」声明标记,缓存层读标记
关键设计是标记的唯一权威属于产出它的模块:产出表单的模块知道自己用了哪个容器类名,就由它把这个类名登记给缓存层。缓存层不做任何硬编码,只读这个列表:
<?php
/**
* 声明本表单的容器类名 —— 供整页缓存层识别「该页面含一次性 nonce,不可缓存」
*/
function zhiji_link_apply_nonce_marker( $markers ) {
$markers = is_array( $markers ) ? $markers : array();
$markers[] = 'zhiji-link-apply-form-wrap';
return $markers;
}
add_filter( 'zhiji_page_cache_nonce_markers', 'zhiji_link_apply_nonce_marker' );
这样做的好处是不会两处各写一份、改一处漏一处。如果反过来让缓存层自己维护一个「哪些短码不能缓存」的清单,那么新增或改名短码时,必须在两个文件里各改一次 —— 漏掉的那次不会报错,只会静默失效。
另外 is_array() 兜底也很重要:别的插件可能用非数组挂这个 filter,不兜底就会 TypeError。
六、坑⑤:资源与守卫的三个小问题
剩下的三处都比较小,但性质和前面一样 —— 都不报错。
① 短码里塞 <script> 和 <style>
v2 把整段 CSS + JS 都写在短码输出里。两个问题:
- 样式与内容分离在两个输出点 —— 任何「只取内容区」的渲染(REST、摘要、订阅)都会拿到没样式的裸 HTML;
- 短码可能出现在一篇被多次渲染的文章里,同一段 JS 会被重复输出。
修法:CSS/JS 走资源层,nonce 通过 wp_add_inline_script 注入:
<?php
function my_fla_enqueue() {
if ( is_admin() || ! MY_FLA_ENABLED ) {
return;
}
wp_register_style( 'my-fla', false, array(), $ver );
wp_enqueue_style( 'my-fla' );
wp_add_inline_style( 'my-fla', my_fla_style() );
wp_register_script( 'my-fla-js', false, array(), $ver, true );
wp_enqueue_script( 'my-fla-js' );
wp_add_inline_script( 'my-fla-js', my_fla_script(), 'after' );
}
add_action( 'wp_enqueue_scripts', 'my_fla_enqueue', 20 );
/**
* ⚠️ 挂在 100:资源层的入队钩子固定在 99,挂 99 会因为「句柄还没注册」
* 而 wp_add_inline_script() 静默返回 false。
*/
add_action( 'wp_enqueue_scripts', 'my_fla_inline_cfg', 100 );
单测里我断言了两件事:短码输出不含 <script 与 <style;以及内联配置的优先级是 100(必须大于资源层的 99)。
② static 守卫防止重复注入
my_fla_style() 用 static $done 保证只返回一次 CSS。但这样的函数不能在同一个进程里连测两次 —— 前面的用例已经把 $done 置位了。正解是在干净子进程里 eval 出这个函数再测:
<?php
$probe = "<?phpn" . $style_fn . "n"
. '$a = my_fla_style(); $b = my_fla_style();'
. 'echo ( strlen($a) > 100 ? "T1" : "F1" ) . ( "" === $b ? "T2" : "F2" );' . "n";
file_put_contents( $probe_file, $probe );
$out = shell_exec( escapeshellarg( PHP_BINARY ) . ' ' . escapeshellarg( $probe_file ) );
// 期望输出 T1T2:首次返回 CSS、第二次返回空串
③ 后台行操作:nonce 必须绑定 ID
「通过 / 拒绝」两个链路的 nonce 动作名要带上 post_id:
<?php
$actions['approve'] = '<a href="' . wp_nonce_url(
admin_url( 'admin-post.php?action=my_approve_link&post_id=' . (int) $post->ID ),
'my_approve_link_' . (int) $post->ID
) . '">通过</a>';
单测里有一条对照测试:拿「为 101 号签发的 nonce」去审批 202 号,必须 403。这条测试看起来多余(谁会把 nonce 用错),但它锁住了「nonce 绑定 ID」这个约定 —— 后续有人重构时把 ID 从动作名里去掉了,这条会立刻红。
八、验证:109 项离线单测
交付代码配了 109 项离线单测,不依赖 WordPress —— 全部跑完不到一秒。其中相当一部分是「专门抓假通过」的断言。这部分的经验来自前几篇文章踩过的坑,这里列出来供参考:
| 类别 | 断言写法 | 为什么必须这样 |
|---|---|---|
| 参数顺序 | has($haystack, $needle) | 写反就变成「30KB 源码里不含整个源码」→ 恒真 |
| 源码断言 | 先剥离注释再 strpos | 注释里提到过被删掉的写法,会误命中 |
| XSS | 断言 <img 出现,而不是「词消失」 | 「词消失」可能只是被别的过滤吃掉 |
| 字面量 | 先断言针本身存在(has($src, $needle)) | 针写错会变成恒假 FAIL,浪费一小时排查 |
| 对齐空格 | 用 s* 正则,不写死空格数 | 源码对齐变了就假 FAIL |
static | 干净子进程 eval 后再测 | 同进程内前序用例已置位 |
| exit | 桩 wp_safe_redirect 抛哨兵异常 | exit 无法 catch,会直接干掉整个测试 |
| 桩忠实度 | 桩 get_posts() 真的按 meta_query 过滤 | 桩不忠实 → 测试全绿而线上全错 |
四个返回码的越权断言
这一节是全文的核心保护。四个场景逐个断言,并且先证明针是对的:
<?php
// ① 缺参数
ok( ! $r['ok'] && 400 === $r['die'], '缺 post_id/nonce → 400' );
// ② nonce 不对
ok( ! $r['ok'] && 403 === $r['die'], 'nonce 错误 → 403' );
// ③ nonce 对但不是管理员
ok( ! $r['ok'] && 403 === $r['die'], '★ 有合法 nonce 但无 manage_options → 403' );
// ④ ★★ 越权核心:nonce 合法 + 是管理员,但 post_id 指向普通文章
ok( ! $r['ok'] && 404 === $r['die'], '★★ post_id 指向普通文章 → 404' );
// ⑤ nonce 是「为 A 签发」的,拿到 B 上用 → 必须失败
ok( ! $r['ok'] && 403 === $r['die'], '★ nonce 绑定 post_id' );
顺序断言:不只是「有没有」,还有「在哪」
守卫函数里三步的顺序用 strpos 比较位置来锁:
<?php
$pos_nonce = strpos( $guard_src, 'wp_verify_nonce' );
$pos_can = strpos( $guard_src, 'current_user_can' );
$pos_type = strpos( $guard_src, $TYPE_NEEDLE );
ok( $pos_nonce < $pos_can && $pos_can < $pos_type,
'★ 顺序 nonce → 权限 → 类型(成本递增,且不向无权者泄露类型)' );
提交端点的顺序也用同样方式锁住,并且额外断言计数写在落库之后:
<?php
$pos_rate = strpos( $SRC, 'MY_FLA_RATE_MAX )' );
$pos_needed = strpos( $SRC, '请填写所有必填项' );
$pos_dup = strpos( $SRC, '$dup = get_posts' );
$pos_insert = strpos( $SRC, '$post_id = wp_insert_post' );
$pos_set = strpos( $SRC, 'set_transient( $key, $cnt + 1' );
ok( $pos_rate < $pos_needed, '★ 限频在必填校验之前' );
ok( $pos_needed < $pos_dup, '★ 字段校验在去重查询之前' );
ok( $pos_dup < $pos_insert, '★ 去重在落库之前' );
ok( $pos_set > $pos_insert, '★★ 限频计数写在 wp_insert_post 之后(失败不吃额度)' );
运行结果:109 / 109 PASS。
附:五处问题的速查表
| 问题 | 表面现象 | 根因 | 修法 |
|---|---|---|---|
| 越权写入 | 后台多了条莫名其妙的友链 | 只验 nonce + 权限,不验 post_type | 守卫最早期加 get_post_type() 校验,返回 404 |
| 拒绝邮件不发 | 通过邮件正常,拒绝邮件消失 | 外层条件要求模板引擎可用,但分支没用模板 | 去掉前置条件;模板是排版方式,不是发送前提 |
| 被刷 | 数据库瞬间几万条待审核 | 公开端点只有 nonce,无频率限制 | 每 IP 每小时限频,计数在成功落库后 +1 |
| nonce 被缓存 | 提交全部报「安全验证失败」,页面正常 | 整页缓存把服务端 nonce 一起固化了 | 产出方声明容器标记,缓存层读标记排除该页 |
| 伪协议 | 看似被校验通过的 URL | FILTER_VALIDATE_URL 认为 javascript: 合法 | 再卡一次 http/https 协议白名单 |
附:一页速查(可复制)
/* 守卫三步,顺序不能变 */
nonce → current_user_can → get_post_type()
400 / 403 / 403 / 404
/* 提交拦截顺序 */
限频 → 必填 → 邮箱 → URL(含协议白名单) → 长度 → 去重 → 落库 → 计数
/* 三条最容易写错的细节 */
1. 限频计数放在 wp_insert_post() 之后,失败不吃额度
2. nonce 动作名绑定 post_id,不用裸动作名
3. 通知的发送不以「模板引擎可用」为前提
/* 查询瘦身(只要「有没有」,不要数据) */
'fields' => 'ids'
'no_found_rows' => true
'update_post_term_cache' => false
'update_post_meta_cache' => false
/* 内联配置优先级 */
资源入队 = 99(父主题约定),内联配置必须挂 100
📚 zibll 子主题开发实录(共 22 篇) · 系列总目录与阅读路径 →
① 前端性能与加载
② 排版与样式系统
- 用 alt 后缀驱动的 WordPress 密集墙排版:从 float 到 flex-wrap 与纵向叠列
- 当主题模板没有过滤器:把「字数+阅读时间」并进它的 meta 行
- 设计令牌的最小实现:一个颜色变量驱动全站(附阅读进度条)
③ 功能实现与边界
- 一个 81 行的朗读按钮改了三轮:三个静默失效的坑
- 删数据类功能怎么写才不闯祸:一个「删文清附件」的六条边界
- 令牌模型与 lost update:一个只在并发时出现的计数 bug
- 「我填了 1 天,它却按 7 天算」:文章过期提示的四个边界
- 「历史上的今天」为什么永远是空的:三个不报错的死写法,与两个时区坑
- 文章系列卡片:一个 PHP 8 致命错误,和三处「不报错但白干」的写法
- 友链申请表单:一次越权写入,和一处「写了但不发」的死角 (本文)
④ 系统与运维
- 404 监控的四个坑:计数翻倍、键名推导、点路径取值、与「自己看不到数据」
- 评论反垃圾的四个坑:先被自己拦住、节流伪装失败、空内容归属、与「WP 原生背锅」
- 页面缓存一次都没命中:六个静默失效,与一条 nonce 结构性保证
- 维护模式:五个「看起来在工作、其实开了后门」的写法
- WebP 自动转换:三个「上传成功、图片没变」的静默失效
- 安全扫描器:五个「扫了但没扫到」的坑,和一个实测确认的路径逃逸
⑤ 成就与活动系统










暂无评论内容