
XSS(クロスサイトスクリプティング)入門 - 3つの種類と、埋め込み先ごとの正しい対策
当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。
XSS(Cross-Site Scripting / クロスサイトスクリプティング)は、Web アプリの脆弱性の中でも古くから知られていて、それでいて今も毎月のように新しい事例が報告される種類の問題です。フレームワークが自動でエスケープしてくれる時代になっても無くならないのは、「どこに・どうやって埋め込むか」によって正しい対策が変わり、その境目で抜け道ができるからです。
この記事では、XSS の仕組みと対策を OWASP の XSS Prevention Cheat Sheet と MDN を一次ソースに整理します。途中に、エスケープの有無でブラウザの解釈がどう変わるかを試せるデモを置いています。
XSS の本質: データがコードとして解釈される
OWASP のチートシートは、XSS を「信頼できないデータを、ブラウザがコードとして扱ってしまう状態」として説明しています。たとえば、コメント欄の入力をそのまま HTML に連結するコードを考えます。
// コメントを表示する(危険な例)
commentArea.innerHTML = '<div class="comment">' + comment + '</div>'comment が「こんにちは」なら問題ありません。しかし <img src=x onerror="alert(document.cookie)"> だった場合、ブラウザはこれを文字ではなく img 要素として解釈し、画像の読み込みに失敗した時点で onerror の JavaScript を実行します。攻撃者が書いた JavaScript が、そのサイトのオリジンの権限で動くことになります。
同じオリジンで動くスクリプトは、そのページでユーザーができることを何でもできます。表示内容の書き換え、入力フォームの盗み見、ログイン中のユーザーとして API を呼ぶ、HttpOnly が付いていない Cookie の読み取りなどです。CORS や SameSite 属性 はオリジンをまたぐ通信を制限する仕組みなので、同じオリジンの中で動く XSS には効きません。
3つの種類
XSS は、悪意のあるデータがどこから来てどこで実行されるかによって、一般に3種類に分けられます。
| 種類 | データの経路 | 例 |
|---|---|---|
| 反射型(Reflected) | リクエストに含まれた値を、サーバーがそのままレスポンスに埋め込む | 検索結果ページの「〇〇の検索結果」に URL の q を出力する |
| 格納型(Stored / Persistent) | 保存された値を、別のユーザーが見るページに埋め込む | 掲示板の投稿、プロフィール欄、商品レビュー |
| DOM ベース(DOM-based) | ブラウザの JavaScript が、URL や postMessage などの値を DOM に書き込む | location.hash を読んで innerHTML に入れる |
反射型は、攻撃者が細工した URL をユーザーに踏ませる必要があります。格納型は一度保存されると、そのページを見た全員に影響するので被害が広がりやすい種類です。DOM ベースはサーバーを通らないこともあり、サーバー側のテンプレートを見直しても見つからない点が厄介です。
ただ、対策の考え方は3種類とも同じです。信頼できないデータを、出力する場所の文法に合わせて無害化してから埋め込む。入力のチェックは役に立ちますが、それだけでは足りません。同じ文字列でも、HTML 本文、属性、JavaScript、URL のどこに入るかによって危険な文字が違うからです。
エスケープすると何が変わるのか(デモ)
下のデモでは、入力した文字列を HTML テンプレートに埋め込んだとき、ブラウザがそれをどんな要素と属性として解析するかを表示します。左がエスケープしない場合、右が & < > " ' を文字参照に変換した場合です。上のボタンで代表的な攻撃パターンを入れられます。
<div class="comment">ここに入力</div>「属性から抜け出す」を押すと、<input value="..."> の中に入れたはずの値が " で属性値を閉じ、autofocus と onfocus という新しい属性を作ってしまう様子が分かります。エスケープすると " が " になるため、値は最後まで value 属性の中に収まります。
埋め込み先ごとの対策
OWASP のチートシートは、出力する場所(コンテキスト)ごとに規則を分けています。要点を表にまとめます。
| 埋め込み先 | 例 | 対策 |
|---|---|---|
| HTML の本文 | <div>ここ</div> | & < > " ' を & < > " ' に変換する |
| HTML の属性値 | <input value="ここ"> | 属性値は必ず " か ' で囲み、属性用のエスケープをする。属性名は固定にする |
| JavaScript | <script>var x = 'ここ'</script> | 原則として入れない。データは JSON として別に渡す |
| URL | <a href="ここ"> | クエリの値は encodeURIComponent でエンコードし、スキームは http と https だけ許可する |
| CSS | <style> や style 属性 | 文字列として埋め込まない。style.setProperty() で、許可した値だけ設定する |
HTML 本文と属性値
いちばん基本になるのがこの2つです。サーバー側のテンプレートエンジン(Laravel の Blade の {{ }}、Django や Jinja2 の自動エスケープ、Go の html/template など)は、標準でここをエスケープします。問題になるのは、それを意図的に外す書き方(Blade の {!! !!}、Jinja2 の |safe など)を使ったときです。
属性値で注意したいのは、引用符で囲まない属性です。<input value=ここ> のように囲んでいないと、空白1つで次の属性を書けてしまいます。また、onclick などのイベントハンドラ属性や iframe の srcdoc 属性は、値そのものが JavaScript や HTML として解釈される場所なので、エスケープしても安全にはなりません。そこにはそもそも信頼できないデータを入れないようにします。
JavaScript の中
<script> の中に文字列を埋め込むのは避けます。OWASP は、16進数や Unicode のエスケープだけでは十分ではないとしています。たとえば </script> という文字列は、JavaScript の文字列の途中にあっても HTML のパーサーが先に見つけて script 要素を閉じてしまいます。
データをスクリプトに渡したいときは、HTML 側に JSON として置き、JSON.parse で読むのが安全です。
<script id="initial-data" type="application/json">{"name":"<img>"}</script>
<script>
const data = JSON.parse(document.getElementById('initial-data').textContent)
</script>JSON を埋め込むときも、< を < にするなど HTML のパーサーに誤解されない形にする必要があります。各言語の JSON 出力関数にはこのためのオプションがあることが多いです。たとえば Go の encoding/json(v1)は < > & を標準で \u003c などにエスケープしますが、Go 1.27 で入った encoding/json/v2 は標準ではエスケープせず、jsontext.EscapeForHTML(true) を渡す必要があります。また、API が JSON を返すときは Content-Type: application/json で返すことも OWASP が挙げている注意点です。text/html で返すと、ブラウザがそれを HTML として表示してしまう余地が残ります。
URL
href や src に外部からの値を入れる場合は、2つのことを確認します。1つは、クエリパラメータの値を encodeURIComponent でエンコードすること(詳しくは「URL エンコード入門」)。もう1つは、URL 全体を受け取る場合にスキームを確かめることです。
<a href="javascript:alert(1)"> は、HTML としては完全に正しいリンクです。エスケープしても javascript: は消えないので、エスケープとは別に「http か https で始まるものだけ許可する」チェックが必要です。ブラウザは先頭の空白などを取り除いてからスキームを判定するため、文字列の先頭一致で javascript: を弾くのではなく、new URL() で解析して protocol を見るのが確実です。
function safeUrl(input) {
try {
const url = new URL(input, location.origin)
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null
} catch {
return null
}
}危険なシンクと安全な API
DOM ベースの XSS は、JavaScript の書き方で決まります。OWASP は、文字列を HTML として解釈する API(シンク)を使わず、文字列を文字として扱う API を使うように勧めています。
| 危険(HTML やコードとして解釈する) | 安全(文字として扱う) |
|---|---|
element.innerHTML = s | element.textContent = s |
element.outerHTML = s | element.insertAdjacentText('beforeend', s) |
element.insertAdjacentHTML(...) | document.createTextNode(s) |
document.write(s) | element.setAttribute(固定の名前, s) |
eval(s) / new Function(s) | JSON.parse(s) |
setTimeout(s)(文字列を渡す形) | setTimeout(() => ...)(関数を渡す形) |
textContent に入れた文字列は、<script> と書いてあってもただの文字として表示されます。HTML を組み立てる必要がないなら、これがいちばん確実な対策です。
フレームワークの抜け道
React の JSX で {value} と書いた値や、Vue のテンプレートの {{ value }} は自動でエスケープされます。フレームワークを使っていれば、HTML 本文と属性値の XSS はかなり起きにくくなっています。
それでも XSS が起きるのは、次のような自動エスケープを外す機能を使ったときです。
- React の
dangerouslySetInnerHTML - Vue の
v-html - Angular の
bypassSecurityTrustHtmlなどのbypassSecurityTrustAs*関数 - Lit の
unsafeHTML refで DOM を取ってinnerHTMLに直接書き込むコード
もう1つは href です。<a href={user.website}> の website が javascript: で始まっていれば、自動エスケープは役に立ちません。OWASP によると React 19 は src と href の javascript: URL をブロックしますが、「アプリケーション側での URL の検証の代わりにはならない」とも書かれています。前の節の safeUrl のような確認は、フレームワークを使っていても入れておく方が安全です。
HTML を表示したい場合はサニタイズする
ブログの本文やリッチテキストエディタの出力のように、ユーザーが書いた HTML をそのまま表示したい場合もあります。この場合はエスケープではなく、危険な要素や属性を取り除くサニタイズをします。
OWASP が勧めているのは DOMPurify です。
import DOMPurify from 'dompurify'
element.innerHTML = DOMPurify.sanitize(dirty)サニタイズで気をつけたいのは、サニタイズした後で文字列を加工しないことです。OWASP も、サニタイズ後に内容を書き換えると対策が無効になり得るとしています。また、サニタイザー自体にも脆弱性が見つかることがあるので、ライブラリは定期的に更新します。
正規表現で <script> を消すような自作のサニタイザーは避けてください。HTML のパーサーは、閉じタグの省略や属性の書き方の揺れを独特の規則で解釈するので、文字列の置換では漏れが出ます。
ブラウザ標準の setHTML()
ブラウザ自身にも、サニタイズしながら HTML を入れる Element.setHTML() が入り始めています。MDN は、対応しているブラウザでは innerHTML の代わりに使うことを勧めています。
target.setHTML(userHtml)setHTML() は、設定に関係なく <script>、<iframe>、<object>、<embed> やイベントハンドラ属性を必ず取り除きます。MDN の互換性データ(2026年11月時点)では、Chrome 146 と Firefox 148 が対応していて、Safari は未対応です。全ブラウザで使える状態ではないので、当面は DOMPurify と併用するか、対応状況を確かめてから使うことになります。
多層防御: CSP と Trusted Types
ここまでの対策は「XSS を作り込まない」ためのものです。それでも漏れは起きるので、漏れたときに被害を抑える仕組みも重ねておきます。
Content Security Policy
CSP は、ページで実行してよいスクリプトをレスポンスヘッダで指定する仕組みです。web.dev が勧める nonce を使った strict CSP は次の形です。
Content-Security-Policy:
script-src 'nonce-{RANDOM}' 'strict-dynamic';
object-src 'none';
base-uri 'none';nonce はレスポンスごとに新しく作る推測できない値で、同じ nonce を付けた <script> だけが実行されます。攻撃者が埋め込んだ <script> や onerror 属性は nonce を知らないので動きません。CSP の書き方と段階的な導入方法は「Content Security Policy(CSP)入門」で詳しく解説しています。
ただし OWASP は、CSP だけに頼ることを避けるべき例として挙げています。CSP は出力のエスケープの代わりではなく、あくまで2枚目の防御です。
Trusted Types
Trusted Types は、DOM ベースの XSS を防ぐための仕組みです。CSP に require-trusted-types-for 'script' を指定すると、innerHTML などの危険なシンクにただの文字列を渡すと TypeError になり、あらかじめ決めた変換関数(ポリシー)を通した値しか受け付けなくなります。
const policy = trustedTypes.createPolicy('sanitize-html', {
createHTML: (input) => DOMPurify.sanitize(input),
})
element.innerHTML = policy.createHTML(userHtml) // ポリシーを通した値なので通る
element.innerHTML = userHtml // TypeError危険なシンクへの書き込みが必ずポリシーを通るようになるので、コードレビューで確かめる場所がポリシーの中だけに絞れます。MDN の互換性データでは Chrome 83、Firefox 148、Safari 26 が対応していて、MDN は2026年2月から主要ブラウザで使える「Baseline 2026」としています。以前は Chromium 系だけの機能でしたが、今は本番で使える選択肢になっています。
Cookie の HttpOnly
セッション Cookie に HttpOnly を付けておくと、document.cookie から読めなくなります。XSS が起きてもセッション ID をそのまま盗まれることは防げます。ただし、攻撃者のスクリプトはそのページの中でログイン中のユーザーとして API を呼べるので、XSS の被害そのものが無くなるわけではありません。
まとめ
- XSS は、信頼できないデータがブラウザにコードとして解釈されることで起きる。種類は反射型・格納型・DOM ベースの3つ
- 対策の基本は、出力する場所の文法に合わせて無害化すること。HTML 本文と属性値はエスケープ、URL はエンコードとスキームの確認、JavaScript の中には入れない
- DOM 操作では
innerHTMLなどのシンクを避け、textContentを使う - フレームワークの自動エスケープは
dangerouslySetInnerHTMLやv-htmlと、hrefのjavascript:で抜けられる - HTML を表示したいときは DOMPurify でサニタイズする。
setHTML()は Safari が未対応 - 漏れたときに備えて、strict CSP、Trusted Types、
HttpOnlyを重ねる
XSS の対策を一言でまとめると、「データとコードを混ぜない」です。文字列を連結して HTML を作っている箇所を見つけたら、そこが最初に疑う場所です。
参考リンク
- Cross Site Scripting Prevention Cheat Sheet(OWASP)
- DOM based XSS Prevention Cheat Sheet(OWASP)
- Trusted Types API(MDN)
- Element.setHTML()(MDN)
- Content Security Policy(MDN)
- Mitigate cross-site scripting (XSS) with a strict Content Security Policy(web.dev)
- DOMPurify(GitHub)
- Content Security Policy(CSP)入門(当ブログ)
- Cookie・セッション・SameSite の基礎(当ブログ)
- Go 1.27 の encoding/json/v2 入門(当ブログ)


