「Reactは自動エスケープしてくれるからXSSは古い話」——そう思っていないでしょうか。

実際には、ReactやNext.jsが標準で塞いでくれる経路は XSS の侵入口の一部 にすぎません。dangerouslySetInnerHTML、依存パッケージの汚染、悪意ある拡張機能。これらはReactの自動エスケープを そもそも経由しません

この記事では、現代のWebアプリでXSSが侵入してくる 4 つの経路 を整理し、それぞれに対する 多層防御モデル をマトリクス化します。最後に、認証トークンの保存設計(better-authのAccess Tokenを例に)を取り上げて、「攻撃経路 × 防御層」で判断する具体例を示します。

この記事を書いたきっかけ

3サイトのフロントエンドを並行開発する中で、リリース運用フェーズへの移行に合わせて認証セキュリティ設計を見直しました。実装を読み込むと、「Access Tokenに5分のTTLを設定しているから安全」という直感は持続的XSSのケースでは成り立たないと気づきました。第1号「NextAuth v5 から better-auth へ!逆引き移行チートシート」で扱った認証基盤の続編として、運用フェーズで考えるべき多層防御の整理を共有します。

先に結論:攻撃経路 × 防御層のマトリクス

詳細は本文で展開します。結論は次のマトリクスに集約されます。

StoredReflectedDOM-basedサプライチェーン拡張機能
CSP×
レート制限××
トークン隔離××
異常検知

読み取り:

  • CSP が最も広く効く根本対策。ただしサプライチェーンと拡張機能には限界がある
  • 認証トークン隔離(BFF化)の効果は限定的で、AT 値漏洩経路は塞ぐが、API 不正コール経路は塞げない
  • 単独の銀の弾丸はなく、組み合わせる前提で設計する

なぜこのマトリクスになるのか、なぜトークン隔離だけでは不十分なのかを、以下で順に解説します。

この記事で得られること

  • XSSの 4 つの侵入経路(Stored / Reflected / DOM-based / サプライチェーン・拡張機能)の整理
  • 持続的 XSS」という概念と、それが短命トークンを無効化する仕組み
  • CSP / レート制限 / 認証トークン隔離 / エラー監視 の4つの守備範囲を比較した防御マトリクス
  • 認証トークンを JS から隠す設計 が「何を防げて、何を防げないか」
  • 単一の銀の弾丸ではなく、多層で組み合わせる ための判断軸

動作確認環境

種別バージョン
OSmacOS 15.x
Node.js24.x
パッケージマネージャーpnpm 10.x
Next.js16.x(App Router)
React19.x
better-auth1.x

1. なぜ XSS が今も現役なのか

XSS(Cross-Site Scripting)は2000年代から続く古典的な脆弱性です。ReactやVueのような現代のフレームワークは、テンプレート内の変数を自動的にエスケープしてくれます。

1
2
3
4
// React は { } 内の文字列を自動エスケープする
const userInput = '<script>alert(1)</script>';
return <div>{userInput}</div>;
// 実際の DOM: <div>&lt;script&gt;alert(1)&lt;/script&gt;</div>

これでXSSが撲滅されたわけではありません。Reactの自動エスケープを そもそも経由しない経路 が複数あるからです。

主な抜け道:

  1. dangerouslySetInnerHTML — マークダウンレンダリング、HTMLメール本文プレビュー、リッチテキスト表示など、生のHTMLを扱う必要がある箇所
  2. 依存パッケージ汚染 — npmの依存ツリーに悪意あるコードが混入すると、自動エスケープの「前」にスクリプトが組み込まれる
  3. ブラウザ拡張機能 — CSPの制約を受けずに任意のJSを実行できる
  4. DOM API の直接呼び出しelement.innerHTML = ... のようなコードがフレームワーク外で書かれる

つまり、XSS対策を考えるときに重要なのは「フレームワークが防いでくれる」ではなく、どの経路から攻撃が入ってくるか を整理することです。


2. XSS 4 つの侵入経路

2.1 Stored XSS(永続型・最も現実的)

攻撃者の入力がサーバに保存され、後で他のユーザーが画面を開いた時に実行される 形式です。

