友链申请表单:一次越权写入,和一处「写了但不发」的死角

友链申请表单:一次越权写入,和一处「写了但不发」的死角-知集
友链申请表单:一次越权写入,和一处「写了但不发」的死角
此内容为付费阅读,请付费后查看
¥1
立即购买
您当前未登录!建议登陆后购买,可保存购买订单
付费阅读
阅读约 113 分钟33,727 字

摘要:「友链申请」是每个站都要做的功能:前台表单(游客可填)→ 后台审核 → 通过后写进链接管理器 + 邮件通知申请人。需求很直白,但把它写对要处理四类互不相同的问题 —— 其中一个是安全漏洞:审批动作只验 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 / nonce400请求格式就不对
nonce 错、或 nonce 是为别的 ID 签发的403不能证明请求来源
nonce 对,但没有 manage_options403不能证明身份
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断言 &lt;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 一起固化了产出方声明容器标记,缓存层读标记排除该页
伪协议看似被校验通过的 URLFILTER_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 篇) · 系列总目录与阅读路径 →

① 前端性能与加载

② 排版与样式系统

③ 功能实现与边界

④ 系统与运维

⑤ 成就与活动系统

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

请登录后发表评论

    暂无评论内容