こんにちは。フロントエンドエンジニアの桐澤(@kiririLee)です。PHPのアプリケーションから配信されるプレスリリースがサニタイザーを通るようにしたため、導入するまでに取り組んだことを書きます。
サニタイザーとは
HTMLにスクリプトを埋め込んでユーザーのブラウザ上で実行する方法としてまず思い浮かぶのはscriptタグを使用する方法かと思います。ですがこれ以外にもimgタグのonerror属性を使用する方法などHTMLでスクリプト実行する方法は意外と多く存在しています。
悪意のあるスクリプトや不正な HTML を取り除く処理をサニタイズと言いますが、これを行うサニタイザーを通すことによって安全性を高めることがあります。
PR TIMESのエディターはHTMLを生成しており、企業ユーザーが編集したプレスリリースはHTMLで表現されて、それがプレスリリースページに表示されます。その仕組み上、悪意のあるHTMLが存在していた場合に、何も対策をしていないとプレスリリースページでHTMLを表示するときにユーザーのブラウザ上でスクリプトを実行する攻撃が可能になってしまいます。そのため今回はサニタイザーを導入しました。
技術選定
一般的にHTMLサニタイズを行うためのライブラリやツールは、JavaScriptやNode.jsのエコシステムでより広く利用されています。今回はカスタマイズが容易で、継続的にメンテナンスが行われており、ライブラリの作者がWebセキュリティの研究者であるという点からDOMPurifyを選択しました。
サニタイズを行う流れ
今回選択したDOMPurifyはNode.jsのライブラリですが、PR TIMESのバックエンドはPHPで実装されています。そのため、既存のPHPサーバー上でサニタイザーを動かすためには以下の2パターンが考えられました。
- 既存のPHPサーバーにNode.jsの環境を作り、PHPのアプリケーション上から外部コマンドを実行するexecを使ってサニタイザーを実行する
- Node.jsの環境をPHPサーバーとは別に作り、サニタイズの機能をAPIにより提供する
今回はPHPのアプリケーションに与えるパフォーマンスへの懸念を考慮し、後者の方法を選択しました。プレスリリースに含まれるコンテンツの量にもよりますが、execによりサニタイズが完了するまでに平均して大体1秒くらいかかっており、コンテンツ量が多いものだと3秒近くかかってしまうものもあったためPHPのアプリケーションへの影響を懸念していました。
また、事前にBigQueryに保存されているアクセスログを見てサニタイザーが実行される回数を調査し料金の見積もりをした結果、サニタイザーが動く環境をAWS Lambdaで作成することにしました。AWS ECSも候補に上がりましたが、前述したアクセスログによる料金の見積もりとサーバー管理をしなくていよいという点、そしてアプリケーションが単体で完結しているという点からAWS Lambdaを選択しました。
サニタイズの流れとしては、以下の図のように配信されるプレスリリースの内容がDBに保存される前にAWS Lambda上で動くサニタイザーにリクエストを行い、レスポンスとして返ってきたサニタイズ済みのHTMLがDBに保存されるようにしました。

