ここからは、公開する前に踏んでおきたい話の章です。まずはセキュリティから。
心配しすぎる必要はありません。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 dompurifyimport 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
hrefやsrcに、外から来た値をそのまま入れる場合です。
<a href={bird.website}>公式サイト</a>bird.websiteがjavascript:で始まっていたら——という攻撃があります。これについては、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件も出ませんでした(そもそもブラウザ側からは読めません)。
.envをgitに入れないのも忘れずに。.gitignoreに.envを入れ、.env.exampleにキー名だけを置くのが定番です。
そのほか、押さえておくもの
| 話 | 押さえどころ |
|---|---|
| 認可はサーバーの仕事 | 画面でボタンを隠しても、APIを直接叩かれれば通ります。サーバーが必ず確認する |
| 依存パッケージ | npm auditで確認。バージョンを固定しておくと、勝手に更新されて悪いコードが入る事故を防げます |
| エラーの中身を出しすぎない | 本番の画面にサーバーのエラー全文を出さない(Error Boundaryの回) |
| CSP(Content Security Policy) | 配信側のヘッダーで、実行してよいスクリプトを制限できる |
公式ドキュメントも見てみる
ja.react.dev外部サイト<div> などの一般的なコンポーネント – ReactReact公式のdangerouslySetInnerHTML。「信頼できないHTMLを渡すとXSSになる」という注意書きが、公式の言葉で書かれていますこのレッスンのまとめ
- 波かっこ
{}で出した値は文字列として描かれる。ここが既定の守り dangerouslySetInnerHTMLは本当に動く。同じ文字列でスクリプトが発火するのを確認した- HTMLを出すなら
DOMPurify.sanitize()を通す。onerrorや<script>だけが落ちる - 消すのは保存時ではなく出すとき(基準はあとで変わるから)
javascript:のURLはReact 19が止める。ただしそれ以外は自分で確認するVITE_の環境変数はビルド結果に丸見え。秘密の鍵はサーバー側に置く- 画面で隠すのは認可ではない。確認するのはサーバー
- 依存はバージョンを固定し、
npm auditを時々見る
次は、ログインと、入れないページです。