flawopen.com/xss/Javascript
JavaScript XSS:DOM・React・Express を保護
危険な DOM/React の出力先やリンクは JavaScript を実行させる恐れがあります。テキスト API を使い、HTML のサニタイズ、URL 検証、Trusted Types を適用します(CWE-79)。
舞台のキュー出し卓を想像してください。舞台監督が観客のメモをすべて演出指示として扱うと、予期しない場面が追加されることがあります。通常の台詞として表示すれば、メモはテキストのままです。ブラウザーではinnerHTMLとReactのdangerouslySetInnerHTMLがDOMに入力をマークアップとして解析させ、textContentは文字として表示します。通常のテキストをエスケープし、明示的に許可するHTMLだけをサニタイズし、リンクを作る前にURLスキームを検証します。
主要な概念と専門用語
innerHTML vs textContentinnerHTML、outerHTML、insertAdjacentHTMLに代入すると、ブラウザーは文字列を HTML として解析し、中の<img onerror>は生きた要素になります。textContentとcreateTextNodeはテキストだけを挿入し、値のどの部分もマークアップとして解析されません。Template literals in res.send()- Express の
res.send()は文字列をデフォルトでtext/htmlとして送ります。テンプレートリテラルは送信前に${req.query.q}を評価するので、レスポンスにはユーザーがクエリ文字列に入れたものがそのまま入ります。req.query.qは配列やオブジェクトにもなりうるので、型を確認します。 dangerouslySetInnerHTML + DOMPurify- React は JSX 内のテキストを自動でエスケープし、
dangerouslySetInnerHTMLはその明示的な例外です。値はDOMPurify.sanitize()に通します。DOMPurify はブラウザー自身のパーサーで解析し、React が挿入する前にスクリプト、イベントハンドラー、危険な URL を取り除きます。 javascript: URLs in href- JSX は属性値をエスケープしますが、
href="javascript:..."はクリックするとコードを実行する正当な URL です。new URL()で解析し、http:とhttps:だけを許可します。URL パーサーは、単純な前方一致チェックが見逃す先頭の空白やタブを取り除きます。 Trusted Types- CSP ディレクティブ
require-trusted-types-for 'script'を使うと、対応ブラウザーはinnerHTMLなどのシンクに普通の文字列を渡すことを拒否し、自分で定義したポリシー(通常は DOMPurify を呼ぶもの)が作ったTrustedHTMLオブジェクトだけを受け付けます。新しいシンクが気づかれずに紛れ込むことはありません。
フレームワーク対応
- このガイドで説明する弱点の CWE 参照: CWE-79
- OWASP Top 10:2025 — A05:2025 Injection
ステップ・バイ・ステップの攻撃フロー
入力がシンクに届く
検索語、API から読み込んだプロフィール項目、別のユーザーが保存したリンクが文字列として届き、innerHTML、dangerouslySetInnerHTML、href、res.send() に渡されます。
値がマークアップになる
無害なテスト値 <img src=x onerror=alert(1)> なら、innerHTML と res.send() は本物の <img> 要素を生成し、保存された javascript:alert(1) のリンクはクリックできるスクリプトになります。
ブラウザーが実行する
画像の読み込みが失敗して onerror が発火するか、ユーザーがリンクをクリックします。攻撃者が何もしなくても、コードはすぐに実行されます。
スクリプトがサイトとして振る舞う
コードはページのオリジンで実行され、DOM を読み、被害者の cookie や localStorage のトークンでサイトの API を呼び、ユーザーに見える内容を書き換えられます。
ソースコード比較:脆弱 vs 堅牢化
// profile.jsx: 以下の関数はどれも文字列を HTML として解析させてしまう
import express from 'express';
const app = express();
// Express: 送信前にクエリの値が HTML に入ってしまう
app.get('/search', (req, res) => {
res.send(`<h1>Results for ${req.query.q}</h1>`);
});
// innerHTML は name をマークアップとして解析する
export function showGreeting(el, name) {
el.innerHTML = `Hello, ${name}`;
}
// React は受け取った HTML をそのまま挿入する
export function Bio({ html }) {
return <div className="bio" dangerouslySetInnerHTML={{ __html: html }} />;
}
// javascript: URL はリンクのクリックで実行される
export function Website({ url }) {
return <a href={url}>Website</a>;
}
// profile.jsx: テキストはテキストのまま。HTML と URL は先に検証する
import DOMPurify from 'dompurify';
import escapeHtml from 'escape-html';
import express from 'express';
const app = express();
// 文字列だけを受け付け、HTML エスケープする
app.get('/search', (req, res) => {
const q = typeof req.query.q === 'string' ? req.query.q : '';
res.send(`<h1>Results for ${escapeHtml(q)}</h1>`);
});
// textContent はマークアップを決して解析しない
export function showGreeting(el, name) {
el.textContent = `Hello, ${name}`;
}
// 挿入前に DOMPurify がスクリプトとイベントハンドラーを取り除く
export function Bio({ html }) {
return <div className="bio" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />;
}
// http と https の URL だけを残し、それ以外は # にする
function safeHref(url) {
try {
const parsed = new URL(url, window.location.origin);
return ['http:', 'https:'].includes(parsed.protocol) ? parsed.href : '#';
} catch {
return '#';
}
}
export function Website({ url }) {
return <a href={safeHref(url)}>Website</a>;
}
エンジニアリング&システム堅牢化チェックリスト
- ✓CI で
react/no-dangerとeslint-plugin-no-unsanitizedを有効にし、innerHTML、outerHTML、insertAdjacentHTML、document.writeにリテラル以外の値を渡すことを禁止する。 - ✓テキストには
textContentを使い、表示が必要な HTML は挿入する箇所で必ずDOMPurify.sanitize()に通す。 - ✓ユーザーが渡す URL は、
href、src、window.locationに入る前にnew URL()とhttp:/https:の許可リストで検証する。 - ✓Express では自動エスケープするビューエンジンか
escape-htmlでレンダリングし、文字列でないクエリパラメータは拒否する。 - ✓nonce ベースの
script-srcを持ち、'unsafe-inline'を含まず、require-trusted-types-for 'script'を指定した CSP を送る。セッショントークンはlocalStorageではなくHttpOnlycookie に保存する。