はじめに
Webアプリを作っていると、避けて通れないのがXSS(クロスサイトスクリプティング)対策です。
正直なところ、「ユーザーが入力したHTMLをそのまま表示したい」という要件が来るたびに胃が痛くなっていたんですよ。リッチテキストエディタの出力とか、Markdownのプレビューとか。エスケープすれば安全だけど、それだと装飾が消えてしまう。かといってそのまま表示するのは論外。
そんな悩みを解決してくれるのがDOMPurifyです。GitHubで16,000スター以上、2014年から継続的に開発されている信頼性の高いライブラリ。セキュリティ研究者のチームが開発しているというのも心強いポイントですね。
DOMPurifyとは
DOMPurifyは、HTMLコンテンツから悪意のあるコードを除去する「サニタイズ」を行うライブラリです。
「DOM-only, super-fast, uber-tolerant XSS sanitizer」がキャッチコピーで、ブラウザのDOMパーサーを活用した高速な処理が特徴。HTML5だけでなく、SVGやMathMLにも対応しています。
現在の最新バージョンはv3.3.1。cure53というセキュリティ研究チームが開発しており、28種類のブラウザで自動テストを実施しているそうです。この辺りの徹底ぶりは、セキュリティライブラリとして信頼できるポイントだと思います。
特徴・メリット
1. とにかく高速
これ、意外と重要なんですけど、DOMPurifyはブラウザ標準のDOMパーサーを活用するので処理が速い。正規表現ベースのサニタイザーと比べると、複雑なHTMLでもストレスなく処理できます。
大量のコンテンツを処理する場面でも、パフォーマンスの心配をしなくていいのは助かる。
2. セキュアなデフォルト設定
何も設定しなくても、危険な要素はしっかり除去してくれます。<script>タグはもちろん、onclickなどのイベントハンドラ属性、javascript:スキームのURLなど、XSSの原因になりそうなものは一通りブロック。
個人的には、この「デフォルトで安全」という設計思想が好きですね。設定を忘れてセキュリティホールを作ってしまうリスクが減る。
3. 柔軟なカスタマイズ
セキュアなデフォルトを持ちながら、必要に応じて許可する要素や属性を細かく制御できます。「このタグだけ許可したい」「この属性は残したい」といった要件にも対応可能。
逆に、デフォルトより厳しくすることもできる。用途に合わせて調整できるのは実用的です。
4. 幅広い環境で動作
モダンブラウザはもちろん、Node.jsでも使えます。Node.jsの場合はjsdomと組み合わせて使う形。SSRやビルド時のサニタイズにも対応できるのは嬉しい。
5. 継続的なメンテナンス
2014年から10年以上、継続的に開発されています。セキュリティライブラリは「放置されていないこと」が重要。新しい攻撃手法が発見されたときに、ちゃんとアップデートされる安心感があります。
インストール方法
npmでインストール
npm install dompurify
TypeScriptを使う場合は型定義も入れておきましょう。
npm install --save-dev @types/dompurify
CDNから読み込む
ビルドツールを使わない場合はCDNも使えます。
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.3.1/purify.min.js"></script>
Node.jsで使う場合
Node.jsではjsdomが必要です。
npm install dompurify jsdom
基本的な使い方
シンプルなサニタイズ
import DOMPurify from 'dompurify';
// 危険なHTMLを無害化
const dirty = '<p>Hello</p><script>alert("XSS!")</script>';
const clean = DOMPurify.sanitize(dirty);
console.log(clean); // <p>Hello</p>
これだけ。<script>タグが綺麗に消えています。
イベントハンドラの除去
const dirty = '<button onclick="alert(\'XSS\')">Click me</button>';
const clean = DOMPurify.sanitize(dirty);
console.log(clean); // <button>Click me</button>
onclick属性も自動で除去。ボタンの見た目は残しつつ、危険なコードだけ消してくれる。
危険なURLスキームの除去
const dirty = '<a href="javascript:alert(\'XSS\')">Click</a>';
const clean = DOMPurify.sanitize(dirty);
console.log(clean); // <a>Click</a>
javascript:スキームのURLも除去されます。
許可するタグを制限
const dirty = '<p><strong>太字</strong>と<em>斜体</em>と<script>悪意</script></p>';
const clean = DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['p', 'strong']
});
console.log(clean); // <p><strong>太字</strong>と斜体と</p>
<em>タグも許可リストにないので除去される。厳密に制御したい場合に便利。
許可する属性を制限
const dirty = '<a href="https://example.com" target="_blank" onclick="alert()">リンク</a>';
const clean = DOMPurify.sanitize(dirty, {
ALLOWED_ATTR: ['href']
});
console.log(clean); // <a href="https://example.com">リンク</a>
targetもonclickも消えて、hrefだけ残る。
HTMLのみを許可(SVG・MathMLを除外)
const clean = DOMPurify.sanitize(dirty, {
USE_PROFILES: { html: true }
});
SVGやMathMLを扱わない場合は、これで攻撃対象を減らせます。
実践的なユースケース
リッチテキストエディタの出力を表示
import DOMPurify from 'dompurify';
interface ArticleProps {
content: string; // WYSIWYGエディタからのHTML
}
function Article({ content }: ArticleProps) {
const sanitizedContent = DOMPurify.sanitize(content, {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'u', 's',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li',
'a', 'img',
'blockquote', 'pre', 'code'
],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'target']
});
return (
<article
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
/>
);
}
dangerouslySetInnerHTMLを使うときは必ずサニタイズ。これ、Reactで生HTMLを表示するときの鉄則ですね。
Markdownプレビュー
import DOMPurify from 'dompurify';
import { marked } from 'marked';
function MarkdownPreview({ markdown }: { markdown: string }) {
// MarkdownをHTMLに変換してからサニタイズ
const html = marked(markdown);
const clean = DOMPurify.sanitize(html);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
Markdownパーサーの出力をそのまま信用しないのがポイント。ユーザーが悪意のあるHTMLをMarkdown内に埋め込む可能性があるので。
コメント機能
import DOMPurify from 'dompurify';
function sanitizeComment(userInput: string): string {
return DOMPurify.sanitize(userInput, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'br'],
ALLOWED_ATTR: ['href'],
ALLOW_DATA_ATTR: false
});
}
function Comment({ content }: { content: string }) {
return (
<div
className="comment"
dangerouslySetInnerHTML={{ __html: sanitizeComment(content) }}
/>
);
}
コメント欄は最小限のタグだけ許可。リンクは許可するけど、data-*属性はブロック。
フックを使った高度なカスタマイズ
import DOMPurify from 'dompurify';
// 外部リンクにrel="noopener"を自動付与
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A') {
const href = node.getAttribute('href');
if (href && href.startsWith('http')) {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
}
});
const dirty = '<a href="https://example.com">外部リンク</a>';
const clean = DOMPurify.sanitize(dirty);
// <a href="https://example.com" target="_blank" rel="noopener noreferrer">外部リンク</a>
サニタイズ後に属性を追加することもできる。外部リンクのセキュリティ対策を自動化できて便利。
Node.jsでの使用
import { JSDOM } from 'jsdom';
import DOMPurify from 'dompurify';
const window = new JSDOM('').window;
const purify = DOMPurify(window);
function sanitizeOnServer(html: string): string {
return purify.sanitize(html);
}
// SSRやAPIでのサニタイズ
const userContent = await getUserContent();
const safeContent = sanitizeOnServer(userContent);
サーバーサイドでもサニタイズできる。DBに保存する前にサニタイズしておくという運用もあり。
注意点
サニタイズのタイミング
個人的には「表示時にサニタイズ」をおすすめします。保存時にサニタイズすると、後からルールを変更したときに過去データが対応できない。
// 推奨: 表示時にサニタイズ
function displayContent(rawContent: string) {
return DOMPurify.sanitize(rawContent);
}
二重エスケープに注意
すでにエスケープされた文字列をサニタイズすると、意図しない結果になることがあります。
const escaped = '<script>';
const clean = DOMPurify.sanitize(escaped);
// <script> のまま(これは正しい動作)
パフォーマンス
大量のHTMLを処理する場合は、Web Workerでの実行も検討してください。メインスレッドをブロックしないように。
まとめ
DOMPurifyを導入して感じた変化:
- 安心感: XSS対策を「ライブラリに任せる」という選択ができる
- 開発速度: 自前でサニタイズロジックを書かなくていい
- 柔軟性: 要件に合わせて許可するタグを調整できる
- 信頼性: セキュリティ専門家が開発・メンテナンスしている
正直なところ、ユーザー入力のHTMLを扱う場面では、DOMPurify一択ですね。自前でXSS対策を書くのは危険すぎる。攻撃手法は日々進化しているので、専門家が継続的にメンテナンスしているライブラリに任せるのが賢明です。
「HTMLをそのまま表示したい」という要件が来ても、もう胃が痛くならない。それだけでDOMPurifyを導入する価値はあると思います。