XSS(クロスサイトスクリプティング)入門 - 3つの種類と、埋め込み先ごとの正しい対策

XSS(クロスサイトスクリプティング)入門 - 3つの種類と、埋め込み先ごとの正しい対策

作成日:
読了:約18分
更新日:

当サイトは 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 テンプレートに埋め込んだとき、ブラウザがそれをどんな要素と属性として解析するかを表示します。左がエスケープしない場合、右が & < > " ' を文字参照に変換した場合です。上のボタンで代表的な攻撃パターンを入れられます。

▶ エスケープの有無で、ブラウザの解釈がどう変わるか
入力した文字列を HTML テンプレートへ埋め込み、エスケープしない場合とする場合で、ブラウザがどんな要素・属性として解析するかを比べます。解析は DOMParser の実行されない文書で行うため、入力したスクリプトがこのページで動くことはありません。
埋め込み先:<div class="comment">ここに入力</div>

「属性から抜け出す」を押すと、<input value="..."> の中に入れたはずの値が " で属性値を閉じ、autofocus と onfocus という新しい属性を作ってしまう様子が分かります。エスケープすると " が &quot; になるため、値は最後まで value 属性の中に収まります。

埋め込み先ごとの対策

OWASP のチートシートは、出力する場所(コンテキスト)ごとに規則を分けています。要点を表にまとめます。

埋め込み先例対策
HTML の本文<div>ここ</div>& < > " ' を &amp; &lt; &gt; &quot; &#x27; に変換する
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 = selement.textContent = s
element.outerHTML = selement.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 を付けておくと、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 を作っている箇所を見つけたら、そこが最初に疑う場所です。

参考リンク

タイムゾーンと日付の正しい扱い方 - UTC・ISO 8601・夏時間の罠と JavaScript Date

タイムゾーンと日付の正しい扱い方 - UTC・ISO 8601・夏時間の罠と JavaScript Date

約9分

日付とタイムゾーンの扱いを実務目線で整理します。UTC とオフセットとIANAタイムゾーン(Asia/Tokyo)の違い、ISO 8601 / RFC 3339 の推奨フォーマット、Unixエポック、JavaScript の Date のハマりどころ(月が0始まり・日付のみ文字列はUTC解釈・getTimezoneOffsetの符号)、夏時間(DST)で生じる「存在しない時刻」と「重複する時刻」、そして「保存はUTC・表示時に変換・IANA識別子で扱う」などのベストプラクティスを、MDN・RFC 3339・IANA を一次ソースにまとめます。

CORS の仕組みとハマりどころ - プリフライト・credentials・Allow-Origin を理解する

CORS の仕組みとハマりどころ - プリフライト・credentials・Allow-Origin を理解する

約10分

CORS(オリジン間リソース共有)を実務目線で整理します。同一オリジンポリシーとの関係、単純リクエストとプリフライト(OPTIONS)の条件、Access-Control-Allow-Origin などの各ヘッダ、credentials 付きで * が使えない理由、Vary: Origin とキャッシュ、そして「No Access-Control-Allow-Origin header」エラーの意味と対処まで、MDN と WHATWG Fetch Standard を一次ソースにまとめます。CORS はブラウザの仕組みであって認可ではない、という勘所も。

楽天市場のおすすめPR / 楽天アフィリエイト