Webアプリケーションの脆弱性報告で常に上位を占めるXSS(クロスサイトスクリプティング)は、ユーザーのブラウザ上で意図しないスクリプトが実行される攻撃手法です。IPA(情報処理推進機構)の脆弱性届出データでも、Webサイトの脆弱性種別でXSSは最多の報告件数を記録し続けています(出典: IPA)。

対策の基本はシンプルですが、出力コンテキストごとに正しい手法を選ばないと防御が機能しません。HTML本文・属性値・JavaScript・URL——それぞれに適したエスケープが存在します。

XSSが発生するメカニズム

XSSの本質は「信頼されたWebページ内で、攻撃者が用意したコードがユーザーのブラウザで実行される」ことにあります。Webアプリケーションがユーザー入力をHTMLへ出力する際、特殊文字(<>"'&など)をそのまま書き出すと、ブラウザはこれをHTMLタグやJavaScriptコードとして解釈します。

たとえば検索フォームの入力値をそのまま画面に表示する実装があった場合、入力欄に<script>alert(document.cookie)</script>と入力すると、画面上でスクリプトが実行されてしまいます。

攻撃者はこの仕組みを利用し、Cookie(セッションID)の窃取・ページ改ざん・フィッシングサイトへの誘導・マルウェア配布などを実行します。

3種類のXSS攻撃タイプと影響範囲

XSSは攻撃コードの保存場所と実行タイミングに応じて3タイプに分類されます。

反射型XSS(Reflected XSS)

攻撃スクリプトがURLのクエリパラメータやフォームデータに含まれ、サーバーがそのまま応答HTMLに出力するパターンです。攻撃者は細工したURLをメールやSNSで拡散し、クリックしたユーザーのブラウザでスクリプトが実行されます。

検索結果ページやエラーメッセージ表示でユーザー入力を無加工で返す実装に多く見られます。

格納型XSS(Stored XSS)

攻撃コードがデータベースやファイルに保存され、ページを閲覧した全ユーザーに影響するタイプです。掲示板・コメント欄・レビュー機能・プロフィール編集画面など、ユーザー投稿をそのまま表示する箇所が標的になります。

反射型と異なり、被害者がURLをクリックする必要がなく、通常の閲覧行為だけで攻撃が成立するため、被害が広範囲に拡大しやすい特徴があります。

DOM Based XSS

サーバー側を経由せず、フロントエンドのJavaScriptがDOM操作を通じてXSSを引き起こすタイプです。document.write()innerHTMLlocation.hashの値をそのままDOMに挿入する実装が典型例です。

サーバー側のログに攻撃の痕跡が残りにくいため、検知が難しいという課題があります。SPAやリッチなフロントエンドアプリケーションの普及に伴い、発生頻度が上昇しています。

分類攻撃コードの保存先影響範囲検知難易度
反射型URL/リクエストパラメータリンクをクリックした個人サーバーログで比較的容易
格納型サーバーのDB/ファイルページを閲覧した全ユーザー保存データの監査が必要
DOM Basedクライアント側のみ細工URLにアクセスした個人サーバーログに残らず困難

根本的なXSS対策:出力コンテキスト別のエスケープ

XSS対策で最も重要なのは「出力時のエスケープ処理」です。IPAの「安全なウェブサイトの作り方」でも根本的解決として最初に挙げられています(出典: IPA)。

ポイントは、出力先のコンテキストに応じて異なるエスケープ方式を使い分けることです。

HTML本文へのエスケープ

HTML要素の内部にユーザー入力を出力する場合、以下の5文字をHTMLエンティティに変換します。

変換前変換後理由
<&lt;タグ開始と解釈される
>&gt;タグ終了と解釈される
&&amp;エンティティ開始と解釈される
"&quot;属性値の区切りと解釈される
'&#x27;属性値の区切りと解釈される

PHPでの実装例:

// ENT_QUOTES で シングルクォートも変換対象にする
// 第3引数で文字コードを明示するのが重要
echo htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8');

PHPではバージョン8.1以降、htmlspecialchars()のデフォルトフラグにENT_QUOTES | ENT_SUBSTITUTEが含まれるため、第2引数を省略しても安全に動作します。それ以前のバージョンではENT_QUOTESと文字コードの明示が必須です。

JavaScriptでの実装例:

function escapeHtml(str) {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;');
}

// DOM APIを使う場合は textContent が安全
element.textContent = userInput; // HTMLとして解釈されない

textContentプロパティは文字列をテキストノードとして挿入するため、HTMLタグが解釈されません。innerHTMLの代わりにtextContentを使うだけで、多くのDOM Based XSSを防止できます。

HTML属性値のエスケープ

属性値は必ずダブルクォートで囲みます。囲んでいない場合、スペースやイベントハンドラ属性を注入される危険があります。

<!-- 危険: 属性値が囲まれていない -->
<input value=ユーザー入力>

<!-- 安全: ダブルクォートで囲み、値をエスケープ -->
<input value="<?= htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8') ?>">

URL出力時の対策

href属性やsrc属性にユーザー入力を使う場合、javascript:スキームによるスクリプト実行を防ぐ必要があります。

// ホワイトリスト方式: http:// または https:// のみ許可
$url = filter_var($userInput, FILTER_VALIDATE_URL);
if ($url && preg_match('/^https?:\/\//', $url)) {
    echo '<a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '">リンク</a>';
}
// JavaScript版: URLスキームの検証
function isSafeUrl(url) {
  try {
    const parsed = new URL(url);
    return ['http:', 'https:'].includes(parsed.protocol);
  } catch {
    return false;
  }
}

JavaScript文脈へのデータ埋め込み

HTMLテンプレート内の<script>要素にサーバー側の値を埋め込む場合、HTMLエスケープではなくJSONエンコードを使います。

<script>
  // json_encode() は JavaScript で安全に解釈できる文字列を生成する
  const userData = <?= json_encode($userData, JSON_HEX_TAG | JSON_HEX_AMP) ?>;
</script>

JSON_HEX_TAGフラグにより<>\u003C\u003Eにエスケープされ、</script>によるスクリプト要素の早期終了を防ぎます。

Content Security Policy(CSP)による多層防御

エスケープ処理は「コードレベルの対策」ですが、CSPは「ブラウザレベルの防御層」を追加します。CSPはHTTPレスポンスヘッダでブラウザに許可するリソースの読み込み元を指示し、不正なインラインスクリプトや外部スクリプトの実行をブロックします。

CSPヘッダの設定例

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src *; object-src 'none'
ディレクティブ役割
default-src未指定のリソース種別に対するフォールバック
script-srcJavaScript読み込み元の制限
style-srcCSS読み込み元の制限
img-src画像の読み込み元の制限
object-srcプラグイン(Flash等)の読み込み制限
connect-srcfetch/XHR/WebSocketの接続先制限
frame-ancestorsiframe埋め込み元の制限(クリックジャッキング対策にも有効)

段階的な導入手順

CSPを既存サイトに一度に適用すると正常な機能が動作しなくなる恐れがあります。段階的に導入するのが安全です。

  1. レポートモードで検証: Content-Security-Policy-Report-Onlyヘッダを使い、違反をログに記録するだけで実際のブロックは行わない
  2. 違反レポートの分析: report-uriまたはreport-toディレクティブでレポートを収集し、ブロックされるリソースを特定
  3. ポリシーの調整: 正規のリソースをホワイトリストに追加
  4. 強制モードに切り替え: Content-Security-Policyヘッダに変更して本番適用

nonce属性によるインラインスクリプトの許可

インラインスクリプトを完全に禁止できない場合、リクエストごとにランダムな値(nonce)を生成し、正規のスクリプトにのみ付与します。

<!-- サーバー側で生成したnonceを設定 -->
Content-Security-Policy: script-src 'nonce-a1b2c3d4e5'

<!-- nonce属性が一致するスクリプトのみ実行される -->
<script nonce="a1b2c3d4e5">
  // このスクリプトは実行される
</script>

<!-- 攻撃者が注入したスクリプトにはnonceがないため実行されない -->
<script>alert('XSS')</script>

フレームワーク別のXSS防御機能

モダンなフロントエンドフレームワークはデフォルトでXSS対策機構を備えていますが、特定のAPIを使うとその保護を回避してしまいます。各フレームワークの「安全なAPI」と「危険なAPI」を把握することが重要です。

React

Reactは JSXの{}内に渡された値を自動でエスケープし、HTMLタグとして解釈しません。

// 安全: 自動エスケープされる
function SafeComponent({ userInput }) {
  return <div>{userInput}</div>;
}

// 危険: dangerouslySetInnerHTML はエスケープをバイパスする
function UnsafeComponent({ userHtml }) {
  return <div dangerouslySetInnerHTML={{ __html: userHtml }} />;
}

dangerouslySetInnerHTMLを使わざるを得ない場合は、DOMPurifyなどのサニタイズライブラリで事前に無害化します。

import DOMPurify from 'dompurify';

function SafeHtmlComponent({ userHtml }) {
  const clean = DOMPurify.sanitize(userHtml);
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

Vue.js

Vueのテンプレート構文{{ }}は自動エスケープを行います。

<template>
  <!-- 安全: 自動エスケープ -->
  <p>{{ userInput }}</p>

  <!-- 危険: v-html はHTMLをそのまま出力 -->
  <p v-html="userInput"></p>
</template>

v-htmlを使う場合も、DOMPurifyによるサニタイズを挟みます。

Angular

Angularはテンプレートバインディング{{ }}で自動サニタイズを行い、[innerHTML]バインディングでもAngular独自のサニタイザが動作します。

// Angularのサニタイザを明示的に使う場合
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';

@Component({ ... })
export class MyComponent {
  safeHtml: SafeHtml;

  constructor(private sanitizer: DomSanitizer) {
    // bypassSecurityTrustHtml() は信頼できるソースにのみ使用
    this.safeHtml = this.sanitizer.bypassSecurityTrustHtml(trustedContent);
  }
}

bypassSecurityTrustHtml()は名前のとおりセキュリティチェックを迂回するため、管理者入力などの信頼できるソースにのみ限定して使用します。

フレームワーク比較表

フレームワーク自動エスケープ危険なAPI推奨サニタイズ方法
ReactJSX {}dangerouslySetInnerHTMLDOMPurify
Vue{{ }}v-htmlDOMPurify
Angular{{ }}, [innerHTML]bypassSecurityTrustHtml()Angular DomSanitizer
Svelte{expression}{@html ...}DOMPurify

HTTPヘッダによる保険的対策

エスケープ処理とCSPに加えて、HTTPレスポンスヘッダでさらなる防御層を追加できます。

CookieのHttpOnly属性

Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Strict

HttpOnlyを設定するとdocument.cookieからのアクセスが禁止され、XSSによるセッションIDの窃取を防止します。Secure属性でHTTPS通信のみにCookie送信を限定し、SameSite=StrictでクロスサイトのリクエストにCookieを付与しないよう制限します。

Content-Type の charset 指定

Content-Type: text/html; charset=UTF-8

文字コードを省略すると、ブラウザが文字コードを誤判定し、UTF-7などのエンコーディングを悪用したXSSが成立する可能性があります。必ずcharsetを明示します。

X-Content-Type-Options

X-Content-Type-Options: nosniff

ブラウザがContent-Typeを無視してコンテンツを推測解釈(MIMEスニッフィング)する動作を防ぎます。JSON応答がHTMLとして解釈されるケースなどを防止します。

推奨ヘッダ一覧

Content-Security-Policy: default-src 'self'; script-src 'self'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()

Trusted Types API——ブラウザネイティブのDOM XSS防止

Trusted Types APIはW3Cで策定が進められているブラウザ標準のDOM XSS防止機能です。Chrome 83以降とEdgeで利用可能で、innerHTMLdocument.write()などの危険なDOM操作に対して、型安全な値のみを受け入れるよう強制します(出典: MDN Web Docs)。

基本的な使い方

// CSPヘッダで Trusted Types を有効化
// Content-Security-Policy: require-trusted-types-for 'script'

// ポリシーを作成
const policy = trustedTypes.createPolicy('escapePolicy', {
  createHTML: (input) => DOMPurify.sanitize(input),
});

// Trusted Types を使わない代入はブラウザがブロック
// element.innerHTML = userInput; // TypeError が発生

// ポリシー経由で安全な値を作成して代入
element.innerHTML = policy.createHTML(userInput);

Trusted Typesを有効にすると、innerHTMLへの生の文字列代入がブラウザレベルで禁止されます。コードレビューでの見落としがあっても、ブラウザが最後の防衛線として機能する仕組みです。

入力値バリデーションの位置づけ

入力値のバリデーションはXSSの「根本的対策」ではなく「補助的対策」です。メールアドレス・電話番号・数値など、入力形式が限定されるフィールドではバリデーションが有効ですが、自由記述のテキスト入力には適用しにくい場合があります。

バリデーションだけでXSSを防ごうとする設計は脆弱です。「出力時のエスケープ処理」が主軸であり、バリデーションは副次的な防御として位置づけてください。

// 数値フィールドの入力値検証
$age = filter_input(INPUT_POST, 'age', FILTER_VALIDATE_INT);
if ($age === false || $age === null) {
    // 不正な入力: 処理を中断
}

// メールアドレスの入力値検証
$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);

WAF(Web Application Firewall)の活用

WAFはHTTPリクエスト・レスポンスを検査し、攻撃パターンに合致する通信を遮断するネットワークレベルの防御策です。

WAFが有効な場面は、既知の攻撃パターンに基づくシグネチャマッチングによる防御や、レガシーアプリケーションのように即座にコード修正ができない環境での暫定対策です。

ただしWAFには限界があります。新種の攻撃パターンやエンコーディングの工夫による回避(WAFバイパス)が存在するため、WAFのみに依存する設計は推奨されません。「コードレベルの対策 + CSP + WAF」の多層防御が理想です。

XSS脆弱性の検出手法

対策の実装後は、その有効性を検証するテストが欠かせません。

手動テスト

基本的なXSSペイロードを入力フィールドやURLパラメータに投入し、スクリプトが実行されるか確認します。

代表的なテスト文字列:

<script>alert(1)</script>
"><img src=x onerror=alert(1)>
javascript:alert(1)
'onmouseover='alert(1)

自動スキャンツール

ツール種別特徴
OWASP ZAPオープンソース無料で高機能、CI/CD統合が可能
Burp Suite商用/Community版ありプロキシ型で詳細な手動テストも可能
Nucleiオープンソーステンプレートベースで拡張性が高い

OWASPが推奨するテスト観点

OWASP XSS Prevention Cheat Sheetでは、以下のコンテキストごとに適切なOutput Encoding(出力エスケープ)を行うことが推奨されています(出典: OWASP)。

  • HTMLの要素内容(<div>ここ</div>
  • HTML属性値(<input value="ここ">
  • JavaScript文字列内(var x = 'ここ'
  • URL(<a href="ここ">
  • CSSの値内(background: url(ここ)

各コンテキストでそれぞれ異なるエスケープが必要であり、1種類のエスケープ関数を全箇所に使い回してもXSSを防げない点に注意が必要です。

XSS対策チェックリスト

実装時に確認すべきポイントを一覧にまとめます。

  • HTML出力箇所で特殊文字(<>&"')をエスケープしている
  • HTML属性値をダブルクォートで囲んでいる
  • href/src属性にユーザー入力を使う場合、http://またはhttps://のみ許可している
  • <script>要素の内容を外部入力から動的に生成していない
  • innerHTMLではなくtextContentを使っている(またはinnerHTML使用時にサニタイズしている)
  • CSPヘッダを設定している
  • CookieにHttpOnly・Secure・SameSite属性を設定している
  • Content-Typeヘッダで文字コード(charset=UTF-8)を指定している
  • フレームワークの自動エスケープ機能を活用し、危険なAPI(dangerouslySetInnerHTML等)を最小限にしている
  • 定期的に脆弱性スキャンを実施している

まとめ

XSS対策は「出力時エスケープ」「CSPの導入」「HTTPヘッダの設定」を組み合わせた多層防御が基本です。フレームワークが提供する自動エスケープ機能を最大限活用しつつ、innerHTMLdangerouslySetInnerHTMLなどの危険なAPIの使用箇所を限定し、使用時にはDOMPurifyなどのサニタイズライブラリを適用します。

さらに、Trusted Types APIやCSPのnonceベース設定など、ブラウザネイティブのセキュリティ機能を積極的に取り入れることで、コードレビューで見落としがあってもブラウザが最終防衛線として機能する堅牢な設計が実現できます。

セキュリティ対策に「完了」はありません。新たな攻撃手法が継続的に発見されるため、OWASP XSS Prevention Cheat Sheet(出典: OWASP)やIPA「安全なウェブサイトの作り方」(出典: IPA)を定期的に参照し、対策をアップデートし続けることが重要です。