自作のPHPやWordPressテーマ・プラグインで、ユーザーが入力した値やDBの値を画面に出しているすべての開発者・Web担当者向けです。XSS(クロスサイトスクリプティング=攻撃者の用意した文字列がそのままHTMLやスクリプトとして実行されてしまう脆弱性)を作り込まないための「出力エスケープ」を、文脈ごとにコピペできる形でまとめました。SQLインジェクション対策と並ぶ、自作コードの必須作法です。
結論:XSS対策は「出力する瞬間」に「文脈ごと」
まず要点だけ。XSS対策で一番やりがちな誤解が「入力を受け取った時点でサニタイズすればいい」というものです。実際の原則は逆で、値を画面に出力する瞬間に、その値が置かれる場所(文脈)に合わせてエスケープするのが正解です。
理由は、同じ文字列でも「置かれる場所」によって危険な文字が変わるからです。
| 出力する場所(文脈) | 危険になる文字・要素 | 使うエスケープ |
|---|---|---|
| HTMLの本文テキスト |
< > &
|
htmlspecialchars() / esc_html()
|
HTML属性値(value="...") |
< > & " '
|
htmlspecialchars() / esc_attr()
|
URL(href="..." src="...") |
javascript: 等のスキーム |
スキーム検証 + esc_url()
|
<script> 内・インラインJS |
< " ' 改行など |
json_encode() / wp_json_encode()
|
入力時に一律サニタイズしてしまうと、正しいデータ(例:5 < 10 という本文)まで壊れ、しかも別の文脈に出したときには結局守れません。入力はバリデーション(形式が正しいかの検証)、出力はエスケープ、と役割を分けて考えるのが鉄則です。
XSSの3タイプ(仕組みだけ簡単に)
再発防止の観点で、種類だけ押さえておきます(攻撃の再現手順には踏み込みません)。
- 反射型:URLパラメータや検索キーワードが、そのまま結果ページに出力される。
- 格納型:投稿・コメント・プロフィールなどDBに保存された値が、後から表示される。影響が広い。
- DOMベース:サーバではなくブラウザ側のJavaScriptが、URLや入力値をそのままDOMに書き込む。
いずれも共通の原因は「外部から来た文字列を、エスケープせずにHTML/スクリプトとして出力している」ことです。対策も共通で、出力時の文脈別エスケープに集約されます。
1. 素のPHP:文脈別エスケープの雛形
HTML本文・属性値
PHPの基本は htmlspecialchars() です。引数を省略しないのが重要で、次の形をそのまま使ってください。
<?php
// 出力エスケープの共通ヘルパー(HTMLテキスト・属性値の両方に使える)
function h(string $s): string
{
// ENT_QUOTES : シングル・ダブル両方のクォートを変換(属性値で必須)
// ENT_SUBSTITUTE: 不正なバイト列を壊さず置換
// 'UTF-8' : 文字コードを明示(未指定は事故のもと)
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
}
?>
<!-- HTML本文 -->
<p>ようこそ、<?= h($username) ?> さん</p>
<!-- 属性値は必ずダブルクォートで囲む -->
<input type="text" name="q" value="<?= h($keyword) ?>">
ENT_QUOTES を付け忘れると ' が変換されず、シングルクォートで囲った属性から抜け出される穴が残ります。属性値は必ず "..." で囲むこともセットです(裸の属性値はスペースだけで区切りを抜けられます)。
URLを属性に入れるとき
href や src にユーザー由来のURLを入れる場合、HTMLエスケープだけでは javascript: スキームを防げません。スキームを許可リストで検証してから出力します。
<?php
function safe_url(string $url): string
{
$url = trim($url);
// http / https / mailto と、自サイト内の相対パス(/で始まる)だけ許可
$ok = preg_match('#\A(https?:|mailto:)#i', $url)
|| (str_starts_with($url, '/') && !str_starts_with($url, '//'));
if (!$ok) {
return '#'; // javascript: data: など危険スキームは弾く
}
return htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
}
?>
<a href="<?= safe_url($link) ?>">プロフィール</a>
//example.com はプロトコル相対URLで外部サイトに飛ぶため、/ 始まりでも // は除外します(オープンリダイレクトと同じ注意点です)。
<script> の中に値を渡すとき
JavaScriptのコードに直接文字列を埋め込むのは避け、json_encode() でまるごとデータとして渡すのが安全です。
<script>
// フラグ JSON_HEX_TAG 等で </script> や属性抜けも無害化する
const user = <?= json_encode(
$userData,
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE
) ?>;
document.getElementById('name').textContent = user.name;
</script>
ブラウザ側でも、受け取った値を element.innerHTML = ... に入れると振り出しに戻ります。textContent や setAttribute を使う、DOMを組み立てるときはエスケープ済みの経路を通す、を徹底してください。
文字コードは必ず宣言する
エスケープを効かせる前提として、レスポンスの文字コードを明示します。宣言が無いと、ブラウザの文字コード自動判定を突かれてエスケープを回避される場合があります。
<?php header('Content-Type: text/html; charset=UTF-8'); ?>
<!DOCTYPE html>
<meta charset="UTF-8">
2. WordPress:文脈別のエスケープ関数
WordPressは文脈ごとに専用のエスケープ関数を用意しています。**出力の直前(遅延エスケープ)**で必ず通します。
<?php
// HTML本文テキスト
echo esc_html( $comment_text );
// HTML属性値
printf( '<input value="%s">', esc_attr( $keyword ) );
// URL(href / src)
printf( '<a href="%s">link</a>', esc_url( $link ) );
// textarea の中身
echo esc_textarea( $body );
// 翻訳文字列はエスケープ版を使う
esc_html_e( '設定を保存しました', 'your-textdomain' );
echo esc_html__( 'こんにちは', 'your-textdomain' );
?>
-
esc_url()はesc_url_raw()と違い、表示用に許可スキーム外(javascript:など)を除去します。DB保存にはesc_url_raw()を使い分けます。 - 属性に翻訳文字列を入れるなら
esc_attr_e()/esc_attr__()を使います。 - エスケープは「入れる時」ではなく「出す時」。DBには生に近い形で保存し、出力の瞬間にその文脈の関数を通す、という順序を守ると事故が減ります。
HTMLタグを一部だけ許可したいとき
「投稿本文にリンクや強調タグだけは許可したい」ケースでは、全消しではなく wp_kses() で許可タグの許可リストを作ります。
<?php
$allowed = array(
'a' => array( 'href' => array(), 'title' => array() ),
'strong' => array(),
'em' => array(),
'br' => array(),
);
echo wp_kses( $user_html, $allowed );
// 投稿本文相当の標準セットでよければ
echo wp_kses_post( $user_html );
?>
自作のHTMLサニタイズを正規表現で書くのは、抜け穴だらけになりがちです。素のPHPでリッチテキストを許可する場合も、HTML Purifier のような実績ある専用ライブラリに任せるのが安全です。
3. 多層防御:エスケープを補強する
出力エスケープが主対策ですが、書き漏らしに備えて次を重ねます(エスケープの代わりにはなりません)。
-
CSP(Content-Security-Policy):
script-srcを絞り、インラインスクリプトや外部スクリプトの実行元を制限する。エスケープを1か所書き忘れても、スクリプト実行自体を止められる可能性が上がります。 -
Cookieの
HttpOnly属性:万一XSSが成立しても、セッションCookieをJavaScriptから読み取られにくくする。 -
テンプレートエンジンの自動エスケープ:Twig・Blade などは既定で出力を自動エスケープします。生出力(
{!! !!}や|raw)を使うのは、意図してエスケープ済みと分かっている箇所だけに限定します。
確認方法
エスケープが効いているかは、記号を含む値を実際に入れて出力されたHTMLソースを見るのが確実です。
# 属性に記号入りの値を渡し、レスポンス内でエスケープされているか確認
curl -s 'https://example.com/search?q=%22%3E%3Cx' | grep -o 'value="[^"]*"'
# 期待: value=""><x" のように実体参照へ変換されている
ブラウザのDevToolsで「要素」タブを開き、埋め込んだ値がテキストとして表示され、</>/" が実体参照(< > ")になっていればOKです。タグとして解釈されていたら、その出力箇所のエスケープが漏れています。
まとめ
- XSS対策は入力時ではなく出力時。値を出す場所の文脈に合わせてエスケープする。
- 素のPHP:HTML/属性は
htmlspecialchars(..., ENT_QUOTES, 'UTF-8')、URLはスキーム検証、<script>内はjson_encode()。属性は必ずクォートで囲む。 - WordPress:
esc_html/esc_attr/esc_url/esc_textareaを出力直前に。タグ許可はwp_ksesの許可リストで。 - CSP・
HttpOnlyCookie・自動エスケープで多層に守る(エスケープの代替にはならない)。
関連記事
- SQLインジェクションを防ぐPHP・WordPressの書き方(PDO/wpdb::prepare)
- CSPで既存サイトを壊さず段階導入する手順(Report-Only→enforce)
- Cookieのセキュリティ属性 SameSite / Secure / HttpOnly の正しい付け方
本記事のような出力エスケープの抜けは、Webサイトを9つの守りでまるごと守るサイトドックでまとめて対策できます → https://sitedock.jp