React・本番で気をつけること

Reactの安全な書き方(XSS)

Reactは既定で安全ですが、その安全を自分で外せる場所が3か所あります。実際にXSSを発火させてから、DOMPurifyでの消し方、javascript:URLの扱い、環境変数がビルドに入ってしまう話まで確かめます。

ここからは、公開する前に踏んでおきたい話の章です。まずはセキュリティから。

心配しすぎる必要はありません。Reactは既定で安全だからです。ただし、その安全を自分で外せる場所があります。そこだけ正確に覚えます。

既定で安全、というのはどういうことか

利用者が書いた文字列を、そのまま出してみます。

const userInput = '<img src="x" onerror="alert(1)" />'

<p>{userInput}</p>

画面にはこう出ます。

<img src="x" onerror="alert(1)" />

タグとしてではなく、文字としてそのまま出ました。<img>要素はDOMに作られず、onerrorも動きません。

波かっこ{}で出した値は、Reactが必ず文字列として扱う——これがReactの既定の守りです。jQueryの.html()のような書き方が最初から無いので、いちばん多いXSSはこれで防がれています。

安全を外せる場所その1:dangerouslySetInnerHTML

同じ文字列を、こう渡すとどうなるか。

<div dangerouslySetInnerHTML={{ __html: userInput }} />

今度は<img>が本当に作られ、onerrorが動きます。同じ文字列で確かめると、こうなりました。

書き方<img>ができるかスクリプトが動くか
<p>{userInput}</p>できない動かない
dangerouslySetInnerHTMLできる動く

名前が長くて物々しいのはわざとです。「危険な」と名前に入っていて、__htmlというキーまで書かせるのは、うっかり使えないようにするためです。

消してから渡す(DOMPurify)

HTMLを含むデータをどうしても出したいときは、危ない部分を落としてから渡します。定番はDOMPurifyです。

npm install --save-exact dompurify
import DOMPurify from 'dompurify'

<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userInput) }} />

実際に通してみた結果です。

入れたもの出てきたもの
<img src="x" onerror="…"><img src="x">onerrorだけ消えた
<p onclick="…">こんにちは<script>…</script><b>太字</b></p><p>こんにちは<b>太字</b></p>

<b>のような普通のタグは残り、スクリプトになりうるものだけが落ちます。「タグを全部消す」のではなく「危ないものだけ消す」——これが欲しかった動きです。

安全を外せる場所その2:URL

hrefsrcに、外から来た値をそのまま入れる場合です。

<a href={bird.website}>公式サイト</a>

bird.websitejavascript:で始まっていたら——という攻撃があります。これについては、React 19が止めてくれます。実際に試すと、hrefの中身がこう書きかえられていました。

javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')

コンソールにも同じエラーが出ます。

Error: React has blocked a javascript: URL as a security precaution.

ただし止まるのはjavascript:だけです。次のものは自分で確認します。

  • http://https://以外のURL(data:など)
  • 外部サイトへのリンクにrel="noopener noreferrer"を付ける(target="_blank"のとき)
  • 画像のsrcに外部の値を入れる場合の出どころ
<a href={url} target="_blank" rel="noopener noreferrer">

安全を外せる場所その3:環境変数

これがいちばん事故になります。

VITE_API_KEY=super-secret-1234
API_SECRET=not-exposed-5678
<p>{import.meta.env.VITE_API_KEY}</p>

このままビルドして、出てきたJSファイルを検索してみます。

npm run build
grep -r "super-secret-1234" dist/
dist/assets/index-BNfh9nLk.js

そのまま入っています。VITE_で始まる環境変数は、ビルド時に値が文字列として埋めこまれるためです。VITE_が付いていないAPI_SECRETのほうは、同じ検索で1件も出ませんでした(そもそもブラウザ側からは読めません)。

.envgitに入れないのも忘れずに。.gitignore.envを入れ、.env.exampleキー名だけを置くのが定番です。

そのほか、押さえておくもの

押さえどころ
認可はサーバーの仕事画面でボタンを隠しても、APIを直接叩かれれば通ります。サーバーが必ず確認する
依存パッケージnpm auditで確認。バージョンを固定しておくと、勝手に更新されて悪いコードが入る事故を防げます
エラーの中身を出しすぎない本番の画面にサーバーのエラー全文を出さない(Error Boundaryの回
CSP(Content Security Policy)配信側のヘッダーで、実行してよいスクリプトを制限できる
React画面全体を落とさない(Error BoundaryとSuspense)エラーの見せ方の回。本番でどこまで出すかは、セキュリティの話でもあります

公式ドキュメントも見てみる

ja.react.dev外部サイト<div> などの一般的なコンポーネント – ReactReact公式のdangerouslySetInnerHTML。「信頼できないHTMLを渡すとXSSになる」という注意書きが、公式の言葉で書かれています

このレッスンのまとめ

  1. 波かっこ{}で出した値は文字列として描かれる。ここが既定の守り
  2. dangerouslySetInnerHTMLは本当に動く。同じ文字列でスクリプトが発火するのを確認した
  3. HTMLを出すならDOMPurify.sanitize()を通すonerror<script>だけが落ちる
  4. 消すのは保存時ではなく出すとき(基準はあとで変わるから)
  5. javascript:のURLはReact 19が止める。ただしそれ以外は自分で確認する
  6. VITE_の環境変数はビルド結果に丸見え。秘密の鍵はサーバー側に置く
  7. 画面で隠すのは認可ではない。確認するのはサーバー
  8. 依存はバージョンを固定し、npm auditを時々見る

次は、ログインと、入れないページです。

やってみよう:XSSを1回発火させてみる

自分の手元で、防げている状態と防げていない状態を見比べます。

  1. 適当なページにconst userInput = '<img src="x" onerror="alert(1)" />'を置く
  2. <p>{userInput}</p>で出す→文字としてそのまま出ることを確認
  3. <div dangerouslySetInnerHTML={{ __html: userInput }} />に変える→alertが出ることを確認
  4. npm install --save-exact dompurifyしてDOMPurify.sanitize(userInput)を通す→alertが出なくなることを確認
  5. .envVITE_TEST_KEY=hello-1234を書いて画面に出し、npm run buildのあとgrep -r "hello-1234" dist/
npm run build
grep -r "hello-1234" dist/

5番で自分のキーがビルド結果から見つかるのを一度見ておいてください。これを知らないまま本番に出すのが、いちばん危ない状態です。確認が終わったら.envとテスト用のコードは消しておきます。

よくある質問

ReactならXSSの心配はないのですか?
波かっこで値を出す限りは安全です。Reactが文字列として描くからです。危ないのはその安全を自分で外したときで、dangerouslySetInnerHTML、javascript:で始まるURL、srcやhrefに外部の値をそのまま入れる場面が該当します。
dangerouslySetInnerHTMLは使ってはいけませんか?
使う場面はあります。ブログ本文のようにHTMLを含むデータを出すときです。ただし必ずDOMPurifyのような道具で危ない部分を落としてから渡します。落とさずに渡すと、投稿者が書いたスクリプトがそのまま動きます。
APIキーはどこに置けばいいですか?
フロントエンドには置けません。VITE_で始まる環境変数はビルド結果のJSにそのまま埋めこまれ、誰でも読めます。秘密の鍵が要る通信は、必ずサーバー側を経由させます。
npm auditは毎回見るべきですか?
見て損はありませんが、報告のすべてが本番に影響するわけではありません。開発時にしか使わない依存の警告も混じるので、影響範囲を読んでから対応を決めます。