以前、ほかのエンジニアが作ったダッシュボードの機能(ウィジェット)を見直していたときのことです。
ウィジェットの値の保存の処理を 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 が動いたら、記録するだけのものです。
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 しました。
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 つ保存するだけのウィジェットにして書きます(名前は例です)。
// ダッシュボードのウィジェット:フォームを送ると、設定の値を保存する
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 を入れます。
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} のフックで受けるほうがすっきりします。
// フォーム:送り先を 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 を見て保存しているものがないかを見ます。
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}を使うほうがよい