1
2
3
4
5
攻撃者                       DB                       被害者
  └─ 自己紹介欄に
     <script>fetch(...)</script>   ────► 保存
                                      └────► 被害者が画面を開くと実行

現実的なシナリオ

  • ユーザー入力欄(自己紹介、ニックネーム、メッセージ)に <script> を仕込む
  • 管理画面でその値を表示する際、エスケープ漏れがあれば管理者の権限で発火
  • マークダウン対応の入力欄で <img src=x onerror=...> のような属性ベースの攻撃

Reactの自動エスケープが効くのは、{ userInput } のような テンプレート内の文字列 です。次のようなケースでは効きません。

1
2
3
4
5
6
7
8
9
// dangerouslySetInnerHTML は名前のとおり危険
<div dangerouslySetInnerHTML={{ __html: userInput }} />

// マークダウン → HTML 変換でサニタイズを怠ると XSS
// marked は v8.0.0 (2023) で sanitize オプションを廃止しており、
// 出力後に DOMPurify 等で必ずサニタイズする必要がある
const dirty = marked.parse(userInput);
const html = DOMPurify.sanitize(dirty);
<div dangerouslySetInnerHTML={{ __html: html }} />

対策

  • 入力時のサニタイズ:DOMPurifyなどでHTMLを安全な形に整形してから保存
  • 出力時のエスケープdangerouslySetInnerHTML を使う箇所を限定し、必ずDOMPurifyを通す
  • CSP:根本対策(後述)

2.2 Reflected XSS(反射型)

URLパラメータに攻撃コードを仕込み、被害者にそのリンクを踏ませる形式です。

1
2
3
4
5
6
7
8
9
攻撃者がフィッシングメール送信
被害者がリンククリック
URL: https://example.com/search?q=<script>fetch(...)</script>
サーバが q をそのままページに埋め込んで返す
ブラウザで実行

現実的なシナリオ

  • 検索結果ページがクエリ文字列をそのまま埋め込む
  • エラーページがエラーメッセージをそのまま表示する
  • リダイレクト先URLがパラメータで指定される

Next.jsのServer Componentsでは、テンプレート内であればReact同等のエスケープが効きます。ただし headers()cookies() を経由して動的にレスポンスを組み立てる場合、サーバ側で文字列結合してしまうと穴が開きます

対策

  • URLパラメータの値を表示する際は必ずエスケープ
  • リダイレクト先URLは オリジン検証(自社ドメインのみ許可)
  • フィッシング誘導を前提に CSP の script-src で実行自体を阻止

2.3 DOM-based XSS

サーバを経由せず、ブラウザ内の JS が URL や postMessage を信頼して DOM に流し込む 形式です。

1
2
3
// 危険な例
document.getElementById('msg').innerHTML = location.hash;
// URL の #xxx 部分をそのまま innerHTML に挿入している

https://example.com/page#<img src=x onerror=fetch(...)> のようなURLを踏ませると発火します。

現実的なシナリオ

  • SPAのルーティングでハッシュベースの状態管理(古い実装)
  • postMessage で受信したデータを オリジン検証なしに 信用してinnerHTMLに流す
  • document.write() でクエリパラメータを書き戻す

対策

  • innerHTML / outerHTML / document.write() の使用を禁止(Lintルールで強制)
  • postMessage必ずオリジン検証 してから処理
  • フレームワーク提供のAPI(Reactなら useEffecttextContent を設定)を使う

2.4 サプライチェーン / 拡張機能(最も防ぎにくい)

用語の注記: 厳密にはサプライチェーン汚染・拡張機能経由は「注入型XSS(攻撃者の入力が表示時に実行される形)」ではなく「任意JS実行(信頼コードの汚染)」のカテゴリです。本記事では フロントエンドの自動エスケープを経由しない侵入経路 という実務的な括りで、Stored/Reflected/DOM-basedと同じ土俵に並べて扱います。

サプライチェーン汚染

1
2
3
4
5
npm install したパッケージが乗っ取られる
ビルドに悪意あるコードが混入
全ユーザーの画面で実行

