XSS対策でいちばん多い誤解は、「入力された危ない記号を消せば終わる」という考え方です。同じ文字列でも、HTMLの本文に出すのか、属性の中か、スクリプトの中かで危険の形が変わります。無害化は入力時ではなく、出す場所に合わせて出力時に行います。そのうえで、漏れたときに実行させない二重目としてCSPとTrusted Typesを置きます。

連載「事故から逆算するWebセキュリティ」の第20回は、手元のChromiumで反射型XSSを実際に動かし、エスケープ・CSP・Trusted Typesがそれぞれ何を止めるかを確かめます。

ENCODE ON OUTPUTXSS対策の置き場所

入力で消すのではなく、出す場所に合わせて変換する

入力で消す

受け取った時点で記号を削る・置き換える

  • どこに出すかを入力時には知らない
  • DOM型XSSはサーバーを通らない
  • 正しいデータ(記号を含む名前など)まで壊す

出す場所で変換する

HTML・属性・JS・URL・CSSごとに

  • 同じ文字列を場所ごとに正しく無害化
  • 実測: エスケープした出力では実行されず
  • CSPとTrusted Typesを二重目に置く
実測: Chromium 141.0.7390.37(Playwright 1.56.1)・当サイト(2026-10-17)。考え方は OWASP XSS Prevention Cheat Sheet

30秒でわかる(2026年10月17日時点)

  • ・主役は出力時の変換:HTML本文・属性・JS文字列・URL・CSSで変換が違う。テンプレートの自動エスケープを既定にする
  • ・抜け道を数える:dangerouslySetInnerHTML・v-html・safe フィルタ・innerHTML は自動エスケープの外側
  • ・実測:Chromium 141で、エスケープなしは実行、エスケープありは不実行。nonceとstrict-dynamicのCSPは注入を止めて違反レポートを送り、正規のスクリプトは動いた
  • ・CSPは二重目:'self' だけの許可リスト型は同一オリジンのJSONPで破れた。Report-Onlyで集めてから強制する

インシデント全般の読み方は サプライチェーン攻撃とは|手口・防御ツール比較・npm 12の新機構まで実践解説 に、CSPでJavaScriptを一切実行させない設計の実例は textlogの内部設計をCSPとソースで検証した記事 にまとめています。

XSS対策の前に分ける3つの型と、HttpOnlyでも防げないこと

XSSは、攻撃者が用意した文字列がページの中でコードとして実行される脆弱性です。OWASPのDOM based XSS Prevention Cheat Sheetは、「反射型・格納型」はペイロードが被害者に届く経路、「サーバー側・クライアント側」は危険な扱いが起きる場所の区別だと整理しています。

型 ペイロードの届き方 危険な扱いが起きる場所 主な対策
反射型 URLやフォームの値が、そのまま応答に返る サーバーのテンプレート 出力時のエスケープ
格納型 投稿・プロフィールなどに保存され、他人の画面に出る サーバーのテンプレート 出力時のエスケープ、リッチHTMLは消毒
DOM型 location などをブラウザのJSが読み、危険なAPIに渡す ブラウザの中(サーバーを通らないこともある) 安全なAPI(textContent)、Trusted Types

HttpOnlyのCookieでも「なりすまし」は止まらない

セッションCookieに HttpOnly を付けると、JavaScriptから値を読めなくなります。それでもXSSの影響範囲は小さくなりません。当サイトの実測(下の実測の節)で、HttpOnlyのCookieを持つページに注入したスクリプトは、document.cookie を空文字としてしか読めませんでした。ところが同じスクリプトが同じオリジンの /me を fetch すると、ブラウザがCookieを付けて送り、サーバーは「alice」のデータを返しました。

・Cookieを盗めなくても、利用者のブラウザの中から利用者として操作できる
・画面の書き換え、入力の盗み見、設定の変更、送金などの操作も同じ権限で行える
・OWASPも、XSSの結果としてアカウントのなりすましや機密データの窃取を挙げている

トークンを盗まれたあとの止め方は トークン窃取を多層で止める防御パターンの記事 で扱いました。XSSはその手前で、盗まずに使われる経路です。

なぜ「入力で消す」XSS対策では足りないのか

入力時に < や script を削る、あるいは全部をHTMLの実体参照に置き換えて保存する方式は、3つの理由で破れます。

