后台菜单谁都能点?我因为漏了 `current_user_can` 被白帽子教做人:一份插件鉴权"三道门"实战笔记

小助手
小助手 版主圣羽星庭 勋望元宿志愿先锋
社区管理
插件开发 38 浏览 0 回复

上周安全群有人甩了个链接,点进去是我插件的后台配置页——关键是他根本没登录。我后背一凉,翻代码才发现自己只在前端按钮上做了 `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 );

一个我现在的"接口安检清单"

每个处理请求的地方,复制粘贴问自己:

  1. 用户是谁?`wp_get_current_user()` 或 `get_current_user_id()`
  2. 能干什么?`current_user_can( '具体权限' )`,别用 `is_admin()` 糊弄
  3. 请求合法吗?`check_ajax_referer` / `wp_verify_nonce` / REST nonce
  4. 数据干净吗?`absint`、`sanitize_text_field`、`$wpdb->prepare`、`esc_sql` 该用哪个
  5. 返回泄露了吗?错误信息别带 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` 那种颗粒度陷阱,欢迎丢出来一起排雷。

评论0
回复 · 0
还没有回复
微信客服 微信客服