過去の代表的な事件:

  • 2024 年 6 月「polyfill.io」事件 — 同年2月に中国系企業Funnullが買収したCDNドメインに悪意あるスクリプトが仕込まれた。利用サイト10万件超のモバイルユーザーをギャンブルサイトへ誘導するマルウェアが配信された(Sansecが6/25公表)
  • 2018 年「event-stream」事件 — npmパッケージのメンテナ権限を譲渡された後、仮想通貨ウォレット狙いの悪意あるコードが混入
  • 2024 年「xz-utils」事件 — オープンソースに長期間潜伏したバックドア

依存パッケージはビルドプロセスの エスケープより前 に組み込まれるため、フレームワークの保護をすり抜けます。

悪意ある拡張機能

ユーザーが入れたブラウザ拡張機能は、ホスト権限があれば 任意のサイトで JS を実行できます。「便利系」を装った悪意ある拡張機能経由で、銀行サイトでも攻撃JSが走る、というケースです。

CSPの制約は 拡張機能の content script(isolated world)には適用されない ため、拡張機能経由の攻撃はCSPでは完全には防げません。

対策

  • 依存パッケージの自動更新監視(Renovate / Dependabot)で脆弱性パッチを即時適用
  • SBOM 管理(Software Bill of Materials)で依存ツリーの可視化
  • CDN 経由の外部スクリプトは Subresource Integrity(SRI) でハッシュ検証
  • 拡張機能経由はユーザー側の問題のため、重要操作には追加認証(再認証・MFA)で被害範囲を限定

3. 「持続的 XSS」という概念

ここまでの4経路のうち、Stored XSS拡張機能経由 は「持続的XSS」になりやすい性質があります。

持続的 XSS とは

「攻撃JSが タブに常駐し続ける 形」を指します。具体的には次のとおりです。

  • Stored XSS:被害者がページを開いている間、攻撃JSは実行され続ける(SPAだと特に長時間)
  • 拡張機能経由:ブラウザを起動している間ずっと動く

これが厄介なのは、「短命なセキュリティトークンの TTL が実効的な防御にならない」 点です。

「5 分 TTL なら安全」は本当か

Webアプリの認証では、Access Token(以下AT)に短い有効期限(例:5分)を設定し、Refresh Token(RT)で随時更新する方式が一般的です。

「ATが漏れても5分で無効になるから安全」——これは 一過性 XSS(リロードで消える型) には有効ですが、持続的 XSS には効きません

なぜか。攻撃JSがタブに常駐していれば、5分ごとに「現在のATを取得するエンドポイント」を叩いて 新鮮な AT を取り続けられる からです。

1
2
3
4
5
// 攻撃 JS(持続的 XSS で常駐している想定)
setInterval(async () => {
  const session = await fetch('/api/auth/get-session').then(r => r.json());
  // session.accessToken を攻撃者サーバに送る or API を不正コール
}, 5 * 60 * 1000);

セッションCookieが有効な限り、このループは止まりません。TTLは 「攻撃 JS が止まった後の有効期間」 にしか効かないのです。


4. 多層防御モデル

XSSの経路ごとに有効な対策が違うため、防御は 複数レイヤーの組み合わせ で考える必要があります。代表的な4つの防御層を整理します。

4.1 CSP(Content-Security-Policy)

HTTPレスポンスヘッダで「どこからJSを読み込んでOKか」「fetchでどこに通信してOKか」をブラウザに宣言する仕組みです。XSS に対する最も強力な根本対策 といえます。

1
2
3
4
5
6
7
8
9
Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{random}';
  connect-src 'self' https://api.example.com https://sentry.io;
  frame-ancestors 'none';
  object-src 'none';
  base-uri 'self';
  report-uri /api/csp-report;
  report-to csp-endpoint;

※ 実際のHTTPレスポンスヘッダは1行で送信されます。上記は可読性のため改行・インデントで整形しています。'nonce-{random}'{random}リクエストごとにサーバ側で生成する実値のプレースホルダ です。同じ値を該当ページ内の <script nonce="..."> にも埋め込みます。

各ディレクティブの効果

  • script-src 'self' 'nonce-{random}' — インラインスクリプトと外部スクリプトを制限。'unsafe-inline' / 'unsafe-eval' を排除すると、Stored XSSで <script> が仕込まれても 実行されない
  • connect-src — fetch / XHR / WebSocketの接続先を許可リスト化。仮にXSSが成立しても、攻撃者の外部サーバに データを送信できない
  • frame-ancestors 'none' — クリックジャッキング対策
  • report-uri / report-to — CSP違反をSentry等に送信(report-uri はdeprecated扱いで report-to への移行が推奨。互換性のため両方を併記する)

