こんにちは、かつコーチです。
前回は、ログイン必須ページを守るProtected Routeの実装方法を解説しました。
「誰がアクセスできるか」を制御できるようになったところで、今回は少し違う角度のセキュリティ対策を扱います。
それがXSS(クロスサイトスクリプティング)です。
「Reactは自動でエスケープしてくれるから安全」という話を聞いたことがある人もいるかもしれませんが、実はそれだけでは不十分です。
今回は、ReactにおけるXSSの基本と、実際に危険なコードの書き方・安全な書き方を具体的に見ていきましょう。
XSSとは?
悪意あるスクリプトが実行されてしまう脆弱性
XSS(Cross-Site Scripting)とは、攻撃者が仕込んだ悪意のあるスクリプトが、他のユーザーのブラウザ上で実行されてしまう脆弱性のことです。
たとえば、掲示板の投稿フォームに<script>タグを含む文字列を入力し、それがそのままページに表示されてしまうと、他のユーザーがそのページを開いた瞬間に悪意のあるスクリプトが実行されてしまいます。
Cookieに保存されたログイン情報を盗まれたり、意図しない操作を勝手に実行されたりと、被害は深刻になりがちです。
Reactが標準で守ってくれる範囲
Reactには、JSXの中に埋め込んだ変数を自動的にエスケープしてくれる仕組みが備わっています。
const userInput = "<script>alert('危険')</script>";
function Comment() {
return <p>{userInput}</p>;
}
このコードでは、userInputの中身がそのままHTMLタグとして解釈されることはなく、文字列としてエスケープされて画面に表示されます。
つまり{ }で変数を出力する通常の書き方をしている限り、Reactが自動的にXSSのリスクを下げてくれているということです。
ただし、これはあくまで「基本の書き方を守っていれば」という条件付きです。
次に、その前提が崩れる代表的なケースを見ていきましょう。
つまずきやすいポイント:dangerouslySetInnerHTML
名前からして危険な機能
Reactには、HTMLを直接挿入できるdangerouslySetInnerHTMLというPropsが用意されています。
CMSから取得したHTMLコンテンツを表示したい場合など、どうしても生のHTMLをそのまま描画したい場面で使われることがあります。
名前に「dangerously(危険を承知で)」という言葉が入っている通り、これはReactの自動エスケープを迂回する機能です。
❌ Before:ユーザー入力をそのままdangerouslySetInnerHTMLに渡す
type CommentProps = {
html: string;
};
function Comment({ html }: CommentProps) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
このコードに、ユーザーが投稿したコメント本文をそのまま渡してしまうと、コメント内に<script>タグが含まれていた場合、それがそのまま実行されてしまいます。
私が以前、社内向けの簡易掲示板を作っていたとき、リッチテキストエディタからのHTML出力をそのままdangerouslySetInnerHTMLに渡してしまい、レビューで「これサニタイズしてないですよね」と指摘されてヒヤッとしたことがあります。
自分では「Reactを使っているから大丈夫」と油断していたのが原因でした。
✅ After:サニタイズライブラリを通してから渡す
import DOMPurify from "dompurify";
type CommentProps = {
html: string;
};
function Comment({ html }: CommentProps) {
const sanitizedHtml = DOMPurify.sanitize(html);
return <div dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />;
}
DOMPurifyのようなサニタイズライブラリを通すことで、<script>タグや危険な属性(onerrorなど)が除去された、安全なHTMLだけを描画できます。
「dangerouslySetInnerHTMLを使う=サニタイズとセットで使う」というルールを、チームの共通認識にしておくことをおすすめします。
その他の危険なパターン
URLを直接埋め込むケース
もう一つ見落としがちなのが、リンクのhrefにユーザー入力をそのまま使うケースです。
// 危険な例
type ProfileLinkProps = {
url: string;
};
function ProfileLink({ url }: ProfileLinkProps) {
return <a href={url}>プロフィールを見る</a>;
}
一見安全そうですが、urlにjavascript:alert('危険')のような文字列が入っていた場合、クリックした瞬間にスクリプトが実行されてしまう可能性があります。
これはjavascript:スキームを利用したXSSの一種です。
function isSafeUrl(url: string): boolean {
return /^https?:\/\//.test(url);
}
function ProfileLink({ url }: ProfileLinkProps) {
const href = isSafeUrl(url) ? url : "#";
return <a href={href}>プロフィールを見る</a>;
}
外部からの入力をURLとして使う場合は、httpやhttpsから始まる文字列かどうかを事前にチェックしておくと安心です。
外部から取得したデータへの注意
APIから取得したデータも「安全なデータ」とは限りません。
自社のバックエンドを経由していても、その元データがユーザー投稿であれば、フロント側での対策は必要です。
「バックエンドがサニタイズしているはずだから大丈夫」と考えるのではなく、フロント側でも表示前のデータを信頼しすぎない姿勢が大切です。
応用・一歩先の使い方
Content Security Policy(CSP)との併用
Reactコード側の対策に加えて、ブラウザレベルで防御を固める方法としてCSP(Content Security Policy)があります。
HTTPレスポンスヘッダーで、実行を許可するスクリプトの読み込み元を制限する仕組みです。
Content-Security-Policy: script-src 'self'
たとえ何らかの理由でXSSのコードが埋め込まれてしまっても、CSPが正しく設定されていれば、外部の不正なスクリプトの実行をブロックできる可能性が高まります。
React側の対策とCSPは、どちらか一方ではなく両方を組み合わせる「多層防御」の考え方で捉えるのがおすすめです。
npmパッケージの脆弱性にも目を向ける
自分で書いたコードが安全でも、依存しているライブラリにXSSの脆弱性が見つかることもあります。
npm auditコマンドを定期的に実行し、既知の脆弱性がないかチェックする習慣をつけておくと安心です。
npm audit
セキュリティは「一度対策したら終わり」ではなく、継続的に見直していくものだと意識しておきましょう。
まとめ
この記事のポイント
- XSSは、悪意あるスクリプトが他ユーザーのブラウザで実行されてしまう脆弱性
- Reactは
{ }での出力を自動エスケープしてくれるが、万能ではない dangerouslySetInnerHTMLを使う場合は必ずDOMPurifyなどでサニタイズするhrefなどにユーザー入力を使う場合は、URLの形式チェックも忘れない- CSPの設定や
npm auditによる継続的なチェックも組み合わせる
次に読むべき記事
認証・セキュリティ編はここまでです。
ここからは、Reactアプリの品質を支えるもう一つの柱、「テスト」について学んでいきましょう。
→ 次の記事:Jestの基本:Reactでテストを書く準備