メインコンテンツへスキップ
友田 陽大
アプリ層セキュリティ
Next.js
React
セキュリティ
TypeScript

Next.js / React のXSS・DOM-XSS対策 — dangerouslySetInnerHTML の穴と、安全なサニタイズ・CSP

Reactは標準でJSXをエスケープするが、dangerouslySetInnerHTML・href=javascript:・ref経由のDOM操作・DOMシンクで抜ける。XSS/DOM-XSSの発生箇所、DOMPurifyによる出力時サニタイズ、CSP(nonce)/Trusted Typesの多層防御を、脆弱→修正の実コードで解説します。

公開日
読了時間
19分
著者
友田 陽大
シェア

最初に結論を述べます。Reactは、JSXに埋め込んだ値を標準でHTMLエスケープするため、素直に書いている限りXSSに強い。問題は、その防御を無効化する「抜け道」がフレームワーク自身に用意されていて、しかもそれを開けるのは攻撃者ではなく開発者だ、という点です。 代表的な穴は4つ——dangerouslySetInnerHTMLhref / srcjavascript: スキーム、ref 経由の直接DOM操作、そしてサードパーティHTMLの埋め込みです。

これは「Reactは危ない」という話ではありません。むしろ逆で、Reactのデフォルトは安全側に倒れています。 危ないのは、その安全弁を「手元で動かしたいから」と外す瞬間です。本記事は、XSS(反射型・格納型・DOM-XSS)がNext.js / Reactのどこに空くのかを地図にし、万一 <script> を仕込まれても被害を小さくするための出力時サニタイズ・URLスキーム検証・Trusted Types・CSP(nonce)を、脆弱→修正の実コードで体系化します。XSSはOWASP Top 10のインジェクション(A03)に含まれる最頻出クラスの1つです(OWASP Top 10)。アプリ全体のセキュリティ地図はNext.js × Supabase アプリケーションセキュリティ完全ガイドにまとめており、本記事はそのうち「XSS/出力の安全性」に絞った深掘りです。


1. 前提:ReactはデフォルトでXSSをエスケープする

最初に、Reactの「効いている防御」を正確に理解します。JSXの {} に渡した値は、テキストノードとして描画されます。HTMLとしては解釈されないため、文字列に含まれるタグはそのまま画面に表示されるだけです。

// React は {} の中身を「テキスト」として描画する=HTMLとして解釈しない
function Comment({ body }: { body: string }) {
  // body が "<script>alert(1)</script>" でも、画面には文字列として出るだけ
  return <p>{body}</p>;
}

属性値も同様にエスケープされます。だから「ユーザー入力をそのまま画面に出す」だけならXSSは起きません。にもかかわらずXSSが起きるのは、開発者がこのデフォルトを意図的に外すときだけです。その出口を一覧にします。

抜け道何が起きるか主なXSSクラス
dangerouslySetInnerHTML文字列をHTMLとして解釈して挿入する格納型 / 反射型
href / srcjavascript:クリック・読み込みでスクリプトが走りうる反射型 / DOM
ref 経由の el.innerHTML = ...Reactの外でDOMを直接操作するDOM
サードパーティHTMLの埋め込みCMS・広告・メール本文を「信頼」してしまう格納型

共通するのは、いずれも「文字列を、HTMLやコードとして実行される文脈に流し込む」点です。逆に言えば、ここさえ押さえれば守りの大半は決まります。


2. XSSの三分類とNext.jsでの現れ方

XSSは「攻撃コードがどこを経由するか」で3つに分かれます。Next.jsでは、サーバー(RSC / SSR)とクライアント(ハイドレーション後)のどちらで起きるかが対策に直結します。

種類経路Next.jsでの典型
反射型(Reflected)リクエスト値が即座に応答へ反射searchParamsdangerouslySetInnerHTML でSSR描画
格納型(Stored)DB等に保存された汚染HTMLが後で描画投稿本文・プロフィールHTMLを保存→表示
DOM-XSSsource→sinkがすべてブラウザ内で完結location.hashinnerHTML に書き戻す

