后台菜单谁都能点?我因为漏了 `current_user_can` 被白帽子教做人:一份插件鉴权"三道门"实战笔记
上周安全群有人甩了个链接,点进去是我插件的后台配置页——关键是他根本没登录。我后背一凉,翻代码才发现自己只在前端按钮上做了 `is_admin()` 判断,真正的处理接口裸奔了半年。
这篇不聊大道理,只记我补窟窿时踩的三个坑,都是血泪换的。
第一道门:菜单注册时的"假权限"
很多人以为 `add_menu_page` 第四个参数写了 `manage_options` 就万事大吉:
add_menu_page(
'我的插件',
'我的插件',
'manage_options', // ← 这只是控制"看不看得到菜单"
'my-plugin',
'my_plugin_render_page'
);
这玩意儿只挡菜单渲染,不挡直接访问 URL。我当时的漏洞就在这:`admin.php?page=my-plugin` 谁都能敲,因为渲染函数里没二次校验。现在我的习惯是进门再验一次:
function my_plugin_render_page() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( __( '你谁啊?' ), 403 );
}
// 正常渲染...
}
别笑,这种"菜单有权限、页面没权限"的缝合怪,GitHub 上一搜一堆。
第二道门:AJAX 接口的 CSRF 裸奔
我插件有个"一键清理缓存"的 AJAX 按钮,当时代码长这样:
add_action( 'wp_ajax_my_plugin_clear_cache', function() {
// 直接干!
delete_transient( 'my_plugin_heavy_data' );
wp_send_json_success();
} );
问题大了。攻击者只要诱导已登录管理员打开一个恶意页面,里面藏个自动提交的 form:
<form action="https://目标站/wp-admin/admin-ajax.php" method="POST">
<input name="action" value="my_plugin_clear_cache">
</form>
<script>document.forms[0].submit()</script>
缓存就这么被清掉了。如果是删数据、改配置的接口,后果更惨。
补法两步走:后端发 nonce,后端验 nonce。
发——把 nonce 塞进 JS 全局变量或 REST schema:
wp_localize_script( 'my-plugin-admin', 'myPluginData', [
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'my_plugin_clear_cache_action' ),
] );
验——接口里死磕 `check_ajax_referer`:
add_action( 'wp_ajax_my_plugin_clear_cache', function() {
check_ajax_referer( 'my_plugin_clear_cache_action', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( '权限不足', 403 );
}
delete_transient( 'my_plugin_heavy_data' );
wp_send_json_success();
} );
注意 `check_ajax_referer` 验证失败会直接 die,不用你自己 return。但如果你想自定义错误响应,得用 `wp_verify_nonce` 手动判断。
第三道门:SQL 拼接时的"信任幻觉"
我插件有个搜索功能,早期直接字符串拼接:
$results = $wpdb->get_results(
"SELECT * FROM {$wpdb->prefix}my_plugin_logs WHERE user_id = {$_GET['user_id']}"
);
`$_GET['user_id']` 传个 `1 OR 1=1` 就直接全表脱裤。当时觉得"后台接口嘛,管理员不会搞自己",直到发现前面两道门都没守好,攻击者能进后台。
现在我的铁律:任何外部输入进 SQL,必须过 `$wpdb->prepare`:
$user_id = absint( $_GET['user_id'] ); // 先整形化
$results = $wpdb->get_results( $wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}my_plugin_logs WHERE user_id = %d AND created_at > %s",
$user_id,
sanitize_text_field( $_GET['start_date'] )
) );
几个细节:
- `%d` 给整数,`%s` 给字符串,`%f` 给浮点,别混用
- `$wpdb->prepare` 返回带引号的字符串,所以 SQL 里别自己加单引号
- 表名 `{$wpdb->prefix}my_plugin_logs` 不能用占位符,得自己拼,但前缀是 WP 内核变量,相对可信
如果是 `IN (...)` 批量查询,占位符要动态生成:
$ids = array_map( 'absint', (array) $_GET['ids'] );
$placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
$sql = $wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE ID IN ($placeholders)", ...$ids );
一个我现在的"接口安检清单"
每个处理请求的地方,复制粘贴问自己:
- 用户是谁?`wp_get_current_user()` 或 `get_current_user_id()`
- 能干什么?`current_user_can( '具体权限' )`,别用 `is_admin()` 糊弄
- 请求合法吗?`check_ajax_referer` / `wp_verify_nonce` / REST nonce
- 数据干净吗?`absint`、`sanitize_text_field`、`$wpdb->prepare`、`esc_sql` 该用哪个
- 返回泄露了吗?错误信息别带 SQL 语句、路径、表结构
最后提一嘴 REST API 的权限回调,很多人写 `__return_true` 图省事,我吃过亏。现在哪怕是个只读端点,也会挂自定义权限回调:
register_rest_route( 'my-plugin/v1', '/stats/', [
'methods' => 'GET',
'callback' => 'my_plugin_get_stats',
'permission_callback' => function() {
return current_user_can( 'read' ) && wp_verify_nonce( $_SERVER['X_WP_NONCE'], 'wp_rest' );
},
] );
其实 `X-WP-Nonce` 头 REST 内核会验,`permission_callback` 里主要做业务权限。但显式写出来,代码审查时不容易被漏掉。
你们插件里还遇到过哪些"看起来有权限实际没权限"的阴间场景?比如 `edit_others_posts` 和 `edit_published_posts` 那种颗粒度陷阱,欢迎丢出来一起排雷。