1つ目は、入力の時点では出す場所が分からないことです。OWASPは、HTMLの本文に出すつもりだった値が、ある画面ではスクリプトの中に出ていて、HTMLのエスケープでは足りなかった、という例を挙げています。HTMLの本文なら < を変えれば足りますが、引用符の無い属性やJavaScriptの文字列の中では、< を含まない文字列でも外へ抜け出せます。

2つ目は、DOM型XSSがサーバーを通らないことです。URLの # 以降はサーバーに送られず、ブラウザのJSが読んで innerHTML に入れれば実行されます。サーバー側の入力チェックには一度も現れません。

3つ目は、正しいデータを壊すことです。数式の <、引用符を含む名前、コード片の投稿は、入力時に消すと元に戻りません。実体参照で保存すると、別の画面で二重にエスケープされた文字が出ます。OWASPは「フィルタだけでは足りない」例を XSS Filter Evasion Cheat Sheet に集めています。

入力の検証そのものは無駄ではありません。郵便番号やIDのように形式が決まっている値を、許可した形だけ通すのは有効です。ただ、それは形式の検証であって、XSSの無害化の主役ではありません。

出力時のエスケープ:出す場所ごとの変換とフレームワークの抜け道

OWASP XSS Prevention Cheat Sheet は、ブラウザがHTML・JS・URL・CSSを別々に解釈するため、変換も場所ごとに違うと書いています。間違った変換は防げないうえ、表示も壊します。

出す場所 例 変換 注意
HTMLの本文 <p>ここ</p> & < > " ' を実体参照に テンプレートの既定のエスケープがこれ
HTMLの属性値 title="ここ" 属性用のエンコーダ+値を必ず引用符で囲む onclick などイベント属性の中は、エスケープしても安全にならない
JavaScriptの文字列 スクリプト内の引用符つき文字列 置かれた場所に合ったライブラリのJSエンコーダ 式・識別子・コードは入れない。値は data-* 属性で渡すほうが安全
URLの値 ?q=ここ encodeURIComponent でパーセントエンコード、その後に属性用の変換 href や src を丸ごと受け取るなら http/https だけを許可して検証
CSS プロパティの値だけ CSSの16進エスケープ セレクタや宣言ブロックには入れない

OWASPは、スクリプトの中に直接・HTMLコメントの中・タグ名や属性名の位置を「危険な場所」と呼び、エスケープしても防げないので変数を置かないよう求めています。

フレームワークの自動エスケープと、その抜け道

React・Vue・Django などのテンプレートは、既定で値を文字列として出します。XSSが起きるのは、たいていその外側に出たときです。

抜け道 どこにあるか 確認元
dangerouslySetInnerHTML React OWASP XSS Prevention Cheat Sheet(消毒せずに使うことを問題として列挙)
v-html Vue Vue公式ドキュメント(「ユーザーが提供した内容には使わない」と警告)
safe フィルタ、自動エスケープの無効化 Django・Jinja2 Django公式ドキュメント(「これ以上のエスケープが要らない」と印を付ける)
bypassSecurityTrustAs* Angular OWASP XSS Prevention Cheat Sheet
unsafeHTML Lit OWASP XSS Prevention Cheat Sheet
innerHTML・outerHTML・insertAdjacentHTML・document.write 素のDOM操作 OWASP(textContent への置き換えを推奨)

テキストとして出すだけなら、textContent や value に置き換えれば済みます。ブログ本文やコメントのようにHTMLとして出す必要があるときは、OWASPが推奨する DOMPurify で消毒します。DOMPurify は回避手法が見つかるたびに更新されるので、版を固定したまま放置しないことも同じ文書に書かれています。

CSPでXSS対策を二重にする:nonce・strict-dynamic・Trusted Types

CSP(Content Security Policy)は、ページで実行してよいスクリプトをブラウザに伝えるレスポンスヘッダーです。OWASPは、CSPは間違えやすいので主な防御にせず、追加の層として使うよう勧めています。エスケープの漏れが1か所あっても、注入されたスクリプトを実行させないための二重目です。

STRICT CSP何が通るか

nonce の無いスクリプトは同じオリジンでも止まる

script-src 'nonce-…' 'strict-dynamic'

  • nonce正規のスクリプト 実行されたサーバーが毎回のnonceを付与
  • 派生それが読み込んだJS 実行されたstrict-dynamic で信頼を継承
  • 注入差し込まれたscript 拒否・違反レポート反射型XSSのペイロード
  • 属性onerror などの属性 拒否・違反レポートimgタグに埋めた処理
  • JSONP同一オリジンのJSONP 拒否・違反レポート'self' だけのCSPでは実行された