ここで重要なのがDOM-XSSです。反射型・格納型はサーバーを通るため、サーバーログやWAFに痕跡が残ることがあります。一方DOM-XSSは、攻撃の起点がURLフラグメント(#...)のようにサーバーへ送信されない値であることが多く、サーバーからは一切見えません。Reactのデフォルトエスケープも、innerHTML のようなブラウザの生DOM APIを直接叩く以上、効きません。「サーバー側のサニタイズで全部守る」という発想がDOM-XSSに通用しないのはこのためです。


3. 抜け道①:dangerouslySetInnerHTML

名前が警告そのものです。これは「innerHTML を React 経由で設定する」APIで、渡した文字列はHTMLとして解釈されます。問題は、その文字列の出所が信頼できるかどうかを、APIは一切確かめないことです。

// 脆弱:APIから受け取ったHTMLをそのまま挿入(格納型XSS)
function Article({ id }: { id: string }) {
  const [html, setHtml] = useState("");
  useEffect(() => {
    fetch(`/api/articles/${id}`)
      .then((r) => r.json())
      .then((d) => setHtml(d.bodyHtml)); // ← DB由来の汚染HTML
  }, [id]);

  // bodyHtml に <img src=x onerror=...> が混ざっていれば実行される
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

<script> タグは挿入後に実行されないブラウザ仕様がありますが、onerror などのイベントハンドラ属性は実行されます。つまり「スクリプトタグを弾けばいい」では塞げません。

判断基準はただ1つ——「このHTMLは、攻撃者が1ビットも関与できない出所か?」 です。サーバーで自分が組み立てたJSON-LDのような完全に信頼できる出力なら許容されます(このサイトでも構造化データの埋め込みにのみ使っています)。しかし、リクエスト・DB・外部APIに由来する値が混ざるなら、出力時に必ずサニタイズします(設定の詳細は第7節)。

// 修正:出力の直前にサニタイズする(DOMPurifyの設定は第7節で詳述)
import DOMPurify from "isomorphic-dompurify";

function Article({ html }: { html: string }) {
  const clean = DOMPurify.sanitize(html);
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

4. 抜け道②:href / src の javascript: スキーム

JSXは属性値をエスケープしますが、HTMLエスケープはURLスキームを無害化しません。 javascript: で始まるURLは、文法上は正しいURLだからです。動的に組み立てた値を hrefsrc に渡すと、クリックや読み込みでスクリプトが走りうる経路になります。

// 脆弱:ユーザー入力のURLをそのまま href に渡す
function ProfileLink({ website }: { website: string }) {
  // website が "javascript:/* 任意コード */" だと、クリックで実行されうる
  return <a href={website}>サイト</a>;
}

React 16.9以降、javascript: URLには開発時に警告が出ますが、警告は防御ではありません。 値を信頼するか否かは自分で決める必要があります。対策は「許可するスキームのallowlist」です。ここで正規表現を自作してはいけません。java\tscript: のようにタブや改行を挟む難読化があり、手書きの正規表現はすり抜けられます。標準の URL パーサはこうした制御文字を仕様に従って除去してから解釈するため、スキーム判定が堅牢になります。

// lib/safe-href.ts — 許可スキームだけ通す。URL APIで難読化に強く判定する
const SAFE_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"]);

export function safeHref(raw: string): string {
  try {
    // 相対URLも解決できるよう base を渡す
    const protocol = new URL(raw, "https://example.com").protocol;
    return SAFE_PROTOCOLS.has(protocol) ? raw : "#";
  } catch {
    return "#"; // パースできない値は通さない
  }
}
// 修正:スキームを検証してから渡す
import { safeHref } from "@/lib/safe-href";

function ProfileLink({ website }: { website: string }) {
  return <a href={safeHref(website)} rel="noopener noreferrer">サイト</a>;
}

なお、スキーム検証は javascript:data: を止めますが、リンクが「どこへ飛ぶか」(外部の悪性サイトへの誘導)は別問題です。戻り先URLを信頼することの危うさは、地続きのオープンリダイレクトとしてNext.jsのオープンリダイレクト対策で扱っています。


5. 抜け道③:ref経由のDOM操作とサードパーティHTML

Reactの安全性は「Reactを通してDOMを更新する」前提で成り立ちます。ref で生のDOM要素を取り出し、innerHTML を直接代入すると、その前提を自分で破ることになります。

// 脆弱:ref で取った要素に innerHTML を直接代入(Reactのエスケープを迂回)
function Banner({ markup }: { markup: string }) {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    if (ref.current) ref.current.innerHTML = markup; // 危険シンク
  }, [markup]);
  return <div ref={ref} />;
}

修正の原則は「生DOM操作をやめ、Reactの描画に戻す」ことです。表示したいのがテキストなら textContent を使えばHTMLとして解釈されません。HTMLが必要なら、第7節のサニタイズを通します。

// 修正:テキストなら state 経由で描画(自動エスケープに戻る)
function Banner({ text }: { text: string }) {
  return <div>{text}</div>;
}

サードパーティHTMLも同じ穴です。CMSのリッチテキスト、広告タグ、メール本文のプレビュー——これらを「外部のものだから整形済みだろう」と信頼して dangerouslySetInnerHTML に流すのは危険です。複数ユーザーが編集できるCMSは、その時点で「信頼できる出所」ではありません。「信頼できるか」は技術ではなく、誰がそのデータを書けるかという設計上の判断であり、ここがツールに任せきれない領域です。


6. DOM-XSS:sourceからsinkへの流れを断つ

DOM-XSSは、クライアント側で「攻撃者が操作できる値(source)が、検証されないまま危険な処理(sink)に届く」ときに起きます。第2節のとおりサーバーを通らないため、独立して理解する価値があります。代表的なsource/sinkを並べます。

汚染source(クライアント)危険sink結果
location.hash / location.searchelement.innerHTMLDOM-XSS
URLSearchParams.get()document.write()DOM-XSS
document.referrereval() / new Function()任意コード実行
postMessageevent.datalocation.href = ...誘導 / XSS
localStorage の値setTimeout(文字列)任意コード実行

ありがちなのが、URLハッシュの値を画面に書き戻すパターンです。

// 脆弱:URLハッシュの値をそのままHTMLに書き戻す(DOM-XSS)
useEffect(() => {
  const tab = decodeURIComponent(location.hash.slice(1)); // ← 汚染source
  document.querySelector("#title")!.innerHTML = `タブ: ${tab}`; // ← 危険sink
}, []);

location.hash# 以降)はサーバーへ送信されません。だからSSRのHTMLにもサーバーログにも現れず、WAFも検知できません。修正は「DOMへ直接書かず、Reactのstate経由で描画する」——これだけで自動エスケープの傘の下に戻れます。

// 修正:DOMに直接書かず state 経由で描画する
const [tab, setTab] = useState("");
useEffect(() => {
  setTab(decodeURIComponent(location.hash.slice(1)));
}, []);
return <p id="title">タブ: {tab}</p>;

eval / new Function / 文字列を渡す setTimeout は、そもそも使わないのが最善です。設定値を「コードとして」評価する設計をやめ、データとして扱えば、このクラスのsinkは消えます。


7. 出力時サニタイズ:DOMPurifyと「そもそも入れない」設計

どうしても信頼できないHTMLを描画する必要があるなら、DOMPurify で無害化します。Next.jsはサーバーとクライアントの両方で描画されるため、isomorphic-dompurify(サーバーでは内部的にDOM実装を使う)を選ぶと両環境で動きます。

// components/rich-text.tsx — 出力の直前にサニタイズし、許可を「狭く」明示する
import DOMPurify from "isomorphic-dompurify";

export function RichText({ html }: { html: string }) {
  const clean = DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ["p", "br", "b", "i", "em", "strong", "ul", "ol", "li", "a"],
    ALLOWED_ATTR: ["href"], // 属性は最小限に
  });
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