段階導入

CSPは厳しすぎると正常な機能まで壊すため、Report-Only モードで違反を観測 → 違反を解消 → enforce モード の段階導入が定石です。

1
Content-Security-Policy-Report-Only: <policy>

これでブロックはせずに違反だけログに送る状態を作れます。

4.2 レート制限

特定のエンドポイントへの 連打を直接ブロック する仕組みです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// better-auth の例
betterAuth({
  rateLimit: {
    enabled: true,
    customRules: {
      '/get-session': { window: 60, max: 12 },     // 60 秒に 12 回まで
      '/refresh-token': { window: 300, max: 2 },   // 5 分に 2 回まで(※後述のカスタムエンドポイント)
    },
  },
});

/refresh-token は標準better-authには存在しません。本記事はカスタムプラグインで定義したリフレッシュ用エンドポイントを前提にしています(標準OAuthに寄せる場合は /oauth2/token 相当を別途実装します)。

持続的XSSが常駐していても、連打そのものをサーバ側で弾く ことにより攻撃の効率を下げます。ATを5分ごとに取得し続けるパターンに対しては、5分単位の閾値が直接効きます。

4.3 認証トークンの保存設計

ATを どこに置くか によって、漏洩経路が変わります。

保存先XSS 耐性CSRF 耐性実装の手間
localStorage✗ JS から読める◯ Cookie ではないので CSRF 経路にならない
sessionStorage✗ JS から読める◯ 同上
httpOnly Cookie✗ JS から読める✗ 自動付与される
httpOnly Cookie(推奨)◯ JS から読めない△ SameSite で軽減可
BFF パターン(JS が AT を一切扱わない)◎ JS から読めない・送れない△ 同上

ただし、後述するように 「JS から AT が見えない」だけでは持続的 XSS には不十分 です。BFFパターンでも完全に防げるわけではありません(§5で詳説)。

4.4 エラー監視による異常検知

CSP / レート制限を抜けた異常リクエストを 事後検知してアラート する仕組みです。

  • Sentryの beforeSend フックで /api/auth/get-session の異常連打を検知
  • 同一セッションから短時間に大量のAPIコール → 攻撃の可能性をアラート
  • CSP違反レポート(report-uri)をSentryで集約

事後検知は予防にはなりませんが、インシデント発生後の影響範囲特定攻撃パターン学習 に直結します。

4.5 攻撃経路 × 防御層のマトリクス(再掲)

冒頭で示したマトリクスを、ここまでの整理を踏まえて再掲します。

StoredReflectedDOM-basedサプライチェーン拡張機能
CSP×
レート制限××
トークン隔離××
異常検知

このマトリクスから読み取れること

  • CSP が最も広く効く根本対策 だが、サプライチェーン汚染(特に自社ビルド内に混入したケース)と拡張機能経由には限定的
  • トークン隔離単独では XSS のどの経路にも完全には対応できない(後述のとおり、AT値漏洩は防げてもAPI不正コールは防げない)
  • 拡張機能経由はどの層でも完全には防げないため、「最重要操作には再認証」のような 被害範囲限定 の発想が必要
  • どの1つも完全ではないため、組み合わせる前提で設計する

5. ケーススタディ:認証トークンの保存設計(better-auth を例に)

実例として、better-authで発行されたAccess Tokenをどう扱うかを取り上げます。

5.1 本記事の前提構成(カスタムプラグインで AT/RT を session に追加)

標準のbetter-authで getSession() が返すsessionコアフィールドは次のとおりです。

  • token / userId / expiresAt / createdAt / updatedAt / ipAddress / userAgent

session.accessToken は標準には存在しません

本記事は カスタムプラグインで session に AT/RT を追加した構成 を前提に解説します。前作のNextAuth v5 から better-auth へ!逆引き移行チートシートで扱った構成です。

