2026年5月20日
2026年5月20日
WordPressのXSS(クロスサイトスクリプティング)対策:エスケープとwp_ksesの使い方
XSS(クロスサイトスクリプティング)は、攻撃者が用意した文字列がそのまま HTML として出力され、訪問者や管理者のブラウザでスクリプトが動いてしまう脆弱性です。管理者がログインした状態でスクリプトが動くと、ユーザーの追加や設定の変更など、管理者にできる操作を勝手に行われるおそれがあります。
WordPress 本体にはエスケープ用の関数がそろっていますが、テーマやプラグインのコードがそれを使っていなければ防げません。Patchstack の WordPress の脆弱性に関する報告でも、新しく見つかる脆弱性の多くはプラグインにあり、種類では XSS が最も多いとされています。この記事では、自作のテーマ・プラグインや functions.php のカスタマイズで XSS を作り込まないための書き方を説明します。
XSS の主な種類
| 種類 | 起きかた | WordPress での例 |
|---|---|---|
| 反射型 | URL のパラメーターやフォームの値が、そのままページに出力される | echo $_GET['q']; で検索語を表示している |
| 格納型 | データベースに保存された値が、表示のたびに出力される | カスタムフィールドやプラグインの設定値をエスケープせずに表示している |
| DOM ベース | JavaScript がページ内の値を innerHTML などで HTML として挿入する |
URL の # 以降の値を innerHTML に入れている |
どの種類も、「信頼できない値を、その場所に合った方法で無害化せずに出力した」ことが原因です。
基本の考え方: 入力は無害化、出力はエスケープ
WordPress の開発者向けドキュメントでは、次の2つを分けて考えます。
- 入力の無害化(サニタイズ): 受け取った値を、保存や処理の前に想定した形に整える(数値なら数値、1行のテキストなら改行やタグを除く)。
- 出力のエスケープ: HTML に出す直前に、出力する場所(本文、属性、URL、JavaScript)に合った関数を通す。公式ドキュメントでは「できるだけ遅く、出力するその場でエスケープする」ことが勧められています。
データベースに保存した値も、別のプラグインや過去の不具合で書き換えられている可能性があるため、出力のときに改めてエスケープします。
手順1: 出力する場所に合ったエスケープ関数を使う
| 出力する場所 | 使う関数 |
|---|---|
| HTML の本文(タグの中の文字) | esc_html() |
HTML の属性値(value、class、title など) |
esc_attr() |
href・src などの URL |
esc_url() |
<textarea> の中身 |
esc_textarea() |
| HTML を含めてよい値(投稿本文と同じタグを許す) | wp_kses_post() |
| 許すタグを自分で決める | wp_kses() |
| JavaScript に値を渡す | wp_json_encode()、インラインの JavaScript では esc_js() |
翻訳用の関数と組み合わせた esc_html__()・esc_html_e()・esc_attr__()・esc_attr_e() もあります。
<?php
// HTML の本文
// 悪い例: echo $_GET['q'];
$q = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : '';
echo '<p>検索語: ' . esc_html( $q ) . '</p>';
// HTML の属性値
// 悪い例: echo '<input type="text" name="q" value="' . $q . '">';
echo '<input type="text" name="q" value="' . esc_attr( $q ) . '">';
// URL(javascript: など許可されていないプロトコルは取り除かれる)
$url = get_post_meta( get_the_ID(), 'related_url', true );
echo '<a href="' . esc_url( $url ) . '">関連リンク</a>';
// 翻訳文字列の出力
esc_html_e( 'Read more', 'my-theme' );
esc_html() は < や > を < > に置き換えるため、HTML のタグを含む値に使うとタグがそのまま文字として表示されます。タグを生かしたい値には wp_kses_post() か wp_kses() を使います(手順2)。
JavaScript に値を渡す場合
PHP の値を JavaScript に渡すときは、文字列をつなげて <script> を組み立てるのではなく、wp_add_inline_script() と wp_json_encode() を使います。wp_json_encode() は値を JSON として正しい形に変換し、既定では / もエスケープするため、値の中の </script> で <script> が途中で閉じられることもありません。
<?php
add_action( 'wp_enqueue_scripts', function () {
wp_enqueue_script( 'my-script', get_theme_file_uri( 'js/my-script.js' ), array(), '1.0.0', array( 'in_footer' => true ) );
$data = array(
'userName' => wp_get_current_user()->display_name,
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
);
// 悪い例: echo '<script>var name = "' . $name . '";</script>';
wp_add_inline_script( 'my-script', 'const myData = ' . wp_json_encode( $data ) . ';', 'before' );
} );
HTML の属性に JSON を入れる場合(data- 属性など)は、esc_attr( wp_json_encode( $data ) ) のように属性用のエスケープも通します。JavaScript の側では、受け取った値を innerHTML で挿入せず、textContent を使います。
手順2: HTML を許す値は wp_kses で許可するタグを決める
ユーザーが入力した HTML を一部だけ許したいときは、wp_kses() に「許すタグと属性」の一覧を渡します。一覧に無いタグや属性、javascript: などの危険な URL は取り除かれます。
<?php
$allowed_html = array(
'a' => array(
'href' => true,
'title' => true,
'target' => array( 'values' => array( '_blank' ) ), // target は _blank だけ許す
'rel' => true,
),
'br' => array(),
'em' => array(),
'strong' => array(),
'p' => array( 'class' => true ),
'ul' => array(),
'ol' => array(),
'li' => array(),
'code' => array(),
);
$bio = get_user_meta( $user_id, 'custom_bio', true );
echo wp_kses( $bio, $allowed_html );
// 投稿本文と同じタグを許す場合
echo wp_kses_post( $custom_html );
// タグをすべて取り除いて文字だけにする場合
echo esc_html( wp_strip_all_tags( $bio ) );
WordPress 本体がすでに行っている無害化
WordPress は、投稿やコメントを保存するときに、ユーザーの権限に応じて wp_kses による無害化を自動で行っています。
unfiltered_htmlの権限を持たないユーザー(投稿者・寄稿者・購読者など)が保存する投稿の本文・抜粋は、投稿で許可されたタグ以外が取り除かれます。コメントも、この権限が無いユーザーの投稿はコメント用の許可タグに絞られます。unfiltered_htmlの権限は、単一サイトでは管理者と編集者、マルチサイトではネットワーク管理者だけが持ちます。この権限があるとスクリプトを含む HTML も保存できるため、信頼できない人を編集者にしないようにします。- 管理者や編集者にもこの権限を持たせたくない場合は、
wp-config.phpにdefine( 'DISALLOW_UNFILTERED_HTML', true );を書きます。
そのため、content_save_pre フィルターなどで同じ無害化を自分で重ねる必要はありません。自作のフォームやカスタムフィールド、プラグインの設定値のように、WordPress 本体の仕組みを通らない値を、自分で無害化・エスケープします。
手順3: 入力を受け取るときに無害化する
フォームの値などを受け取るときは、wp_unslash() で WordPress が付けたバックスラッシュを外してから、値の種類に合った関数で無害化します。
<?php
// 1行のテキスト(タグ・改行を取り除く)
$name = isset( $_POST['name'] ) ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : '';
// 複数行のテキスト
$message = isset( $_POST['message'] ) ? sanitize_textarea_field( wp_unslash( $_POST['message'] ) ) : '';
// メールアドレス
$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
// 0 以上の整数(ID など)
$post_id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0;
// 保存する URL
$site = isset( $_POST['site'] ) ? esc_url_raw( wp_unslash( $_POST['site'] ) ) : '';
無害化をしても、出力するときのエスケープ(手順1)は省きません。
nonce は CSRF 対策
フォームに wp_nonce_field() を入れ、受け取る側で wp_verify_nonce() や check_admin_referer() で確かめる仕組みは、ほかのサイトから勝手に送信させる攻撃(CSRF)を防ぐためのものです。XSS を防ぐものではありませんが、XSS 対策と同じく、管理画面やフォームの処理には欠かせません。
手順4: 自作のコードを PHPCS で点検する
エスケープ漏れを目で探すのは大変です。PHP_CodeSniffer(PHPCS)と WordPress Coding Standards(WPCS)を使うと、エスケープされていない出力や、無害化されていない入力を一覧にできます。Composer が使える開発環境で、テーマやプラグインのフォルダーに対して実行します。
# 作業用のフォルダーで WPCS を入れる
composer config allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
composer require --dev wp-coding-standards/wpcs:"^3.0"
# セキュリティに関する項目だけを点検する
vendor/bin/phpcs --standard=WordPress \
--sniffs=WordPress.Security.EscapeOutput,WordPress.Security.ValidatedSanitizedInput,WordPress.Security.NonceVerification \
wp-content/themes/my-theme/
echo $_GET['q']; のような行には、「出力がエスケープされていない」「入力が無害化されていない」「wp_unslash() を通していない」といった指摘が出ます。指摘された行を、手順1〜3 の書き方に直します。
手順5: ヘッダーで被害を小さくする(CSP)
コードの修正が基本ですが、Content-Security-Policy(CSP)ヘッダーで、ブラウザが実行してよいスクリプトの読み込み元を絞ると、XSS があったときの被害を小さくできます。
<?php
add_action( 'send_headers', function () {
// まずは Report-Only で、違反をブラウザのコンソールで確認するだけにする
header( "Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'" );
// MIME タイプの推測を止める
header( 'X-Content-Type-Options: nosniff' );
} );
- WordPress 本体やプラグインは、インラインの
<script>を多く出力します。script-src 'self'だけを強制すると、管理画面やブロックの表示が動かなくなることがあるため、まずContent-Security-Policy-Report-Onlyで違反を確かめてから、許可する範囲を決めます。 script-srcに'unsafe-inline'を入れるとインラインのスクリプトがすべて許可され、XSS への効果は小さくなります。インラインのスクリプトを許したい場合は、nonce を使う方法があります。WordPress 5.7 以降は、wp_script_attributesとwp_inline_script_attributesのフィルターで<script>にnonce属性を付けられます。send_headersで付けたヘッダーは、WordPress が出力するページにだけ付きます。画像や CSS などの静的ファイルにも付けたい場合は、Web サーバーの設定で付けます。
X-XSS-Protection ヘッダーは使いません。 これは古いブラウザに組み込まれていた XSS フィルターを操作するためのヘッダーです。Chrome や Edge はこのフィルターを削除しており、Firefox はもともと対応していません。MDN は CSP を使うことを勧めており、OWASP は付ける場合でも 0(無効)にすることを勧めています。
設定したヘッダーは次のように確かめます。
curl -sI https://example.com/ | grep -iE "content-security-policy|x-content-type-options"
修正できたかを確かめる
開発環境やテスト環境で、入力欄や URL のパラメーターに <script>alert(1)</script> や "><img src=x onerror=alert(1)> のような文字列を入れ、ページのソースで <script> のように文字として出力されていること、ダイアログが表示されないことを確認します。本番のサイトや、他人のサイトでは試しません。
まとめ
- 値は出力する直前に、場所に合った関数(
esc_html()・esc_attr()・esc_url()・wp_kses()など)でエスケープする - JavaScript に値を渡すときは
wp_add_inline_script()とwp_json_encode()を使う - 受け取った値は
wp_unslash()のあと、種類に合った関数で無害化する。投稿・コメントの保存時の無害化は WordPress 本体が行う - PHPCS と WordPress Coding Standards でエスケープ漏れを点検する
- CSP は Report-Only から始め、
X-XSS-Protectionは使わない