2026年10月13日
2026年10月13日
CSRFとXSS攻撃の対策方法(WordPressのコード例つき)
WordPress で CSRF と XSS を防ぐ基本は、状態を変える処理に「権限チェック+nonce の検証」を入れること、入力はサニタイズし、出力は出力先に合ったエスケープ関数で処理することの2点です。この記事では、2つの攻撃の違いと関係を整理したうえで、自作のテーマやプラグイン、functions.php のコードで実際に使う関数を、悪い例と良い例を対比しながら説明します。
CSRFとXSSの違いと、なぜ一緒に考えるのか
CSRF(クロスサイトリクエストフォージェリ) は、ログイン中の利用者に、本人の意図しないリクエストを送らせる攻撃です。たとえば管理者が別のサイトを開いた瞬間に、そのページに仕込まれたフォームが自動送信され、WordPress の設定が書き換えられる、といった形です。ブラウザはリクエストにログイン中の Cookie を自動で付けるため、サーバーは本人の操作と区別できません。
XSS(クロスサイトスクリプティング) は、攻撃者が用意したスクリプトを、対象サイトのページの中で実行させる攻撃です。投稿やコメント、URL のパラメータなどに含まれた文字列が、エスケープされずに HTML として出力されると起こります。データベースに保存されて表示のたびに実行される「格納型」、URL などの値がそのまま返される「反射型」、JavaScript が DOM を書き換える過程で起こる「DOM 型」があります。
この2つを一緒に考える理由は、XSS があると CSRF 対策が意味をなさなくなるからです。CSRF 対策の nonce はページの中に埋め込まれているため、同じページで攻撃者のスクリプトが動けば nonce を読み取ってリクエストを送れてしまいます。CSRF 対策と XSS 対策は、どちらか一方ではなく両方そろって初めて効果を発揮します。
CSRF対策: 権限チェックとnonceをセットで使う
WordPress の nonce は、特定のユーザーが特定の操作を、一定時間内に行うためのトークンです。既定では最長で約24時間有効で、ログインしているユーザーとセッションにひも付きます。名前は「一度だけ使える数値(number used once)」に由来しますが、WordPress の nonce は有効期間内なら何度でも使える点に注意してください。
もうひとつ大事なのは、nonce は「そのリクエストが自サイトの画面から送られたこと」を確かめるもので、「その人に操作する権限があること」は確かめない点です。権限は current_user_can() で別に確認します。
悪い例です。権限も nonce も確認せずに、送られてきた値で設定を更新しています。
// 悪い例: だれでも、どこからでも設定を書き換えられる
add_action( 'admin_init', function () {
if ( isset( $_POST['myplugin_note'] ) ) {
update_option( 'myplugin_note', $_POST['myplugin_note'] );
}
} );
良い例です。フォームの出力側で wp_nonce_field() を使い、受け取る側で権限と nonce を確認します。
// フォームの出力(管理画面のページ内)
?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="myplugin_save_note">
<?php wp_nonce_field( 'myplugin_save_note', 'myplugin_nonce' ); ?>
<input type="text" name="myplugin_note" value="<?php echo esc_attr( get_option( 'myplugin_note', '' ) ); ?>">
<?php submit_button( '保存' ); ?>
</form>
<?php
// 受け取り側
add_action( 'admin_post_myplugin_save_note', 'myplugin_save_note' );
function myplugin_save_note() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'この操作を行う権限がありません。', 'myplugin' ), '', array( 'response' => 403 ) );
}
check_admin_referer( 'myplugin_save_note', 'myplugin_nonce' );
$note = isset( $_POST['myplugin_note'] )
? sanitize_text_field( wp_unslash( $_POST['myplugin_note'] ) )
: '';
update_option( 'myplugin_note', $note );
wp_safe_redirect( admin_url( 'options-general.php?page=myplugin' ) );
exit;
}
check_admin_referer() は nonce が無効なら処理を止めます。$_POST の値は WordPress によってスラッシュが付加されているため、wp_unslash() で戻してからサニタイズします。
Ajax(admin-ajax.php)では wp_create_nonce() で作った値を JavaScript に渡し、受け取り側で check_ajax_referer( 'myplugin_ajax', 'nonce' ) を使います。REST API で独自のエンドポイントを作る場合は、register_rest_route() の permission_callback に権限チェックを書きます。ログイン中のユーザーとして JavaScript から REST API を呼ぶときは、wp_create_nonce( 'wp_rest' ) の値を X-WP-Nonce ヘッダーで送ると、Cookie 認証のリクエストとして扱われます。permission_callback に __return_true を指定すると誰でも呼び出せるエンドポイントになるため、公開してよい読み取り専用のものに限ってください。
ブラウザ側の対策として、Cookie の SameSite 属性も CSRF を起こりにくくします。多くのブラウザは属性が指定されていない Cookie を Lax として扱いますが、ブラウザによって挙動が異なるため、SameSite だけに頼らず nonce による検証を基本にします。
XSS対策: 入力はサニタイズ、出力はエスケープ
XSS を防ぐ中心は出力時のエスケープです。保存前のサニタイズも大事ですが、データはインポートや他のプラグインなど、自分のコードを通らない経路でも入ってくるため、「保存時に処理したから表示時は不要」とは考えないでください。WordPress のコーディング規約でも、出力の直前でエスケープする「遅いエスケープ」が推奨されています。
エスケープ関数は、出力する場所に合わせて選びます。
- HTML の本文として出す:
esc_html() - HTML の属性値に入れる:
esc_attr() hrefやsrcに URL を入れる:esc_url()(javascript:などの危険なスキームを取り除きます)- テキストエリアの中に入れる:
esc_textarea() - 一部の HTML タグを許可したい:
wp_kses_post()や、許可するタグを指定したwp_kses()
悪い例と良い例を並べます。
// 悪い例: 検索語や投稿メタをそのまま出力している
echo '<p>検索語: ' . $_GET['q'] . '</p>';
echo '<a href="' . get_post_meta( $post->ID, 'link', true ) . '">リンク</a>';
// 良い例: 出力先に合わせてエスケープする
$q = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : '';
echo '<p>検索語: ' . esc_html( $q ) . '</p>';
echo '<a href="' . esc_url( get_post_meta( $post->ID, 'link', true ) ) . '">リンク</a>';
入力側では、値の種類に合ったサニタイズ関数を使います。1行のテキストは sanitize_text_field()、複数行は sanitize_textarea_field()、メールアドレスは sanitize_email()、整数は absint()、キーやスラッグは sanitize_key() が代表的です。選択肢から選ぶ値は、許可した値の一覧と照合する方が確かです。
JavaScriptで起こるXSSを防ぐ
PHP 側でエスケープしていても、JavaScript で受け取った値を innerHTML で差し込むと DOM 型の XSS が起こります。文字列として表示するなら textContent を使います。
// 悪い例
listItem.innerHTML = data.title;
// 良い例
listItem.textContent = data.title;
PHP から JavaScript に値を渡すときも、文字列を連結して <script> を組み立てるのではなく、wp_add_inline_script() と wp_json_encode() を組み合わせるか、wp_localize_script() を使います。
wp_enqueue_script( 'myplugin-admin', plugins_url( 'admin.js', __FILE__ ), array(), '1.0.0', true );
wp_add_inline_script(
'myplugin-admin',
'const MyPlugin = ' . wp_json_encode( array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'myplugin_ajax' ),
) ) . ';',
'before'
);
運用面での対策
自分のコード以外にも、次の点を確認しておくと被害の可能性を下げられます。
- unfiltered_html 権限: シングルサイトでは管理者と編集者が、投稿にスクリプトを含む HTML を書ける権限を持っています。アカウントが乗っ取られると格納型 XSS の入り口になるため、編集者の人数を絞り、強いパスワードと二要素認証を使います。
wp-config.phpにdefine( 'DISALLOW_UNFILTERED_HTML', true );を書いて、この権限を外すこともできます - プラグインとテーマの更新: 公開されている XSS や CSRF の脆弱性の多くは、プラグインやテーマで見つかっています。更新を止めているものや、配布が終了したものがないか定期的に確認します
- Content-Security-Policy: 実行を許可するスクリプトの読み込み元を HTTP ヘッダーで制限でき、XSS が起きた場合の被害を減らせます。ただし WordPress のコアや多くのプラグインはインラインスクリプトを使うため、厳しい設定はそのままでは管理画面などが動かなくなります。まず
Content-Security-Policy-Report-Onlyで違反を記録し、影響を見ながら調整してください - WAF: Wordfence のようなセキュリティプラグインや、クラウド型の WAF は既知の攻撃パターンを止める助けになりますが、コード側の対策の代わりにはなりません
テストは自分が管理するサイト、できればステージング環境で行い、他人のサイトに攻撃用の文字列を送らないでください。WordPress のバージョンや関数の仕様は変わることがあるため、使う関数は WordPress の開発者向けドキュメントで確認してください(2026年10月現在)。
まとめ
- CSRF は「意図しないリクエストを送らせる」、XSS は「スクリプトを実行させる」攻撃で、XSS があると nonce による CSRF 対策も破られる
- 状態を変える処理には
current_user_can()と nonce の検証(check_admin_referer()、check_ajax_referer()、REST のpermission_callback)をセットで入れる - 入力は
sanitize_text_field()などでサニタイズし、出力はesc_html()、esc_attr()、esc_url()などを出力の直前で使う - JavaScript では
innerHTMLではなくtextContentを使い、値はwp_json_encode()で渡す - 編集権限の管理、プラグインの更新、CSP や WAF は補助として組み合わせる