Remix のコンポーネントで一部のHTMLタグを安全に表示したい

## はじめに

通常、コンポーネントでデータを表示する際、HTML タグは自動的にサニタイズされます。しかし、特定の要件により、一部の HTML タグをそのまま有効にしたい場合があるかもしれません。例えば、

- `strong`, `a` タグは HTML としてレンダリングしたい
- 有効なタグ以外はエスケープしたい
- `a` タグの属性は `href` 属性のみを許容したい

といった要件があったとします。このような場合、特定の HTML タグだけをホワイトリスト方式で指定し、他のタグや属性を排除することで、XSS のリスクを最小限に抑えつつ安全に表示する方法が必要です。

本記事では、Remix のコンポーネント内で、指定した HTML タグのみを安全に表示するための方法を検討し、実装する方法を試していきます。

### 対応方法を検討

Remix のコンポーネントで変数などからデータを出力するとサニタイズされます。

```tsx
export const MyComponent = () => {
  const value = '<script>alert("XSS")</script> <strong>strong string.</strong>';
  return <div>{value}</div>;
};
```

これを HTML として表示するには、コンポーネントの [dangerouslySetInnerHTML](https://react.dev/reference/react-dom/components/common#common) props に指定することで HTML としてレンダリングすることができます。
※ props 名からも危険な雰囲気が出てますよね。

```tsx
export const MyComponent = () => {
  const value = '<script>alert("XSS")</script> <strong>strong string.</strong>';
  return <div dangerouslySetInnerHTML={{ __html: value }} />;
};
```

表示させたい HTML が固定であれば大きな問題にはなりませんが、動的なデータ（データベースから取得した値やユーザー入力など）を扱う場合は、なんらかのサニタイズ（無害化）処理が必要です。
サニタイズ処理を自前で実装するのも大変なので、サニタイズするパッケージを探してみたところ、以下の２つが良さそうに思いました。

- [sanitize-html](https://github.com/apostrophecms/sanitize-html)
- [DOMPurify](https://github.com/cure53/DOMPurify)

どちらのパッケージを利用しても要件を実現できそうですが、より要件にマッチしそうな sanitize-html を今回は利用することにしました。
処理の流れは以下のとおりです。

- sanitize-html を利用して
  - `strong`, `a` タグを除いてエスケープする
  - `a` タグの `href` 属性以外はすべて削除する
- サニタイズした文字列を dangerouslySetInnerHTML props を利用して表示する

## 環境セットアップ

今回の対応では、sanitize-html を利用するので、プロジェクトにインストールします。

```zsh
% npm install sanitize-html
```

TypeScript で利用したいので、コミュニティでサポートされている型定義の [@types/sanitize-html
](https://www.npmjs.com/package/@types/sanitize-html) をインストールします。

```zsh
% npm install -D @types/sanitize-html
```

dangerouslySetInnerHTML props の利用については、React に組み込まれている機能なので、特別な設定やセットアップは必要ないです。

### 実行環境

- Remix: `2.11.0`
- sanitize-html: `2.13.0`

## 実装

コンポーネントでサニタイズ処理と表示処理を以下のように実装しました。

```tsx
import sanitize from "sanitize-html";

export const MyComponent = () => {
  const value = `
   <script>alert("XSS")</script>
   <a href="https://www.google.co.jp/" target="_blank">Google へのリンク</a>
   <strong>strong は有効です。</strong>
   <span>span は無効です。</span> 
  `;

  const options: sanitize.IOptions = {
    allowedTags: ["a", "strong"], // 有効にしたい a, strong タグを指定（ホワイトリスト）
    allowedAttributes: {
      // 有効な属性を指定（タグごとに設定することができる）
      a: ["href"], // a タグの href 属性のみ許容する
    },
    disallowedTagsMode: "escape", // 無効なタグをエスケープするように指定
  };
  const sanitizedContent = sanitize(value, options);

  // dangerouslySetInnerHTML props にサニタイズ後の文字列を設定
  // ※ なんらかのタグを利用する必要があります（今回は div）
  return <div dangerouslySetInnerHTML={{ __html: sanitizedContent }} />;
};
```

## HTML を含む文字列を確認

実装したコンポーネントをブラウザで出力して、出力内容を確認してみました。

![Monosnap Image 2024-08-16 07-52-29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/331769/419f5d3a-007e-a2fa-464e-0898abce11b0.png)

`a` タグ、`strong` タグが HTML としてレンダリングされ、その他のタグはエスケープされていることを確認できます。

また、Chrome の Developer Tools で出力されている DOM を確認したところ、許可した属性以外（今回の場合だと `a` タグの `target` 属性）が削除されていることを確認できました。

![Monosnap Image 2024-08-16 07-54-23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/331769/2d09b1da-f69c-f06d-f7e6-45521eb0608c.png)

## sanitize-html のオプション

今回のような要件だった場合、有効なタグの指定(`allowedTags`)や属性の指定(`allowedAttributes`)で十分ですが、もう少し独自の設定でサニタイズすることができます。
その他のオプションについては、公式ページを参照して、必要に応じて確認してみてください。

https://www.npmjs.com/package/sanitize-html

https://github.com/apostrophecms/sanitize-html

## さいごに

今回は `sanitize-html` でサニタイズして、`dangerouslySetInnerHTML` で出力する方法を試してみました。他にも、[html-react-parser](https://github.com/remarkablemark/html-react-parser) や [DOMPurify](https://github.com/cure53/DOMPurify) を利用する方法も検討できそうです。実装方法については、いくつかあるので要件に応じて選択できそうですね。