設計上、外さないでほしい原則が3つあります。

  1. 入力時ではなく出力時にサニタイズする。 同じデータが複数の文脈(HTML本文・属性・URL)で再利用されることがあり、サニタイザの改善は過去の保存データには遡及せず、入力フィルタは別経路の保存で簡単に迂回されます。「描画する場所」でかけるのが最も確実です。
  2. allowlistは狭く、明示する。 「危険なものを除く(denylist)」ではなく「許すものだけ通す(allowlist)」。新しい攻撃ベクタが見つかっても、許可を絞っていれば影響を受けにくくなります。
  3. 最強の対策は「生HTMLを文字列で持たないこと」。 ユーザー投稿のリッチテキストなら、HTML文字列ではなくMarkdownや構造化ブロックとして保存し、安全なレンダラで描画すれば、サニタイズすべきHTML文字列がそもそも存在しません。たとえばこのブログは react-markdown で描画していますが、デフォルトでは生HTMLを解釈しませんrehype-raw を足した瞬間に、その安全性は失われます。

正直なスコープ。 DOMPurifyは強力ですが、「魔法の安全装置」ではありません。allowlistが描画文脈に合っていなければ穴は残りますし、SVGMathML を許可すれば検討すべき表面が増えます。何を許可するかの判断は、あなたのプロダクトの要件に依存する設計判断であり、ライブラリは肩代わりできません。


