はじめに
最近、ReactやVueをまたぎながら、フロントエンド開発を中心に業務しております。
私はこれまでセキュリティについて全体像は頭では理解しているものの、フロントエンド開発の文脈で体系的に学んだことはありませんでした。
そのため、初心者向けのフロントエンド特化のセキュリティ本を読んでみたところ、こんな記述がありました。
- XSSにはエスケープ処理が必須
- ライブラリやフレームワークを使う方が賢明
- ブラウザの機能も活用する
ReactやVueなどのモダンフレームワークは安全なデフォルトを備え、開発者が特別意識しなくても一定の防御が効くと言われます。
私がエンジニアになった頃にはすでにこうしたフレームワークが当たり前に存在していたため、その仕組みや背景を深く考えたことがありませんでした。
そこで今回は、特にReactを題材に「エスケープ処理はどのように働き、どこまで守ってくれるのか?」を実際に検証してみます。
旧来のDOM操作との違い
歴史を振り返ります
かつてのフロントエンド開発では、jQueryや生のJavaScriptでinnerHTML
を使い、HTML文字列を直接DOMに挿入することが一般的でした。
この場合、imgタグがHTMLとして解釈されますが、"src=x"は存在しないので、onerror属性を解釈し、意図しないスクリプトが実行されてしまいます
<div id=app>入力内容を表示します</div>
const app = document.getElementById('app');
const userInput = '<img src=x onerror="alert(\'👿悪意のあるスクリプト👿!\')">';
app.innerHTML = userInput; // HTMLパーサが動き、スクリプト実行の危険
なので、開発者は入力文字を検証し、文字列に変更するエスケープ処理を必要があります。
、<img src="x" onerror="alert('👿悪意のあるスクリプト👿!')" />
ただ、開発者がエスケープ処理を忘れれば、悪意のあるスクリプトが実装されるリスクしかないです。
Reactのデフォルト挙動
実際に簡単なフォームを作って検証してみました。
フォームに入力した値を表示するシンプルなアプリです。
安全な表示
ReactではJSX内で値を埋め込むと、自動的にHTMLがエスケープされます。
const userInput = '<img src="x" onerror="alert('XSS via img')" />';
<div>{userInput}</div>
// <script>タグは実行されず、そのまま表示される
ログイン処理結果という部分を見ると、
"ようこそ、<img src="x" onerror="alert('XSS via img')" />さん!"
となり、imgタグが文字列として解釈されていることがわかります。
危険な表示
ReactにもHTMLを直接挿入する手段は存在します。それがdangerouslySetInnerHTMLです。
<div dangerouslySetInnerHTML={{ __html: userInput }} />
これを使うと、意図しないスクリプトが実行されます。
imgタグとして読み込まれましたが、ソースがなく表示できないのでファイルが壊れたアイコンが表示されています。
結果、スクリプトが実行されました。
↓ちなみにQiita上でもHTMLとして解析されてます。(エラーはQiita側で対策されて発生してません)
仕組みの深堀り
デフォルトでエスケープされるのはわかりました。でもどういう仕組で?
エスケープ処理の正体
ReactはinnerHTMLを直接操作せず、 document.createTextNode() というブラウザに存在するAPIを使ってテキストノードとしてDOMに追加します。
Reactが 「エスケープしている」 と言われる理由は、値をHTML文字列として扱わず、テキストノードとしてDOMに追加しているからです。
つまり、React自身が「エスケープ処理」をしているのではなく、 常に安全なDOM API(createTextNode)を使っているので、結果的にエスケープされているということです。
内部で以下のような流れが行われています。
- JSXの波括弧{}内の値を文字列化
- document.createTextNode(value)でテキストノード生成
- 親要素にappendChild()で追加
- HTMLに文字列表記される
この経路ではHTMLパーサが介在しないため、タグはタグとして解釈されません。
実際にcreateTextNodeにconsole.logを仕込み、ReactのuseStateを更新すると、内部的に使用されていることが確認されました。
export default function EscapePage() {
const [input, setInput] = useState("");
// createTextNodeの監視
const monitorReact = () => {
console.log("🔍 Reactの内部DOM操作を監視");
console.log("入力:", input);
// 元のAPIを保存
const original = document.createTextNode;
const calls: string[] = [];
// createTextNodeを監視
document.createTextNode = function (data: string) {
console.log("📝 createTextNode呼び出し:", data);
calls.push(data);
return original.call(this, data);
};
// Reactに処理させる
setResult(input);
// 監視終了
setTimeout(() => {
document.createTextNode = original;
}, 100);
};
return (
{/* 入力 */}
<div className="mb-6">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<button
onClick={monitorReact}
>
React処理を監視
</button>
</div>
);
}
SSRでは自前でエスケープ処理をしている
Next.jsなどのSSR(サーバーサイドレンダリング)環境では、createTextNodeのようなブラウザのAPIは存在しないので使えません。
こちらでは逆に、Reactが自前でエスケープ処理を行っています。
SSR後の素のHTMLを見ると、勝手にエスケープ処理されている事がわかります。
検証タブではすべてDOMにパースされてエスケープされている文字列が見えません。
なので、右クリックでページのソースを表示すると、DOM生成前の素のHTMLが見てみましょう。
実際にサーバー側では文字列にエスケープされているのことが確認できます。
dangerouslySetInnerHTMLを使用した場合は、サーバー側でもエスケープされず、そのまま表示されていることがわかります。
コラム:Vueの場合も同様
Reactと同じく、Vueもデフォルトでは値をHTMLとして解釈せず、テキストノードとしてDOMに追加するため、自動的にエスケープされます。
<p>{{ userInput }}</p> <!-- HTMLはそのまま文字列として表示される -->
ただし、Vueには v-html ディレクティブがあり、これを使うとHTMLとして挿入されます。
当然、XSSのリスクが復活するので、こちらも信頼できるコンテンツ以外には使わないのが基本です。
<p v-html="userInput"></p> <!-- 危険! -->
つまり、VueもReactと同じく「安全なデフォルト」ですが、意図的にバイパスする機能を使えば危険になる点は共通しています。
まとめ
Reactのようなモダンフレームワークが「セキュア」と言われる理由は、安全なデフォルトにあります。
値を直接HTMLとして解釈させず、document.createTextNode()のような安全なDOM APIでノードを作ることで、XSSの多くを未然に防いでくれます。
とはいえ、dangerouslySetInnerHTMLを使えば一瞬で昔のJSの危険な世界に逆戻りです。名前の通り危険な場面以外では使わないのが鉄則。
SSR環境ではサーバー側でのエスケープ処理が行われますが、こちらも同様に危険なAPIを使えば無効化されます。
つまり、
- 普段はフレームワークが守ってくれる
- でも危険な抜け道もある
- だから仕組みを理解して正しく使う
これが今回の検証での結論です。
「安全なデフォルト」を信じるのは大事ですが、なぜ安全なのかを知っておくと、いざというとき安心してコードを書けますね☺️