搜索全站
插件 18 分钟阅读

插件开发:用 nonce 与 capability 守住越权与 CSRF

设置页被跨站提交、AJAX 接口被越权调用,都是插件最常见的漏洞。本文讲清 nonce 的创建与校验、capability 的正确用法、AJAX 与 REST 的防护写法,附完整代码。

U
UFO_PF 2018-10-27 · 阅读 18 分钟
插件 插件
阅读提示
本文约 6,935 字,预计阅读 18 分钟。文中命令与配置均经过实测,可直接参考执行。

引言:功能做对了,安全没做,等于白做

插件开发里有一类 bug 不会让你的功能失效,却会让别人的漏洞生效:设置页面没有防跨站请求,攻击者可以在任意站点放一个自动提交的表单,让登录状态下的管理员「帮」他改掉你插件的配置;AJAX 接口没有能力判断,任何登录用户甚至未登录访客都能调用它去删数据。

这两类问题对应的是两个不同的防护机制,经常被混为一谈:

  • CSRF(跨站请求伪造):用 nonce 防。它证明「这个请求确实是从你的站点页面发出的」。
  • 越权(Broken Access Control):用 capability 防。它证明「发起这个请求的用户有权限做这件事」。

两者必须同时存在。只有 nonce 没有 capability,任何登录用户都能操作;只有 capability 没有 nonce,管理员在不知情时会被诱导操作。WordPress 生态里的历史漏洞统计中,这两类占比极高,原因不是难防,而是开发者常常漏掉其中一个。

本文给出两个机制的标准写法,覆盖普通表单、AJAX、REST 三类入口。

原理分析:nonce 是什么,它不是什么

nonce 的全称是「number used once」,但 WordPress 的 nonce 严格来说不是一次性数字,而是一个有时效的、与用户绑定的哈希串。它的生成包含四个要素:

  1. 当前用户的 ID(get_current_user_id());
  2. 当前会话的 token(登录 cookie 的一部分);
  3. 一个动作字符串(你自定义,比如 mmf_save_settings);
  4. 时间刻度(由 wp_nonce_tick() 计算,默认 24 小时为一个周期)。

核心把这四者哈希,生成类似 a1b2c3d4e5 这样的字符串。校验时重新计算一遍并比对。因此它同时证明了三件事:是谁发的、针对哪个动作、在多久之前发的。

需要明确 nonce 的三个边界:

特性 说明
有时效 默认 24 小时,可用 nonce_life 过滤器调整
绑定用户 换了用户或重新登录,旧 nonce 失效
不是授权 它只证明请求来源合法,不证明用户有权限
不是加密 它不保护数据内容,数据仍需净化与转义

wp_verify_nonce() 的返回值是 1(12 小时内生成的)、2(12–24 小时生成的)或 false(无效)。所以判断真伪时要用 false === wp_verify_nonce(...),不要用 ! wp_verify_nonce(...)——虽然两者在布尔语境下等价,但显式比较可读性更好,也避免误判。

capability 这一侧的核心是 current_user_can( $capability, ...$args )。WordPress 的能力体系分两类:

  • 元能力(如 edit_post、delete_post):需要传入对象 ID 作为第二个参数,核心会映射到具体角色权限;
  • 原始能力(如 manage_options、edit_posts、edit_others_posts、install_plugins):直接判断。

插件自定义的能力可以通过 map_meta_cap 过滤器扩展,但 90% 的场景用现成的就够了。常用对照:

场景 推荐 capability
插件全局设置 manage_options
站点内容管理 edit_posts
编辑他人内容 edit_others_posts
上传文件 upload_files
用户管理 edit_users
安装插件/主题 install_plugins / install_themes

保姆级实操

第一步:给普通表单加 nonce

输出表单时加字段,处理提交时校验:

// 渲染端
function mmf_render_form() {
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_die( '权限不足。', '权限不足', array( 'response' => 403 ) );
    }
    ?>
    <form method="post">
        <?php wp_nonce_field( 'mmf_save_settings', 'mmf_nonce' ); ?>
        <input type="text" name="mmf_title" value="" />
        <?php submit_button( '保存' ); ?>
    </form>
    <?php
}
// 处理端
add_action( 'admin_post_mmf_save_settings', 'mmf_handle_save' );
function mmf_handle_save() {

    // 1) 校验 nonce
    if ( ! isset( $_POST['mmf_nonce'] )
        || false === wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['mmf_nonce'] ) ), 'mmf_save_settings' ) ) {
        wp_die( '请求校验失败,请刷新页面重试。', '校验失败', array( 'response' => 403 ) );
    }

    // 2) 校验能力
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_die( '权限不足。', '权限不足', array( 'response' => 403 ) );
    }

    // 3) 净化数据
    $title = isset( $_POST['mmf_title'] ) ? sanitize_text_field( wp_unslash( $_POST['mmf_title'] ) ) : '';

    update_option( 'mmf_title', $title );

    // 4) 重定向回去,避免刷新重复提交
    wp_safe_redirect( add_query_arg( 'mmf_updated', '1', wp_get_referer() ) );
    exit;
}

注意表单要提交到 admin-post.php 并带 action 隐藏字段(或者像上面那样用 admin_post_{action} 钩子):

<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
    <input type="hidden" name="action" value="mmf_save_settings" />
    <?php wp_nonce_field( 'mmf_save_settings', 'mmf_nonce' ); ?>
</form>

第二步:用 check_admin_referer() 简化校验

// 等价于「校验 nonce + 失败则 wp_die + 检查 referer」
check_admin_referer( 'mmf_save_settings', 'mmf_nonce' );

