WordPress.orgにプラグインを申請したら、セキュリティ面で差し戻されたことがあります。しかも、自分では対策したつもりの箇所でした。
何が足りなかったのかを一つずつ潰していくうちに、「プラグインのセキュリティで見るべき箇所」は、だいたい決まっていると気づきました。散らばって覚えていると抜けるので、1枚のチェックリストにまとめておきます。申請前や、レビュー前に、上から順に見る用です。個別の詳しい実装は、それぞれ別記事へのリンクを置いています。
見るべきは、6つの入り口
プラグインで攻撃が入る(あるいは事故が起きる)のは、だいたい次の6つの入り口でした。
入力の受け取り。DBへのアクセス。出力の表示。権限と認可。預かった秘密の保管。そして、AIを使うなら、その出力の扱い。順に、確認する点を書きます。
1. 入力:受け取った値を、信用しない
フォーム、URLパラメータ、AJAX、REST。外から来る値は、全部サニタイズしてから使います。
// 受け取りは wp_unslash + sanitize、用途に合った関数で
$name = isset($_POST['name']) ? sanitize_text_field(wp_unslash($_POST['name'])) : '';
$email = isset($_POST['email']) ? sanitize_email(wp_unslash($_POST['email'])) : '';
そして、状態を変える操作(保存・削除・送信)には、nonce を必ず付けます。CSRF対策です。
// 送信側でnonceを埋め、受け取り側で検証する
check_admin_referer('myplugin_save'); // 失敗すれば処理を止める
2. DB:クエリは prepare を通す
値を文字列連結でSQLに埋めない。$wpdb->prepare に通します。
// ✅ 値はプレースホルダで
$wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$wpdb->posts} WHERE post_author = %d", $author_id
));
実務でつまずくのは、IN句(配列をプレースホルダの数だけ作る)、LIKE(esc_like を通す)、テーブル名(プレースホルダにできない)あたりです。ここは別記事にまとめました。
3. 出力:表示する直前に、エスケープする
保存時ではなく、表示する瞬間にエスケープします。埋める場所によって関数を変えます。
echo esc_html($text); // テキスト
echo '<a href="' . esc_url($url) . '">'; // URL
echo '<input value="' . esc_attr($val) . '">'; // 属性値
保存時にきれいに見えても、再表示のときにエスケープが抜けると、そこで事故ります。「保存はそのまま、表示でエスケープ」が原則です。
4. 権限と認可:この操作を、この人がしていいか
ログインしているか、ではなく、その操作をする権限があるか、を見ます。
// AJAX/RESTの機密操作は、権限チェックを必ず入れる
if (!current_user_can('manage_options')) {
wp_send_json_error('forbidden', 403);
}
とくにWordPress 7.0では、REST APIの permission_callback が省略できなくなりました。公開APIでも __return_true と明示が要ります。ここは差し戻しの定番です。
JSでボタンを隠すのは、認可ではありません。エンドポイントは直接叩けるので、認可は必ずサーバー側に置きます。
5. 秘密:預かった鍵を、平文で置かない
APIキーやトークンを預かるなら、update_option に平文で入れない。DBだけが漏れる経路(他プラグインのSQLi、バックアップの置き忘れ)は現実にあります。
wp-configの salt から鍵を導出して、AES-256-GCMで暗号化して保存する。鍵をコードに直書きしてもDBに置いても意味がないので、ファイル側の salt から導くのが落としどころでした。
6. AIを使うなら:出力も外部入力として扱う
AIチャットボットなどを積むなら、追加で3つ見ます。
LLMの返答を、そのまま innerHTML に入れない。返答は外部入力と同じで、<script> が紛れうる。表示前にエスケープします。
BYOKで請求が青天井にならないよう、1日あたりの利用上限をサーバー側で持つ。呼ぶ前に判定して、超えていたらAIを呼ばずに返す。
根拠がないのに、それらしい嘘を答えさせない。サイト内を検索して、十分な根拠がなければ答えさせない。
申請前に、grep で洗う
最後に、抜けがないか機械的に洗います。
# 受け取り・クエリ・出力・権限・危険な出力先を一気に確認
grep -rn "\$_POST\|\$_GET\|\$_REQUEST\|wpdb->query\|wpdb->get_\|echo\|innerHTML\|current_user_can\|register_rest_route" ./
ヒットした箇所を、上の1〜6と照らし合わせる。サニタイズは通っているか。prepareを経由しているか。エスケープしているか。権限を見ているか。この照合を申請前にやると、差し戻しがだいぶ減りました。
早見でまとめ
- 入力:
wp_unslash+sanitize_*、状態変更には nonce - DB:
$wpdb->prepare。IN句・LIKE・テーブル名の扱いに注意 - 出力:表示の直前に
esc_html/esc_url/esc_attr。保存はそのまま - 認可:
current_user_canはサーバー側で。7.0はpermission_callback必須 - 秘密:APIキーは平文で置かず、saltから鍵を導いて暗号化
- AI:出力も外部入力。innerHTML直挿し禁止、利用上限、根拠のない回答をさせない
- 申請前に grep で該当箇所を洗い、1〜6と照合する
セキュリティは、一つひとつは知っていても、抜けるのは「全部を一度に確認していない」ときでした。この1枚を申請前に開いて、上から照合するようにしてから、差し戻しが減りました。役に立ったらストックして、リリース前のチェックに使ってください。
ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。