admin_init はログインしていなくても動く:ダッシュボードのウィジェットの保存に、権限と nonce の確かめを足す

広告WordPress・PHP を学べる本

Amazonのアソシエイトとして、WordPress functions.phpは適格販売により収入を得ています。

以前、ほかのエンジニアが作ったダッシュボードの機能(ウィジェット)を見直していたときのことです。

ウィジェットの値の保存の処理を admin_init に書き、isset( $_POST[...] ) だけを見て保存していました。権限の確かめも、nonce の確かめもありません。

「管理画面の中の処理だから、ログインした人しか動かせない」と思いがちですが、そうではありません。admin_init は、ログインしていない人のアクセスでも動きます。そのため、悪意のある人が、わざと決まった名前の値を送れば、外から値を変えられる状態でした。ふつうに使っていて困ることはありませんが、直しておくべき所です。

admin_init はどこで動くか

WordPress の公式の資料(developer.wordpress.org)の admin_init のページに、次のように書かれています。

  • 管理画面の画面だけでなく、admin-ajax.php と admin-post.php でも動く

この 2 つは、ログインしていない人も呼べる入り口です。admin-ajax.php は、ログインしていない人向けの Ajax(wp_ajax_nopriv_)のためにあります。

作業環境で確かめた

このサイトの作業環境(WordPress 7.1.3・PHP 8.3)に、検証用のオリジナルプラグインを置いて確かめました。決まった項目を POST したときに admin_init が動いたら、記録するだけのものです。

wp-content/mu-plugins/(検証用のオリジナルプラグイン).php
add_action( 'admin_init', function () {
    if ( ! isset( $_POST['zz_admininit_test'] ) ) {
        return;
    }
    update_option( 'zz_admininit_test', array(
        'script'    => basename( $_SERVER['SCRIPT_NAME'] ?? '' ),
        'logged_in' => is_user_logged_in() ? 1 : 0,
    ), false );
} );

Cookie を付けずに(ログインしていない状態で)、curl で POST しました。

ログインしていない状態で POST する
curl -s -o /dev/null -w '%{http_code}' -X POST -d "zz_admininit_test=1" http://functions.local/wp-admin/admin-ajax.php
curl -s -o /dev/null -w '%{http_code}' -X POST -d "zz_admininit_test=1" http://functions.local/wp-admin/admin-post.php
curl -s -o /dev/null -w '%{http_code}' -X POST -d "zz_admininit_test=1" http://functions.local/wp-admin/index.php
送り先 返ってきた HTTP admin_init ログイン
/wp-admin/admin-ajax.php 400(本文は「0」) 動いた していない
/wp-admin/admin-post.php 400 動いた していない
/wp-admin/index.php 302(ログイン画面へ) 動かなかった していない

admin-ajax.php と admin-post.php は、返事は 400 でも、その前に admin_init が動いています。返事がエラーでも、保存はもう終わっているということです。

ふつうの管理画面(index.php)は、admin_init の前にログイン画面へ移されるので、動きませんでした。

確かめたあと、検証用のオリジナルプラグインと記録した値は消しました。

前の書き方(まずい形)

説明のために、設定の値を 1 つ保存するだけのウィジェットにして書きます(名前は例です)。

functions.php(前)
// ダッシュボードのウィジェット:フォームを送ると、設定の値を保存する
function my_widget_save() {
    if ( isset( $_POST['my_widget_value'] ) ) {
        update_option( 'my_widget_setting', $_POST['my_widget_value'] );
    }
}
add_action( 'admin_init', 'my_widget_save' );

function my_widget_render() {
    echo '<form method="post">';
    echo '<input type="text" name="my_widget_value" value="' . get_option( 'my_widget_setting' ) . '">';
    echo '<p><input type="submit" class="button button-primary" value="保存"></p>';
    echo '</form>';
}
書き方 役目
add_action( 'admin_init', ... ) 管理画面を開いたときに保存の処理を動かす(つもり)
isset( $_POST['my_widget_value'] ) フォームが送られたかを見る。だれが送ったかは見ていない
update_option( ... ) サイト全体の設定に保存する。ログインしていなくても保存できる
echo ... get_option(...) 保存した値を、そのまま画面に出している

この形だと、悪意のある人が、admin-ajax.php に決まった名前の値をわざと送れば、ログインしていなくても値を変えられます。値をそのまま画面に出しているので、変えられた値にスクリプトが入っていれば、管理者がダッシュボードを開いたときに動くおそれもあります。

ユーザーごとの値に保存していても、CSRF は残る

get_current_user_id() のユーザーに保存する形なら、ログインしていないと ID は 0 で、update_user_meta( 0, ... ) は何もせずに false を返しました(作業環境で確かめた)。

ただし、nonce がないので、ログインしている人に、ほかのサイトからフォームを送らせること(CSRF)はできます。保存先がどこでも、権限と nonce の確かめが要ります。

直し方:権限と nonce を確かめる

保存の前に、次の 2 つを確かめます。

  • 権限:current_user_can() で、その操作をしてよい人か
  • nonce:wp_verify_nonce() で、自分のサイトのフォームから送られたか

フォームには wp_nonce_field() で nonce を入れます。

functions.php(直した形)
function my_widget_save() {
    if ( ! isset( $_POST['my_widget_value'] ) ) {
        return;
    }
    // 権限と nonce を確かめてから保存する
    if ( ! current_user_can( 'manage_options' )
        || ! isset( $_POST['my_widget_nonce'] )
        || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['my_widget_nonce'] ) ), 'my_widget_save' ) ) {
        return;
    }
    update_option( 'my_widget_setting', sanitize_text_field( wp_unslash( $_POST['my_widget_value'] ) ) );
}
add_action( 'admin_init', 'my_widget_save' );