セッションのキャッシュは httpOnly Cookie 内のJWTで管理します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// auth.ts
const auth = betterAuth({
  session: {
    cookieCache: {
      enabled: true,
      maxAge: 5 * 60,       // 公式デフォルトは 5 分。長くするとセッション失効が反映されるまで遅延する点に注意
      strategy: 'jwt',      // 署名のみ(中身は読める)。中身を秘匿したい場合は 'jwe'(暗号化)を選ぶ
    },
  },
  advanced: {
    defaultCookieAttributes: {
      httpOnly: true,
      sameSite: 'lax',
      secure: process.env.NODE_ENV === 'production',
    },
  },
});

なお、cookieCache 自体はベースのsession/userフィールドのみを格納する短命キャッシュ(DB照会を省略する仕組み)です。AT/RTのようなカスタムフィールドは、別途プラグイン側で getSession() のレスポンスへ含める設計となります。

httpOnly Cookieなので、JSから document.cookie で値を読むことはできません。しかし、/api/auth/get-session を呼ぶと、カスタムプラグインが追加した AT がレスポンス JSON に含まれて返ります

1
2
3
// クライアント JS
const session = await authClient.getSession();
console.log(session.data.session.accessToken);  // 読める

これは API クライアント側で Bearer ヘッダに乗せるため の設計です。利便性は高い反面、JS のメモリに AT が露出する 構造です。

5.2 customSession で RT を隠す

本記事の前提構成(カスタムプラグインでsessionフィールドにRTを追加した形)では、/api/auth/get-session のレスポンスJSONにRTが含まれてしまいます。これを customSession プラグインで除去するのが推奨パターンです。

1
2
3
4
5
6
plugins: [
  customSession(async ({ user, session }) => {
    const { refreshToken: _r, refreshTokenExpiresAt: _e, ...safe } = session;
    return { user, session: safe };
  }, options),
];

RTはCookie内JWTには残るため、サーバ側の /refresh-token エンドポイントでは引き続き利用可能です。JSからは見えない構造です。

5.3 「AT を JS から完全に隠す」という選択肢(BFF パターン)

ここからが本題です。AT も JS から触らせない構成(いわゆるBFFパターン)を取れば、AT値の漏洩経路を完全に塞げます。

1
2
3
4
5
6
7
8
[現状: AT が JS から見える]
JS  ──Bearer ${AT}──► BE
    ↑ JS が AT を持っている