8. Trusted TypesとCSP(nonce):多層防御の「最後の壁」

ここまでは「穴を開けない/無害化する」一次防御でした。次は、万一それをすり抜けても被害を抑える多層防御です。一次防御が完璧である保証はないという前提に立つのが要点です。

Trusted Types:危険シンクを型で縛る

Trusted Typesは、innerHTML のような危険シンクへの素の文字列代入を実行時に禁止するブラウザの仕組みです。CSPで有効化すると、シンクには登録済みポリシーが生成した TrustedHTML しか渡せなくなり、監査すべき箇所が「数個のポリシー」に集約されます。

// CSP: require-trusted-types-for 'script'; trusted-types app-html;
// 有効化すると innerHTML 等への素の文字列代入が TypeError になる
import DOMPurify from "isomorphic-dompurify";

// 型は @types/trusted-types を導入して補う(any を使わない)
if (typeof window !== "undefined" && "trustedTypes" in window) {
  window.trustedTypes!.createPolicy("app-html", {
    createHTML: (input: string) => DOMPurify.sanitize(input),
  });
}

require-trusted-types-for 'script' の指定は MDN: Content-Security-Policy が一次情報です。ただしブラウザ対応は均一ではありません(Chromium系で先行、他は限定的)。だからこれは「最後の壁」であって、対応ブラウザだけを守る前提にはできません。多層防御の1枚として積む位置づけです。

CSP(nonce):注入されても実行させない

CSP(Content Security Policy)は、ページが読み込めるスクリプトの出所を制限します。'unsafe-inline' を避け、リクエストごとにnonceを発行して script-src 'self' 'nonce-...' 'strict-dynamic' とすれば、万一インラインスクリプトを注入されても実行されません。