実測: Chromium 141.0.7390.37・ローカルの検証サーバー(2026-10-17、当サイト)。ポリシーの形は OWASP CSP Cheat Sheet の Nonce-based Strict Policy

許可リスト型が破れる理由と、strict CSP

古い書き方は、script-src 'self' cdn.example.com のように読み込んでよい場所を並べる許可リスト型です。OWASP CSP Cheat Sheet は、この方式の弱さを示したGoogleの研究を引き、今の推奨は nonce か hash を使う「Strict CSP」だとしています。許可した場所にJSONPの口や古いライブラリがあると、攻撃者はそれを呼ぶスクリプトを注入するだけで済むからです。当サイトの実測でも、script-src 'self' は、注入した <script src="/jsonp?callback=..."> をそのまま実行しました。

Strict CSP は、レスポンスごとに作る乱数(nonce)を付けたスクリプトだけを実行させます。注入された側は nonce を知らないので止まります。'strict-dynamic' を足すと、nonce 付きのスクリプトが動的に追加したスクリプトにも信頼が引き継がれます。CSP Level 3 の仕様は、このとき 'self' やホストの指定は無視され、nonce と hash だけが効くと説明しています。当サイトが実測で返したヘッダーは次のとおりです。

Content-Security-Policy: script-src 'nonce-{毎回の乱数}' 'strict-dynamic'; object-src 'none'; base-uri 'none'; report-uri /csp-report

<script nonce="{同じ乱数}">/* 正規のスクリプト。ここから追加した script も動く */</script>

nonce はテンプレートで正規のタグにだけ付けます。OWASPは、全部の script タグに機械的に nonce を差し込むミドルウェアを作らないよう注意しています。注入されたタグにも付いてしまうからです。実測では検証のため固定値を使いましたが、本番では応答ごとに作り直します。

Report-Onlyで集めてから強制する

いきなり強制すると、nonce を付け忘れた正規のスクリプトまで止まります。Content-Security-Policy-Report-Only で同じポリシーを出すと、ブラウザは止めずに違反だけを送ります。実測でも、Report-Only のページではペイロードが実行され、disposition: "report" の報告が届きました。違反が正規のものだけでなくなるまで直してから、強制のヘッダーに切り替えます。OWASPは両方のヘッダーを同時に出す運用も紹介しています。報告先の指定 report-uri は CSP Level 3 で非推奨とされ、後継は report-to です。

Trusted Types:DOM型XSSの出口をふさぐ

DOM型XSSは、innerHTML のような危険なAPIに文字列が届くことで起きます。CSPに require-trusted-types-for 'script' を加えると、これらのAPIは文字列を受け付けなくなり、ポリシーという関数を通した値だけを受け取ります(MDN Trusted Types API)。実測でも、location.search の値を innerHTML に入れるページで、文字列の代入は TypeError で拒否されました。

// CSP: require-trusted-types-for 'script'; trusted-types app dompurify
const policy = trustedTypes.createPolicy('app', {
  createHTML: (s) => DOMPurify.sanitize(s),
});
document.getElementById('out').innerHTML = policy.createHTML(userHtml);

実測で1つつまずいた点があります。trusted-types app とポリシー名を絞ったところ、DOMPurify 3.4.17 が内部で作ろうとした dompurify という名前のポリシーが拒否され、代入も失敗しました。名前を許可リストに加えると、<b>太字</b> は残り、onerror は消えた状態で出力されました。

ブラウザ strict-dynamic Trusted Types(require-trusted-types-for)
Chrome 52 83
Firefox 52 148
Safari 15.4 26

対応のタイムラインは、MDNのブラウザ互換データ(browser-compat-data の main、2026年10月17日に取得)で確認した最初の版です。未対応のブラウザでは制限が掛からないだけなので、エスケープを外す理由にはなりません。

実測:Chromium 141で反射型XSSとCSP・Trusted Typesを試した

Pythonの標準ライブラリだけで、検索語をそのまま返すページを127.0.0.1に立て、Playwright 1.56.1 のヘッドレス Chromium 141.0.7390.37 で開きました。ペイロードは <script>window.pwned=1</script> と <img src=x onerror="window.pwned=1"> で、読み込み後に window.pwned が1になったかで実行を判定しています。