function my_widget_render() {
    echo '<form method="post">';
    wp_nonce_field( 'my_widget_save', 'my_widget_nonce' );
    echo '<input type="text" name="my_widget_value" value="' . esc_attr( get_option( 'my_widget_setting' ) ) . '">';
    echo '<p><input type="submit" class="button button-primary" value="保存"></p>';
    echo '</form>';
}
書き方 役目
current_user_can( 'manage_options' ) サイトの設定を変えてよい人(管理者)だけにする。ログインしていなければ false
wp_verify_nonce( ..., 'my_widget_save' ) フォームに入れた nonce が正しいか。正しくなければ false
wp_unslash() + sanitize_text_field() WordPress が足したバックスラッシュを外し、余計な文字を取り除く
wp_nonce_field( 'my_widget_save', 'my_widget_nonce' ) フォームに nonce の隠し項目を入れる。action の名前は確かめる側とそろえる
esc_attr() 属性の中に出すときに、HTML として働かないようにする

どの権限で確かめるかは、保存する値に合わせます。サイト全体の設定なら manage_options、ユーザーごとの値なら edit_posts や read など、その画面を使う人に合わせます。複数行の文を保存するなら、保存は sanitize_textarea_field()、表示は esc_textarea() を使います。

確かめた結果

作業環境(WordPress 7.1.3・PHP 8.3)で、見直していた機能の前の形と、上と同じやり方(権限と nonce の確かめ)で直した形を並べて試しました。「ログインしていない」は curl で admin-ajax.php に POST、ほかは PHP のスクリプトで管理者としてログインした状態を作って試しました。

送り方 前の形 直した形
ログインしていない(admin-ajax.php に POST) 保存された 保存されない
管理者・nonce なし 保存された 保存されない
管理者・まちがった nonce 保存された 保存されない(wp_verify_nonce は false)
管理者・正しい nonce 保存された 保存された(wp_verify_nonce は 1)

直した形は、正しいフォームから、権限のある人が送ったときだけ保存されます。

見直していた機能も、この形に直しました。

もっとよい形:admin-post.php に送る

admin_init は、管理画面のすべてのアクセスで動きます。保存のためだけなら、admin-post.php に送り、admin_post_{action} のフックで受けるほうがすっきりします。

functions.php(admin-post.php を使う形)
// フォーム:送り先を admin-post.php にして、action を付ける
function my_widget_render() {
    echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '">';
    echo '<input type="hidden" name="action" value="my_widget_save">';
    wp_nonce_field( 'my_widget_save', 'my_widget_nonce' );
    echo '<input type="text" name="my_widget_value" value="' . esc_attr( get_option( 'my_widget_setting' ) ) . '">';
    echo '<p><input type="submit" class="button button-primary" value="保存"></p>';
    echo '</form>';
}

// 保存:admin_post_{action} は、ログインしている人のときだけ呼ばれる
function my_widget_handle_save() {
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_die( '権限がありません。', 403 );
    }
    check_admin_referer( 'my_widget_save', 'my_widget_nonce' );
    update_option( 'my_widget_setting', sanitize_text_field( wp_unslash( $_POST['my_widget_value'] ?? '' ) ) );
    wp_safe_redirect( admin_url() );
    exit;
}
add_action( 'admin_post_my_widget_save', 'my_widget_handle_save' );
書き方 役目
action="admin-post.php" + name="action" 送り先と、どの処理を呼ぶかを決める
admin_post_my_widget_save ログインしている人のときだけ呼ばれる
check_admin_referer() nonce が正しくなければ、そこで止める
wp_safe_redirect() + exit 保存のあと、ダッシュボードに戻す

作業環境で、ログインしていない状態で action=… を付けて admin-post.php に POST すると、admin_post_{action} ではなく admin_post_nopriv_{action} のほうが呼ばれました。ログインしていない人の処理が、別の名前に分かれます。

それでも、権限と nonce の確かめは省きません。ログインしている人の中にも、権限の低い人がいるからです。

自分のテーマを探す

テーマの中で admin_init を使っている所を探し、$_POST を見て保存しているものがないかを見ます。

admin_init を使っている所を探す
grep -rn "add_action( *['\"]admin_init" --include=*.php wp-content/themes
見つけたら確かめること なければ
current_user_can() があるか 権限の確かめを足す
wp_verify_nonce() か check_admin_referer() があるか フォームに nonce を入れ、確かめを足す
保存する前に wp_unslash() と sanitize をしているか 値に合った sanitize の関数を使う
画面に出すときに esc_html() などを通しているか 出す場所に合った esc の関数を使う

同じテーマを使い回しているサイトがあれば、全部のサイトで同じ所を直します。

まとめ

  • admin_init は、admin-ajax.php と admin-post.php でも動く。ログインしていなくても動く
  • admin_init で保存するなら、current_user_can() と wp_verify_nonce() を必ず確かめる
  • 保存だけなら、admin-post.php と admin_post_{action} を使うほうがよい

参考

関連機能

  1. WordPress 固定ページの投稿画面に新規作成したテンプレートファイルを追加する

  2. WordPress の大きな版上げ(メジャーアップデート)を安全に進める手順:作業環境・バックアップ・本体が先・様子見

  3. 公開フォルダに残った古い PHP スクリプトの見つけ方と片付け方:手元のコピーで探して、外から開かずに片付ける

サイドバー

よく使うカスタマイズ

最近の記事

Profile

PAGE TOP