这个函数内部会调用 wp_verify_nonce(),失败时输出「Are you sure you want to do this?」并终止。它适合后台管理页面,前台页面一般不用(referer 检查在前台容易误伤)。

第三步:给 AJAX 接口加防护

add_action( 'wp_ajax_mmf_delete_item', 'mmf_ajax_delete_item' );
function mmf_ajax_delete_item() {

    // 一行搞定 nonce 校验,失败直接返回 -1 并终止
    check_ajax_referer( 'mmf_delete_item', 'nonce', true );

    if ( ! current_user_can( 'manage_options' ) ) {
        wp_send_json_error( array( 'message' => '权限不足' ), 403 );
    }

    $id = isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0;
    if ( ! $id ) {
        wp_send_json_error( array( 'message' => '参数错误' ), 400 );
    }

    $result = wp_delete_post( $id, true );
    wp_send_json_success( array( 'deleted' => (bool) $result ) );
}

前端要把 nonce 传过去,用 wp_create_nonce() 生成并本地化:

wp_enqueue_script( 'mmf-admin', plugins_url( 'assets/js/admin.js', __FILE__ ), array( 'jquery' ), '1.0', true );
wp_localize_script( 'mmf-admin', 'mmfAdmin', array(
    'ajaxUrl' => admin_url( 'admin-ajax.php' ),
    'nonce'   => wp_create_nonce( 'mmf_delete_item' ),
) );

第四步:给 REST 路由加 permission_callback

REST 接口的防护入口是 permission_callback,必须提供,否则 WordPress 会报 _doing_it_wrong 提示:

add_action( 'rest_api_init', function () {
    register_rest_route( 'mmf/v1', '/items/(?P<id>\d+)', array(
        'methods'             => 'DELETE',
        'callback'            => 'mmf_rest_delete_item',
        'permission_callback' => function ( $request ) {
            return current_user_can( 'manage_options' );
        },
        'args' => array(
            'id' => array(
                'validate_callback' => function ( $param ) {
                    return is_numeric( $param ) && $param > 0;
                },
                'sanitize_callback' => 'absint',
            ),
        ),
    ) );
} );

REST 里 nonce 通过 X-WP-Nonce 请求头传递,前端用已本地化的 wpApiSettings.nonce,或者用 wp_create_nonce( 'wp_rest' ) 生成。

第五步:给 URL 链接加 nonce

删除、导出这类通过链接触发的操作也需要 nonce:

$url = wp_nonce_url(
    admin_url( 'admin-post.php?action=mmf_export&id=' . absint( $id ) ),
    'mmf_export_' . $id,
    'mmf_nonce'
);
echo '<a href="' . esc_url( $url ) . '">导出</a>';

// 处理时
if ( ! isset( $_GET['mmf_nonce'] ) || false === wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['mmf_nonce'] ) ), 'mmf_export_' . absint( $_GET['id'] ) ) ) {
    wp_die( '链接已失效。' );
}

把对象 ID 拼进 action 字符串,可以让 nonce 只对这一个对象的操作有效,粒度更细。

第六步:所有 PHP 文件入口加直接访问防护

defined( 'ABSPATH' ) || exit;

这一句不防 CSRF,但它能防止文件被直接通过 URL 访问导致的信息泄露和执行,是每个插件文件的第一行必备内容。

常见踩坑

  1. 只有 nonce 没有 capability:任何登录用户都能调接口。nonce 只证明「来自本站页面」,不证明「有权限」。

  2. 只有 capability 没有 nonce:管理员可能被诱导点击,属于典型 CSRF。

  3. 用 if ( ! wp_verify_nonce() ) 判断:虽然结果一致,但可读性差,且容易在后续重构中出错。推荐 false === wp_verify_nonce()。

  4. nonce 动作名不区分操作:所有删除操作用同一个 delete 动作名,等于给了一个通杀令牌。动作名要具体到操作类型,最好带上对象 ID。

  5. REST 路由漏写 permission_callback:WordPress 会发出 _doing_it_wrong 通知,且路由对所有人开放。

  6. 案例: 某插件提供了一个 AJAX 接口用于清空缓存,代码里只写了 if ( ! current_user_can( 'manage_options' ) ) return;,没有 nonce。攻击者在一个热门论坛的签名里放了一段自动提交到 admin-ajax.php 的脚本,凡是登录了且是管理员的站长访问该论坛,其站点的缓存就被悄悄清空,随后站点因无缓存直接被流量打垮。补上 check_ajax_referer() 后问题解决。教训是:能力判断回答「你是谁」,nonce 回答「这是不是你主动发起的」,两个问题都要回答。

  7. 把 nonce 写进缓存页面:被 CDN 缓存的页面里带 nonce,会导致所有用户拿到同一个过期 nonce。nonce 必须来自非缓存的、与登录用户绑定的输出。

总结

防护清单四条,逐条对照:每个会改变数据的入口都要校验 nonce;每个入口都要判断 capability;REST 路由必须提供 permission_callback;所有输入先净化再使用(函数对照见 数据验证与转义函数)。

代码顺序也有讲究:先 check_ajax_referer() 或 wp_verify_nonce(),再 current_user_can(),最后才是业务逻辑和数据处理。

建议把「有没有漏掉 nonce / capability」作为每次提交代码前的自查项。设置页面场景的配套写法见 创建自定义设置页面,那里 settings_fields() 会自动输出 nonce,但页面级的 current_user_can() 仍然要你自己写。

U
UFO_PF作者

专注 WordPress 生态的实战作者,内容以「可复制、可验证」为第一原则。

留下你的想法

邮箱地址不会被公开。带 * 的为必填项。