既存のプレスリリースの調査
作成したサニタイザーの機能は後述しますが、基本的にやることはDOMPurifyのAllowListを作成して、エディター上で入力されたHTMLだけになるようにフィルタすることです。
AllowListにないHTMLタグ・属性はサニタイザーにより消されてしまうため、サニタイザーを通すことで既存のプレスリリースが壊れてしまう恐れがありました。そのため、既存のプレスリリースで使用されているHTMLタグ・属性を調査し、AllowListに追加する必要がありました。
調査方法
本番環境のリードレプリカから実際に配信されたプレスリリースを取得して調査を行いました。
調査の流れは以下の通りです。
- 実際に配信されたプレスリリースをデーターベースから取得する
- 取得したプレスリリース本文をサニタイザーに通して、サニタイズ前とサニタイズ後の文字列を比較する
- 比較によって消された要素があった場合にコンソールに出力する
- 消された要素がタグ・属性であった場合はAllowedListに登録する
- 2 から 4を繰り返してサニタイザーによって消される要素がないように調整する
PR TIMESでは一ヶ月あたり約3万件のプレスリリースが配信されています(2023年8月時点)。お客様のプレスリリースを壊さないために過去に配信されたプレスリリースを全て調査するようにしたいと思いましたが、一件ずつ上記のステップを手でおこなっていては途方もない作業量で終わらないため、一部を自動化する調査用のアプリケーションを作成しました。結果的に2005年から2023年の2月までに配信されたプレスリリースを全件調査することができました。
調査用に作ったアプリケーション
Node.jsのWorkerを使用することで一回でプレスリリースを数万件ずつ調査できるようにするアプリケーションを作成し、膨大な調査量を完了できるようにしました。
処理内容
調査用のアプリケーションはプレスリリースの内容が入った配列をfor文で回して一件ずつサニタイザーに通していきます。この時、サニタイズ前とサニタイズ後の文字列を比較して差分をログに出力します。
処理のイメージは以下の通りです。文字列の比較にはkpdecker/jsdiffを使用しました。
pressReleaseList.forEach((pr) => {
// サニタイザーにプレスリリース本文を通す
const { sanitized, removedElements, removedAttributes } = sanitizeHtml(
pr.body
);
// サニタイズ前後の文字列を比較する
const diffList = diffChars(pr.body, sanitized);
for (const diff of diffList) {
const { removed, value } = diff;
// 消されて問題ない要素は無視する
if ((removed && value === "\\r")) continue;
// 差分がない場合はcontinue
if (!diff.added && !diff.removed) continue;
// 消された要素をログに出力
console.log(diff, pr.releaseId, pr.companyId);
}
// 消されたタグ・属性をログに出力
if (removedElements.length > 0 || removedAttributes.length > 0) {
console.log({removedElements, removedAttributes})
}
});処理内容としてはシンプルですが、プレスリリースの件数が増えてくるとサニタイズ処理と文字列の比較処理に時間がかかり、最終的にメモリ不足で処理が中断されてしまう問題がありました。
Node.jsのWorker
Node.jsは worker_threads という標準パッケージを提供しており、このパッケージを使うことで一つのアプリケーションから複数のスレッドを立ち上げることができます。これによってマシンのCPUを有効活用しアプリケーションのパフォーマンスを上げることができます。
処理に時間がかかりメモリ不足で処理が中断されてしまう問題はこのパッケージを使用することで解決しました。
Worker導入後
調査用のアプリケーションを動かしていたマシンの論理CPUは16コアであったため、16個スレッドを立ち上げて(調整して最終的には20個立ち上げました)1スレッドあたりにかかる負荷を軽減させました。Woker導入前はスレッドが1つで負荷がかかると落ちてしまていましたが、導入後はスレッド一つにかかっていた負荷が16個に分散されて落ちなくなりました。これによって1回の実行でプレスリリースを1万件調査できるようにました。
実装のイメージは以下の通りです。
// 調査するプレスリリースを20分割する
for (let i = 0; i < 20; i++) {
const splited = rawPressReleaseList.splice(0, rawPressReleaseList / 20);
taskList.push(splited);
}
// workerを20個立ち上げる
for (let i = 0; i < 20; i++) {
workerList.push(
new Worker(resolve(__dirname, "./worker.js"), {
workerData: `worker${i}`,
})
);
}
// 立ち上げたスレッドに分割したプレスリリースを割り当てる
for (let i = 0; i < workerList.length; i++) {
workerList[i].postMessage(taskList[i]);
promseList.push(new Promise((r) => workerList[i].on("exit", r)));
}rawPressReleaseList / 20とすることでWorker一つあたりで調査するプレスリリースの数を決めます。1万件を一回で調査したい場合は、10000 / 20 となり1つあたりのWorkerに500件ずつ割り当てることができます。postMessageで立ち上げたスレッドに分割したプレスリリースを渡します。worker.js側ではworker.parentPort でpostされたプレスリリースを受け取り、サニタイズとサニタイズ前後の文字列の比較処理を行います。
作成したサニタイザーの主な機能
作成したサニタイザーの主な機能を紹介します。
AllowListによるHTMLタグと属性の制限
使えるタグ・属性が増えると攻撃の手段も増えるため、AllowListにより必要最低限に絞るようにしました。AllowListはDOMPurifyのALLOWED_TAGS、ALLOWED_ATTRオプションを使用して実装しました。以下のようにタグと属性をそれぞれのオプションに配列で指定することで、指定されたタグ・属性のみを許可することでき、それ以外のタグ・属性は削除することができます。前述した調査により見つかった過去プレスリリースで使用されているタグ・属性はこの配列に追加して行きました。
// READMEから抜粋
// allow only <b> and <q> with style attributes
const clean = DOMPurify.sanitize(dirty, {ALLOWED_TAGS: ['b', 'q'], ALLOWED_ATTR: ['style']});a.hrefのスキームの制限
PR TIMESのエディターでは文中の文字をリンクにできる機能があり、aタグを使用しています。そのため、AllowListにaタグとhref属性を追加する必要がありましたが、hrefにjavascriptスキームを使用することで可能になる攻撃あるため、a.hrefに指定できるスキームを限定する必要がありました。
このようなAllowListに追加されているが属性の値を限定したいという場合には、DOMPurifyのafterSanitizeAttributesを使いました。DOMPurifyにはhooksという仕組みがありサニタイズの仕組みを拡張することができ、今回は以下のように、href属性の値を見てjavascriptスキームのような不正な形式の値があった場合にその属性を削除するようにしました。
// 実装イメージ
const regex = RegExp(限定したいスキーマの形);
purify.addHook("afterSanitizeAttributes", (node: Element) => {
if (node.hasAttribute("href") && !node.getAttribute("href")?.match(regex)) {
node.removeAttribute("href");
}
});DOMPurifyにより逆順になった属性を戻す
バックエンドの事情によりできるだけHTMLの構造を変更したくはありませんでしたが、DOMPurifyを通すと属性が逆順になってしまう挙動があったためこれを戻す必要がありました。そのため、以下のようにHTMLの属性を逆順にする関数を作り、属性を全て逆順にしてからDOMPurifyに通すようにしました。
const dom = new JSDOM(html);
const { document } = dom.window;
document.querySelectorAll("*").forEach((element) => {
const attributes = Array.from(element.attributes);
attributes.reverse().forEach((attr) => {
element.removeAttribute(attr.name);
element.setAttribute(attr.name, attr.value);
});
});本番環境で確認
既存のプレスリリースを調査した際は、サニタイズ前とサニタイズ後の文字列を比較することでHTMLが壊れていないことをチェックしましたが、実際のブラウザ上で表示が崩れる可能性はあったため、本番環境で表示が崩れてしまう場合に備える必要がありました。これに関してやったことは以下の二つです。
- フロントエンド側でユーザーが配信前にプレビューを見る際にもサニタイザーを通すようにして、サニタイズ後のプレスリリースの表示をユーザーが確認できるようにしました。こうすることで仮に壊れてしまったとしても、ユーザーが壊れたプレスリリースを配信してしまうことを防ぐようにしました。
- プレスリリースが壊れてしまった時の調査のためにサニタイザーによって消されたタグ・属性をLambdaのログに出力するようにしました。
幸いにもサニタイザーをリリースしてから表示が崩れてしまったというようなお問い合わせもなく導入ができました。
今後について
今後は以下の二つを行いたいと思っています。
- 実行環境をLambdaからECSに移行する
- 消された要素があった場合に通知をする
今後はコスト削減のために実行環境をLambdaからECSに移行したいと考えています。当初の見積から予想よりも大幅に高い請求があったわけでありませんが、PR TIMESをご利用いただくお客様は年々増加しているのと、サニタイザーをPR TIMESが提供する他サービスでも活用していく予定があり、サニタイザーの実行回数が増えていくことが予測されるため移行を考えています。
また、エディター機能とAllowListの同期を取れるようにするためにサニタイザーにより消された要素があった場合に通知をするようにしたいと考えています。
新機能追加などによりエディター上で入力できるHTMLが増えた場合にサニタイザーのAllowListも更新しないと配信されるプレスリリースが壊れてしまうため、エディター機能とAllowListの同期が必要です。サニタイザーは自分がほぼ一人で作ったためエディターの新機能を追加した人から見ると勝手にHTMLが消され、プレスリリースが壊れてしまう原因の特定が難しいことがありました。これを解消するために消されたタグ・属性があった場合はSlackに通知してサニタイザーの存在に気が付ける仕組みを作りたいと思っています。