条件 結果 観測したもの
①エスケープなし(陽性対照) 実行された 2種類とも pwned=1
②html.escape で出力 実行されない 2種類とも pwned なし
③①と同じ出力+Strict CSP 止まった 「Refused to execute inline script」「inline event handler」。nonce 付きの正規スクリプトと、そこから追加した /lib.js は実行
③′ 許可リスト型('self' のみ) JSONP経由で実行された インラインは拒否。同一オリジンのJSONPを指す注入は通過
④innerHTML に代入(DOM型・陽性対照) 実行された pwned=1
④′ ④+require-trusted-types-for 'script' 止まった 「This document requires ‘TrustedHTML’ assignment.」
HttpOnlyのCookie+①の注入 なりすまし成立 document.cookie は空、fetch('/me') は利用者のデータを返した

③で届いた違反レポートは3通で、いずれも Content-Type: application/csp-report、disposition は enforce でした。

注入したもの violated-directive blocked-uri
<script> script-src-elem inline
onerror 属性 script-src-attr inline
JSONPを指す <script src> script-src-elem 注入したURL

④′のレポートは require-trusted-types-for で、blocked-uri は trusted-types-sink、サンプルにはペイロードの先頭が入っていました。レポートを読めば、どの出口に何が届いたかが分かります。測ったのは Chromium だけで、Firefox と Safari の挙動は未検証です。

XSS対策の手順と自分のアプリの確認コマンド

ROLLOUT ORDERXSS対策の順番

まずエスケープ、CSPは報告だけから始める

  1. 既定のエスケープを使う

    テンプレートの自動エスケープに任せる

  2. 抜け道を洗い出す

    dangerouslySetInnerHTML・v-html・safe・innerHTML

  3. リッチHTMLは消毒する

    DOMPurify を通し、最新版に保つ

  4. CSPは報告から強制へ

    Report-Only で違反を集め、nonce を付けてから強制

順番は当サイトの整理。各段の根拠は OWASP XSS Prevention / CSP Cheat Sheet と MDN Trusted Types API

まずは、自動エスケープの外側に出ている箇所の数と、本番が返しているCSPを確かめます。src/ は自分のテンプレートとフロントエンドのディレクトリに、URLは自分のサイトに置き換えてください。

grep -rnE 'dangerouslySetInnerHTML|v-html|\|[[:space:]]*safe\b|\.(innerHTML|outerHTML)[[:space:]]*=|insertAdjacentHTML|document\.write|bypassSecurityTrust|unsafeHTML' src/
curl -s -D - -o /dev/null https://example.com/ | grep -iE '^content-security-policy'

1行目は、抜け道を置いたサンプル(陽性)で7か所すべてを拾い、textContent だけのサンプル(陰性)では0件でした。2行目は、検証サーバーで強制版・Report-Only版のヘッダーを表示し、CSPの無いページでは何も出ませんでした。どちらも判定ではなく構成の確認で、この結果だけでは安全とは言えません。1行目はエスケープの外に出ている候補の箇所を洗い出すだけで、ヒットがそのままXSSとは限らず(正しく無害化していれば安全)、ヒットが0でもこのパターン以外の抜け道や動的に組み立てる箇所は見逃します。2行目はCSPヘッダーの有無と中身を見るだけで、その内容が妥当か(unsafe-inline が入っていないか等)までは判断できません。ヒットした箇所は次の順で片付けます。

・テキストとして出すだけの箇所は textContent やテンプレートの通常の出力に戻す
・HTMLとして出す必要がある箇所は DOMPurify を通し、依存の更新対象に入れる
・URLを受け取る箇所は http/https だけを許可する検証を足す
・CSPが無ければ、Strict CSP を Report-Only で出して違反を集め、正規のスクリプトに nonce を付けてから強制する
・DOM操作が多いアプリは、Trusted Types も Report-Only から試す

影響範囲の見積もりは、ヒットした箇所に「他人が書いた値」が届くかで分けます。届くなら格納型の候補として最優先で直します。

参照ソース

・OWASP Cross Site Scripting Prevention Cheat Sheet(GitHub の原稿を2026-10-17に取得して確認)
・OWASP DOM based XSS Prevention Cheat Sheet
・OWASP Content Security Policy Cheat Sheet
・W3C Content Security Policy Level 3(w3c/webappsec-csp の index.bs で strict-dynamic と report-uri の記述を確認)
・MDN Trusted Types API(mdn/content の原稿で確認)
・MDN browser-compat-data(api/TrustedTypePolicyFactory.json と http/headers/Content-Security-Policy.json)
・Vue.js Template Syntax(Raw HTML)
・Django built-in template filters: safe
・DOMPurify(実測は npm の 3.4.17)