FlawOpen

flawopen.com/xss/Javascript

● CWE-79 · 高
セキュリティ研究 · FlawOpen

JavaScript XSS:DOM・React・Express を保護

危険な DOM/React の出力先やリンクは JavaScript を実行させる恐れがあります。テキスト API を使い、HTML のサニタイズ、URL 検証、Trusted Types を適用します(CWE-79)。

💡 わかりやすい解説 (ELI5)

舞台のキュー出し卓を想像してください。舞台監督が観客のメモをすべて演出指示として扱うと、予期しない場面が追加されることがあります。通常の台詞として表示すれば、メモはテキストのままです。ブラウザーではinnerHTMLとReactのdangerouslySetInnerHTMLがDOMに入力をマークアップとして解析させ、textContentは文字として表示します。通常のテキストをエスケープし、明示的に許可するHTMLだけをサニタイズし、リンクを作る前にURLスキームを検証します。

主要な概念と専門用語

innerHTML vs textContent
innerHTML、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 オブジェクトだけを受け付けます。新しいシンクが気づかれずに紛れ込むことはありません。

フレームワーク対応

ステップ・バイ・ステップの攻撃フロー

ステップ 1

入力がシンクに届く

検索語、API から読み込んだプロフィール項目、別のユーザーが保存したリンクが文字列として届き、innerHTML、dangerouslySetInnerHTML、href、res.send() に渡されます。

ステップ 2

値がマークアップになる

無害なテスト値 <img src=x onerror=alert(1)> なら、innerHTML と res.send() は本物の <img> 要素を生成し、保存された javascript:alert(1) のリンクはクリックできるスクリプトになります。

ステップ 3

ブラウザーが実行する

画像の読み込みが失敗して onerror が発火するか、ユーザーがリンクをクリックします。攻撃者が何もしなくても、コードはすぐに実行されます。

ステップ 4

スクリプトがサイトとして振る舞う

コードはページのオリジンで実行され、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>;
}

エンジニアリング&システム堅牢化チェックリスト

参考資料