// CSP の考え方(nonce発行とミドルウェア実装の詳細は別記事)
const csp = [
  "default-src 'self'",
  `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
  "object-src 'none'",
  "base-uri 'none'",
].join("; ");

ここで強調すべき正直さがあります。CSPはXSSの「注入」を防ぐものではなく、注入された後の「実行・悪用」を制限するものです。 そして 'unsafe-inline' を付けたり、script-src をワイルドカードで広げたりした緩いCSPは、油断を生むだけで防御になりません。 nonce発行とミドルウェアでの厳格な実装はNext.jsのセキュリティヘッダーとCSP(nonce)に切り出しています。


9. 検出:汚染入力からDOMシンクへの流れを追う

設計で塞ぐと決めたら、「塞げているか」を検証します。XSSの多くは構造が共通なので、機械的に検出できる部分があります。

  • すべての dangerouslySetInnerHTML を洗い出す — 出力がサニタイズを通っているかをレビュー対象に固定する。
  • 汚染source→危険sinkのデータフローを追う(taint解析)location.* / searchParams / リクエスト由来の値が、innerHTML / document.write / eval に検証なしで到達していないか。
  • href / src の動的値にスキーム検証が入っているか。
  • eval / new Function / 文字列 setTimeout の使用箇所。

これらは正規表現では正確に書けません。「値がどこから来てどこへ流れるか」を追う必要があるからです。私が公開しているOSS Aegis はこのデータフロー検出を実装しており、インストール不要で走ります。

# インストール不要・設定不要でスキャン(汚染入力→DOMシンクを可視化)
npx @aegiskit/cli scan

手動の検証手順としては、OWASPの Web Security Testing Guide がXSS(反射型・格納型・DOM)のテスト観点を体系化しています。スキャンの「疑い」を、実際に自分のアプリで再現して確定させるのが王道です。

正直なスコープ。 検出できるのは「危険シンクに汚染入力が届いている」「サニタイズを通っていない」というまでです。「そのHTMLの出所は本当に信頼できるのか」「サニタイザのallowlistはこの描画文脈に合っているか」「出力エンコーディングは文脈(HTML本文/属性/URL/JS)に正しく対応しているか」——これら安全な出力設計の妥当性は、ツールには判定できません。データフロー解析も関数内が基本で、モジュールを跨ぐ流れは見逃します。検出は人間のレビューを置き換えるのではなく、補完するものです。


10. 本番前チェックリスト

外注でもAI製でも、本番投入の前に最低限これだけは確認してください。

  • dangerouslySetInnerHTML の各箇所で、HTMLの出所が信頼できるか、または出力時サニタイズを通している
  • サニタイザはallowlist方式(許可するタグ・属性を明示)になっている
  • href / src の動的値は、URL APIでスキームを検証している(正規表現自作に頼らない)
  • ref 経由の innerHTML 代入や document.write を使っていない(テキストは textContent / state へ)
  • location.hash / searchParams 等の汚染sourceがDOMシンクに素通りしていない
  • eval / new Function / 文字列を渡す setTimeout を使っていない
  • ユーザー投稿は、生HTMLではなく Markdown / 構造化データとして保持している(rehype-raw 等で生HTMLを再有効化していない)
  • CSP(nonce) を付与し、'unsafe-inline' やワイルドカードで緩めていない
  • 余力があれば Trusted Types を有効化し、危険シンクをポリシー経由に絞っている
  • taint/DOMシンク検出をCIに常設している(npx @aegiskit/cli scan

発注者の視点で効くのは、**「dangerouslySetInnerHTML は何箇所ありますか?」「そのHTMLの出所は誰が書けますか?」「URLのスキームはどこで検証していますか?」**の3問です。良い開発者は即答できます。


11. どこまで自分で、どこから監査か

最後に、正直に線を引きます。

サニタイズ忘れ・汚染→シンクの流れ・javascript: スキームの素通りは、静的解析で機械的に検出できます。 ここはCIに番をさせるべき領域で、人間が毎回目視する必要はありません。まずは Aegis(無料OSS、npx @aegiskit/cli scan)で現状を可視化するのが、最もコスパの良い第一歩です。

一方、「そのHTMLは信頼できるのか」「allowlistはこの文脈に合っているか」という安全な出力設計の妥当性は、人間の判断に残ります。 ここでツールが「安全だ」と言い切る製品は、むしろ危険です。Aegisは危険な出力経路を検出・警告しますが、出力設計が正しいことを証明はしません。完全に安全にする魔法はありません。 だからこそ最初の可視化(無料OSS、npx @aegiskit/cli scan)で穴の在処を掴むことから始めるのが、最もコスパの良い第一歩です。

だからこそ、線引きが要ります。どこまで自分で固め、どこから専門家のレビューが要るか——既存アプリのXSS・出力安全性のレビューや、CSP・Trusted Typesを含む多層防御の設計が必要なら、セキュリティ監査で承ります。私自身、木材流通業界のDX案件で、信頼境界をまたぐデータの出力安全性を実運用で設計・検証してきました。


よくある質問(FAQ)

Q. Reactを使っていればXSSは起きませんか? A. 素直に書いている限り、Reactの自動エスケープが大半を防ぎます。ただし dangerouslySetInnerHTMLhrefjavascript:ref 経由の innerHTML、DOM-XSSは、その防御の「外側」です。フレームワークの安全性は、安全弁を自分で外さない限りで成り立ちます。

Q. dangerouslySetInnerHTML は使ってはいけませんか? A. 禁止ではありません。出所が完全に信頼できるHTML(サーバーで自分が組み立てたJSON-LD等)に限れば適切な用途です。リクエスト・DB・外部由来の値が混ざる場合は、出力時にDOMPurifyでサニタイズしてください。

Q. 入力時にサニタイズしておけば十分ですか? A. 不十分です。同じデータが別の文脈で再利用されたり、サニタイザの改善が過去データに遡及しなかったり、別経路の保存で入力フィルタを迂回されたりします。サニタイズは描画する出力時にかけるのが最も確実です。

Q. CSPを入れればXSSは防げますか? A. CSPは「注入を防ぐ」ものではなく「注入された後の実行・悪用を制限する」多層防御です。しかも 'unsafe-inline' などで緩めると効果を失います。一次防御(サニタイズ・スキーム検証)と組み合わせる「最後の壁」と考えてください。

Q. DOM-XSSはサーバー側の対策で防げますか? A. 防げません。location.hash のようにサーバーへ送られない値が起点になることが多く、SSRのサニタイズもWAFも届きません。クライアントで汚染sourceを危険sinkに渡さない(state経由・textContent)設計が必要です。


まとめ:守りの境界は「エスケープの有無」から「出力設計の正しさ」へ

要点を整理します。

  • Reactは JSXの式展開を自動でHTMLエスケープするためXSSに強い。穴は攻撃者ではなく開発者が開ける——dangerouslySetInnerHTMLjavascript: スキーム、ref 経由の innerHTML、サードパーティHTMLの4経路。
  • DOM-XSSはサーバーを介さずブラウザ内で完結し、サーバーログにもWAFにも映りにくい。汚染source→危険sinkの流れを、state経由・textContentで断つ。
  • 信頼できないHTMLは出力時にDOMPurifyでallowlistサニタイズ。最も堅いのは生HTML文字列を持たない(Markdown・構造化データ)設計。
  • Trusted Types / CSP(nonce) は、混入しても被害を抑える多層防御。緩いCSPは油断を生むだけで防御にならない。
  • サニタイズ忘れや汚染→シンクの流れは機械的に検出できる。だが安全な出力設計の妥当性は、設計と人間のレビューでしか守れない

AIで速く作ること自体は正しい。速く作ったものを、漏らさず安全に固める——その仕組みづくりや、既存のNext.js / Reactアプリの出力安全性レビューが必要であれば、お気軽にご相談ください。


参考資料

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事の対策、ツールで自動化できます

Next.js / Supabase のセキュリティ統制を、OSS の Aegis で自動化

この記事の対策の多くは、ミドルウェア1枚と静的解析で機械的に検出・強化できます。無料・MIT の Aegis なら、いまのプロジェクトを1コマンドからスキャンできます。設計が要る「縦のリスク」は監査でも承ります。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。