0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

XSSを防ぐPHP・WordPressの出力エスケープ実装(esc_html/esc_attr・コピペOK)

0
Posted at

自作の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を属性に入れるとき

hrefsrc にユーザー由来の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 = ... に入れると振り出しに戻ります。textContentsetAttribute を使う、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="&quot;&gt;&lt;x" のように実体参照へ変換されている

ブラウザのDevToolsで「要素」タブを開き、埋め込んだ値がテキストとして表示され、</>/" が実体参照(&lt; &gt; &quot;)になっていれば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・HttpOnly Cookie・自動エスケープで多層に守る(エスケープの代替にはならない)。

関連記事

本記事のような出力エスケープの抜けは、Webサイトを9つの守りでまるごと守るサイトドックでまとめて対策できます → https://sitedock.jp

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?