Claude CodeでWordPressのnonce・権限・エスケープ漏れを洗い出す
security-review後に、nonce検証・current_user_can・esc_*・$wpdb->prepareの抜けを観点として渡すWordPressプラグインの点検手順を、抜けのあるコードと修正後のコードで示します。
WordPressプラグインの脆弱性は、nonceの検証、権限チェック、出力のエスケープ、SQLのプレースホルダという4か所の抜けに集中しやすい領域です。Claude Codeの/security-reviewは、ブランチの差分をインジェクションや認証の問題といった観点で調べます。ただしコマンド一覧に引数の記載はなく、WordPress固有の合格基準は手元から渡せません。そこで/security-reviewを通したあとに、WordPressの観点をプロジェクトのスキルとして渡す二段構えにします。このページでは、抜けのあるプラグインのコードを題材に、観点の作り方、スキルの書き方、指摘の読み方、直し方までを一通り扱います。
security-reviewだけでは足りない部分を先に決める
/security-reviewは、現在のブランチとoriginのデフォルトブランチとの差分を見て、インジェクション・認証の問題・データ漏出といったリスクを探すコマンドです。コマンド一覧の説明にあるのはこの範囲で、引数の記載はありません。
差分の見方やエラーの直し方はClaude Code security-reviewでブランチの脆弱性を検知するにあります。ここで扱うのは、その手前と後ろです。
- 手前: WordPressの慣習(
admin_post_*、wp_ajax_*、REST、$wpdb)をClaudeが拾えるよう、確認項目を言葉にしておく - 後ろ: 指摘が本当に抜けなのか、WordPressの規則に照らして1件ずつ確かめる
WordPressのセキュリティは、関数ごとに「この文脈ではこれ」と決まっています。汎用のレビューが「入力の検証が足りません」と書くだけでは、wp_verify_nonceなのかcurrent_user_canなのかが分かりません。観点を名前つきで渡すと、指摘が直す場所に結びつきます。
抜けやすい5つの観点と、公式の規則
WordPress公式のセキュリティ解説から、プラグインで抜けやすい規則を5つ取り出します。ここに並べたものが、後でスキルに書き込む確認項目になります。
| 観点 | 抜けの形 | 確認する関数・規則 |
|---|---|---|
| nonce | 抜けの形状態を変えるURL・フォーム・AJAXに検証が無い | 確認する関数・規則check_admin_referer、check_ajax_referer、wp_verify_nonce |
| 権限 | 抜けの形誰でも呼べる処理になっている | 確認する関数・規則current_user_can(必要な能力を指定) |
| 出力 | 抜けの形echoに変数を直接渡している | 確認する関数・規則esc_html、esc_attr、esc_url、esc_js、wp_kses_post |
| SQL | 抜けの形文字列連結でクエリを組んでいる | 確認する関数・規則$wpdb->prepareのプレースホルダ、esc_like |
| REST | 抜けの形ルート登録にpermission_callbackが無い | 確認する関数・規則公開APIなら__return_trueを明示 |
nonceは認可の代わりにならない
nonceはURLやフォームを悪用した偽装リクエスト(CSRF)から守る仕組みです。公式の解説は、nonceを認証・認可・アクセス制御に頼ってはいけないと明記し、保護する関数はcurrent_user_can()で守る、nonceは漏れるものと考える、と述べています。つまり、nonceと権限チェックは両方必要です。
公式の解説にはほかにも、レビューで拾える性質が書かれています。
- nonceの検証はコンテキストごとに関数が違う。管理画面のURL・フォームは
check_admin_referer、AJAXはcheck_ajax_referer、それ以外はwp_verify_nonce - 検証が
falseなら処理を続けない - アクション文字列は、できるだけ具体的にする(投稿IDを含めるなど)
- ログインしていない訪問者には、既定で同じnonceが発行される。ゲストにも守りたい重要な操作は、独自のセッションの仕組みと
nonce_user_logged_outフィルターで補う
最後の点は見落とされがちです。公開フォームにnonceを付けても、ゲストのCSRF対策にはなっていないことがあります。
権限チェックは「呼べる人」の範囲を決める
WordPressのユーザー解説にも、権限を確認しないリンクの例があります。ログイン状態を問わず、誰がクリックしても投稿をゴミ箱へ送れるコードで、edit_others_postsの確認を加えると編集者以上に限定されます。能力の選び方が緩すぎる(たとえば購読者にも許す)ケースも、レビューで拾う価値があります。
エスケープは「出力する場所」で選ぶ
エスケープの解説は、文脈に合う関数を使うこと、出力の直前に行うこと(late escaping)を求めています。
- HTML要素の中身は
esc_html、属性はesc_attr、URLはesc_url、インラインのJavaScriptはesc_js - 属性の一部に変数を使うときは、連結した文字列全体を1回の
esc_attrで包む。変数ごとに別々に包むと、変数の直前にあるエスケープ対象の文字を取りこぼす - 信頼できないHTMLを残したいときは
wp_ksesかwp_kses_post - URLを
esc_attrで包むのは誤り。URLにはesc_url
SQLはプレースホルダで書く
$wpdb->prepare()は%s(文字列)、%d(整数)、%i(識別子)などのプレースホルダを使い、プレースホルダの前後は引用符で囲みません。LIKEのワイルドカード%は、クエリ文字列に直接書かず、完全なLIKE文字列を引数として渡します。ユーザー入力に含まれる%や_は$wpdb->esc_like()で無害化します。
RESTルートは権限コールバックを宣言する
register_rest_routeでpermission_callbackを渡さないと、WordPress 5.5以降は_doing_it_wrongの通知が出ます。公開してよいルートは'permission_callback' => '__return_true'と明示します。レビュー側は「公開のつもりで__return_trueを書いたのか、書き忘れか」を区別したい場面です。
抜けのあるプラグインのコードを用意する
題材は、メモを保存・削除・検索する小さな管理画面プラグインです。次のコードは、上の5つの観点を意図的に抜いた例で、実在のプラグインではありません(php -lで構文エラーが無いことは確認済みです)。
<?php
/** Plugin Name: Memo Board */
add_action( 'admin_post_mb_save', 'mb_save' );
function mb_save() {
global $wpdb;
$title = $_POST['title'];
$wpdb->query(
"INSERT INTO {$wpdb->prefix}mb_memo (title) VALUES ('$title')"
);
wp_safe_redirect( admin_url( 'admin.php?page=memo-board' ) );
exit;
}
add_action( 'wp_ajax_mb_delete', 'mb_delete' );
function mb_delete() {
global $wpdb;
$wpdb->delete(
$wpdb->prefix . 'mb_memo',
array( 'id' => (int) $_POST['id'] )
);
wp_send_json_success();
}
function mb_render_page() {
global $wpdb;
$q = $_GET['s'] ?? '';
$rows = $wpdb->get_results(
"SELECT * FROM {$wpdb->prefix}mb_memo WHERE title LIKE '%$q%'"
);
echo '<h1>Search: ' . $q . '</h1>';
foreach ( $rows as $r ) {
echo '<li data-id="' . $r->id . '">' . $r->title . '</li>';
}
}
add_action( 'rest_api_init', function () {
register_rest_route( 'memo-board/v1', '/export', array(
'methods' => 'GET',
'callback' => 'mb_export',
) );
} );意図的に仕込んだ抜けは、次の対応です。
| 場所 | 抜け | 該当する観点 |
|---|---|---|
mb_save | 抜けnonce検証なし、権限チェックなし、入力をそのままSQLへ連結 | 該当する観点nonce / 権限 / SQL |
mb_delete | 抜けnonce検証なし、権限チェックなし | 該当する観点nonce / 権限 |
mb_render_pageのクエリ | 抜け$qの連結、LIKEのワイルドカードを無害化していない | 該当する観点SQL |
mb_render_pageの出力 | 抜け$q・$r->id・$r->titleをそのままecho | 該当する観点出力 |
REST /export | 抜けpermission_callbackなし | 該当する観点REST |
mb_deleteの(int)キャストは整数化としては働きますが、nonceも権限も無いので、ログイン済みのユーザーなら誰でも削除リクエストを送れます。この種の「一見安全に見える抜け」が、観点を渡す意味のある場所です。
WordPressの観点をスキルとして渡す
確認項目はプロジェクトのスキルにしておくと、毎回同じ観点で呼び出せます。プロジェクトのスキルは.claude/skills/<スキル名>/SKILL.mdに置き、コミットすればチームにも配れます。disable-model-invocation: trueを付けると、Claudeが自動で呼ばず、手で/wp-security-auditと打ったときだけ動きます。allowed-toolsは、そのターンに限って許可なしで使えるツールの指定です。
.claude/skills/wp-security-audit/SKILL.md---
name: wp-security-audit
description: WordPressプラグインの差分を、nonce・権限・エスケープ・SQLの観点で点検する
disable-model-invocation: true
allowed-tools: Read Grep Bash(git diff *)
---
次の観点で、現在のブランチの差分を点検してください。
対象を指定されたときは、そのパスも読みます: $ARGUMENTS
1. 状態を変えるハンドラ(admin_post_*, wp_ajax_*, フォーム処理)に
check_admin_referer / check_ajax_referer / wp_verify_nonce があるか
2. 同じハンドラに current_user_can があり、能力が操作に対して緩すぎないか
3. echo / print に渡す変数が esc_html / esc_attr / esc_url / esc_js /
wp_kses_post で包まれているか。属性は連結した全体を1回で包んでいるか
4. $wpdb->query / get_results / get_var に変数が連結されていないか。
prepare のプレースホルダを引用符で囲んでいないか。LIKE は esc_like か
5. register_rest_route に permission_callback があるか。
__return_true は、公開の意図がある場合だけ許す
指摘は「ファイル:行 / 観点番号 / 抜けの内容 / 直し方の1行」の形で出し、
問題が無い観点も1行で「確認済み」と書いてください。
ソースは修正せず、報告だけにしてください。スキルはYAMLのフロントマターと本文でできていて、$ARGUMENTSには呼び出し時に打った文字列が入ります。/wp-security-audit includes/のようにパスを足せば、その場所を追加で読ませられます。観点をCLAUDE.mdに書く方法もあります。CLAUDE.mdはセッションの開始時に毎回読まれるので、スキルを呼ばなくても観点が前提になります。ただし常にコンテキストへ載るため、WordPressのレビュー専用の長い手順はスキルに分けるほうが身軽です。
実行の流れ
差分を2つの観点で見る流れ
- 1
ブランチを切って変更を差分にする
プラグインの変更をブランチにコミットします。
/security-reviewは、このブランチとoriginのデフォルトブランチとの差分を対象にするためです。originが無いと失敗します(原因と直し方は上記の記事にあります)。 - 2
/security-reviewで広く見る
引数なしで
/security-reviewを実行します。インジェクションや認証の問題といった一般的な観点の指摘を集めます。 - 3
/wp-security-auditでWordPressの観点を足す
同じセッションで
/wp-security-auditを実行します。上の5観点について、問題の有無を1行ずつ報告させます。 - 4
指摘をひとつずつ規則と照らす
報告された関数名と、公式の規則を突き合わせます。根拠が確認できた指摘から直します。
- 5
直して差分をもう一度かける
修正をコミットし、2つのコマンドを続けて再実行します。観点ごとの「確認済み」が揃えば終わりです。
この記事では2つを別々に実行します。どちらの指摘がどこから来たかを、あとで見分けやすいからです。
出力に期待してよいこと、いけないこと
上のスキルが指定する出力は、1件ずつの「場所・観点・内容・直し方」です。確認したいのは、少なくとも次の抜けが報告に挙がっているかどうかです。
mb_saveとmb_delete: nonceと権限(観点1・2)mb_saveとmb_render_page: SQLの連結(観点4)mb_render_page:echoの3か所(観点3)/exportルート:permission_callback(観点5)
挙がらなかった抜けがあれば、観点が足りないか、差分に入っていないかのどちらかです。そのときは、プロンプトで該当ファイルのパスを渡して読ませます。差分に含まれない既存コードは/security-reviewの範囲外なので、既存コードも点検したいときは、/wp-security-audit includes/のように対象パスを渡して読ませます。
直したあとのコード
指摘を受けて直したコードです。同じくphp -lで構文エラーが無いことを確認しています。
<?php
/** Plugin Name: Memo Board */
add_action( 'admin_post_mb_save', 'mb_save' );
function mb_save() {
check_admin_referer( 'mb_save' );
if ( ! current_user_can( 'edit_posts' ) ) {
wp_die( 'Forbidden', '', array( 'response' => 403 ) );
}
global $wpdb;
$title = sanitize_text_field( wp_unslash( $_POST['title'] ?? '' ) );
$wpdb->insert(
$wpdb->prefix . 'mb_memo',
array( 'title' => $title ),
array( '%s' )
);
wp_safe_redirect( admin_url( 'admin.php?page=memo-board' ) );
exit;
}
add_action( 'wp_ajax_mb_delete', 'mb_delete' );
function mb_delete() {
check_ajax_referer( 'mb_delete' );
if ( ! current_user_can( 'edit_others_posts' ) ) {
wp_send_json_error( null, 403 );
}
global $wpdb;
$wpdb->delete(
$wpdb->prefix . 'mb_memo',
array( 'id' => absint( $_POST['id'] ?? 0 ) ),
array( '%d' )
);
wp_send_json_success();
}
function mb_render_page() {
global $wpdb;
$q = sanitize_text_field( wp_unslash( $_GET['s'] ?? '' ) );
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}mb_memo WHERE title LIKE %s",
'%' . $wpdb->esc_like( $q ) . '%'
)
);
echo '<h1>Search: ' . esc_html( $q ) . '</h1>';
foreach ( $rows as $r ) {
echo '<li data-id="' . esc_attr( $r->id ) . '">'
. esc_html( $r->title ) . '</li>';
}
}
add_action( 'rest_api_init', function () {
register_rest_route( 'memo-board/v1', '/export', array(
'methods' => 'GET',
'callback' => 'mb_export',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
) );
} );直した箇所を観点ごとに並べると、次のとおりです。
修正前と修正後の対応
修正前
nonceなし、権限なし、変数をそのままSQLとechoに連結、RESTはpermission_callbackなし。
修正後
check_*_refererとcurrent_user_canを先頭に置き、書き込みは$wpdb->insertのフォーマット指定、検索はprepareとesc_like、出力はesc_htmlとesc_attr、RESTは能力で守る。
フォーム側にも対応が要ります。nonceを検証するだけでなく、フォームにwp_nonce_field( 'mb_save' )を出力して初めて通ります。AJAXならwp_create_nonce( 'mb_delete' )を作って、JavaScriptへ渡します。スキルの観点1を「生成と検証の両方があるか」と書いておけば、片側だけの修正も再実行で見つかります。
指摘をうのみにしないための確認
Claudeの報告は、読む側が規則に照らして確かめる前提で使います。つまずきやすい場面を挙げます。
落とし穴
nonceを付けただけで権限チェックが消える
nonceは偽装リクエストへの備えで、認可の代わりではありません。報告に「nonce検証あり」と書かれていても、観点2が空なら権限は未確認です。観点1と観点2は、同じハンドラで両方そろっているかを見ます。
落とし穴
公開RESTを「抜け」と誤判定する
意図して公開しているルートでは__return_trueが正解です。指摘を直す前に、そのルートが本当に公開でよいかをプラグインの仕様で確かめます。逆に、書き忘れのpermission_callbackは、公開と管理者限定のどちらにするかを決めてから直します。
落とし穴
エスケープを重ねて二重にする
保存時にesc_htmlをかけてから、表示時にもう一度かけると文字が壊れます。保存時は入力の整形(sanitize_*)と検証、表示時は文脈に合うエスケープ、と役割を分け、エスケープは出力の直前に置きます。
指摘の照合に使える質問も添えておきます。
- この出力先は、HTMLの要素・属性・URL・JavaScriptのどれか。関数は文脈に合っているか
- このハンドラを呼べる最小の能力は何か
- 状態を変える操作に、生成と検証の両方のnonceがあるか
prepareのプレースホルダは引用符なしか。LIKEはesc_likeか
運用に載せるときの置き場所
2つのコマンドは、ローカルで書いた直後に流すのが最も小回りが利きます。PRの段階で自動化したいときは、GitHub Actions版の入力を調整するclaude-code-security-review Actionの3入力が参考になります。編集のたびに典型的な脆弱性を検知させるなら、security-guidanceプラグインが別の層を担います。
プラグインの開発自体の進め方はClaude CodeでWordPressプラグイン開発を始めるに、テーマ側の作業はClaude CodeのBuild with WordPressプラグインでテーマを作るにあります。
セキュリティ解説は、機密性の高いコードを扱うときの基本として、提案された変更を承認前にすべて確認すること、リポジトリごとの権限設定を使うこと、必要ならdev containerなどで隔離することを挙げています。観点を渡しても、最終的に直すかどうかを決めるのは、差分を読む人です。
まとめ
/security-reviewは一般的な脆弱性の入り口で、nonce・current_user_can・esc_*・$wpdb->prepare・permission_callbackのWordPress固有の規則は、スキルとして別に渡すのが現実的です。観点を5つに絞ると、指摘が直す場所と結びつき、公式の規則で1件ずつ確認できます。最初の一回は小さなハンドラ1つで試して、スキルの確認項目を自分のプラグインの書き方に合わせて育てる進め方が、無理のない出発点です。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。