本文约 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 严格来说不是一次性数字,而是一个有时效的、与用户绑定的哈希串。它的生成包含四个要素:
- 当前用户的 ID(
get_current_user_id()); - 当前会话的 token(登录 cookie 的一部分);
- 一个动作字符串(你自定义,比如
mmf_save_settings); - 时间刻度(由
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 访问导致的信息泄露和执行,是每个插件文件的第一行必备内容。
常见踩坑
-
只有 nonce 没有 capability:任何登录用户都能调接口。nonce 只证明「来自本站页面」,不证明「有权限」。
-
只有 capability 没有 nonce:管理员可能被诱导点击,属于典型 CSRF。
-
用
if ( ! wp_verify_nonce() )判断:虽然结果一致,但可读性差,且容易在后续重构中出错。推荐false === wp_verify_nonce()。 -
nonce 动作名不区分操作:所有删除操作用同一个
delete动作名,等于给了一个通杀令牌。动作名要具体到操作类型,最好带上对象 ID。 -
REST 路由漏写 permission_callback:WordPress 会发出
_doing_it_wrong通知,且路由对所有人开放。 -
案例: 某插件提供了一个 AJAX 接口用于清空缓存,代码里只写了
if ( ! current_user_can( 'manage_options' ) ) return;,没有 nonce。攻击者在一个热门论坛的签名里放了一段自动提交到admin-ajax.php的脚本,凡是登录了且是管理员的站长访问该论坛,其站点的缓存就被悄悄清空,随后站点因无缓存直接被流量打垮。补上check_ajax_referer()后问题解决。教训是:能力判断回答「你是谁」,nonce 回答「这是不是你主动发起的」,两个问题都要回答。 -
把 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() 仍然要你自己写。