[BFF パターン: AT を JS から隠す]
JS ──/api/proxy/*──► Next.js Server ──Bearer ${AT}──► BE
                      Cookie から AT を取り出す

JSはAT値を一切知らず、API呼び出しはすべてNext.js Server経由で行われます。

5.4 BFF パターンが「防げるもの」と「防げないもの」

ここが最も誤読されやすいポイントです。BFF化で AT 値の漏洩経路は塞げます が、API 不正コール経路は防げません

なぜか。httpOnly Cookieの挙動を思い出してください。

httpOnly Cookie は 「JS から値が読めない」だけ で、「JS が送る fetch には自動で付く」

つまり、持続的XSSが成立していれば、攻撃JSは次のように動けます。

1
2
3
// 攻撃 JS(同一 Origin で動いている)
await fetch('/api/proxy/users/me/delete', { method: 'POST' });
// ↑ Authorization ヘッダなしの普通の fetch

このリクエストの流れ:

  1. ブラウザが Cookie を自動付与 する(httpOnly でもfetchには付く)
  2. Next.js Serverから見ると「正規ユーザーのCookie付きリクエスト」と区別できない
  3. ServerはCookieからATを取り出してBEに転送
  4. BEはATを信用して DELETE /v1/users/me を実行
  5. ユーザーアカウントが消える

攻撃JSはAT値を一度も見ないまま、API を不正実行できてしまう わけです。

例え話

銀行 ATM で例えるなら、AT 値 = 暗証番号Cookie = キャッシュカード に相当します。

AT 隔離は「暗証番号を見せない」工夫です。しかし本人がカード(Cookie)を ATM に挿したままの状態(=ログイン中のセッション)だと、横から手を伸ばして引き出しボタンを押されれば、暗証番号を知らなくても金額を引き出されてしまいます。

攻撃 JS は「横から手を伸ばす」役です。暗証番号(AT 値)は見られなくても、ボタン操作(API コール)は実行可能です。

SameSite や CSRF トークンで防げないのはなぜか

§4.3の表で「httpOnly CookieはCSRFが △ SameSite で軽減可」と書きました。ただし、この章で扱う API 不正コールは同一オリジン内のXSSが起点のため、SameSite も CSRF トークンも効きません

  • SameSite: クロスサイトのfetchを制約する仕組みなので、同一オリジン内の攻撃JSには無関係
  • CSRF トークン: 攻撃JSが同一オリジンで動いていれば、トークンを取得するエンドポイントもDOMからも普通に読み出せる

この経路に効くのは CSP の connect-src 制限(プロキシ経路自体を許可リスト化)、レート制限(連打を抑制)、重要操作の再認証/MFA(被害範囲限定)の組み合わせです。SameSite/CSRFはあくまで「他サイト経由の悪用」を防ぐ層であり、本章のシナリオには別レイヤーで対処する必要があります。

5.5 整理:何が防げて何が防げないか

攻撃経路httpOnly Cookie + customSessionBFF 化(AT も JS から隠す)
RT 値を JS から盗む◯ 防げる◯ 防げる
AT 値を JS メモリから抜く◯ 防げる
AT 値を拡張機能・ログに漏らす◯ 防げる
AT 値を攻撃者の外部サーバに送信◯ 防げる
攻撃 JS がブラウザ内で直接 API を叩く

→ AT隔離は「AT値の盗難経路」を塞ぐ施策で、「ブラウザ内API不正コール」は塞げません。単独の銀の弾丸ではない ことが分かります。

5.6 判断軸

ではどう決めるか。コスト × 効果 で見ます。

  • AT 隔離(BFF 化)のコスト:APIクライアント層の全面書き換え、自動生成コードの置換、CSRF対策の追加、全API経路のリグレッションQA。中〜大規模
  • AT 隔離の効果:AT値漏洩経路の遮断(拡張機能・ログ・外部送信)。ただし、より深刻な「API不正コール」は別経路で防ぐ必要あり

このため、現実的な判断は:

  • 短期:CSP強化 + レート制限明示設定 + 異常検知で 攻撃面を直接抑える(小〜中コスト、即効性)
  • 中長期:BFF化は 新規プロジェクトの初期設計に組み込む(既存プロジェクトへの後付け改修はROIが見合わない)

という段階導入が落としどころです。「AT 隔離しないと脆弱」ではなく、「多層防御の 1 レイヤーとして必要に応じて加える」 という見方です。


6. まとめ

この記事のポイント

  • XSSは古典的だが、自動エスケープを経由しない経路(dangerouslySetInnerHTML / サプライチェーン / 拡張機能 / DOM API 直接呼び出し) が今も穴になる
  • 侵入経路は Stored / Reflected / DOM-based / サプライチェーン・拡張機能 の4種類。それぞれ性質が違うので、対策も使い分ける
  • 持続的 XSS(タブ常駐型)が成立すると、短命トークンのTTLは実効的な防御にならない
  • 防御は CSP / レート制限 / 認証トークン隔離 / 異常検知 の多層構造で考える
  • 単一の銀の弾丸はない。攻撃経路 × 防御層のマトリクス で組み合わせを判断する
  • 認証トークン隔離(BFF化)について:AT 値漏洩経路は塞げるが、API 不正コール経路は塞げない。単独施策としては効果が限定的

設計の出発点

XSS対策を考えるとき、「フレームワークが防いでくれるか」ではなく「どこから JS が紛れ込むか」 から逆算する習慣をつけると、漏れが減ります。

  • 入力欄経由 → CSP + サニタイズ
  • URL経由 → CSP + パラメータエスケープ
  • DOM API → Lint + フレームワークAPIへの寄せ
  • 依存ツリー → 自動更新監視 + SBOM
  • 拡張機能 → 重要操作の再認証

そして「すでにXSSが成立した時」のことも想定して、トークン保存設計と異常検知 で被害範囲を限定します。これが多層防御の考え方です。

「httpOnly Cookieだから安全」「5分TTLだから安全」のように 単一施策で安心するのは誤読 だということが、この記事で伝われば幸いです。


参考リンク