ユーザー入力の HTML を SSR と SPA の両方で表示している方へ。
「新しいタブで開く」はずのリンクが、初回表示では効くのに、ハイドレーションが済んだ瞬間から同タブ遷移に退化していました。
この記事では、sanitizer を PHP(SSR)と DOMPurify(SPA)の2実装で持つ構成に潜む「双子の食い違い」と、書く前の30秒 grep で同型バグを未然回避した実例を示します。
はじめに
CMS のリッチテキストを公開ページに出す機能で踏んだ話と、その直後に同じ構造の罠を回避した話のセットです。この記事で分かることは次の3点です。
- sanitizer が2実装あると、許可リストの差分がそのまま「SSR とハイドレーション後の挙動差」になること
- この食い違いはエラーも警告も出ないため、事後デバッグだと丸1日級・事前の grep だと30秒で済むこと
-
targetを許可するときの正しいセット(rel="noopener noreferrer"の強制付与)
原因を先に書きます。SSR 側の PHP sanitizer は target を許可、SPA 側の DOMPurify 設定は target を剥いでいました。同じ HTML が、どちらの sanitizer を最後に通ったかで違う DOM になるわけです。
症状:初回表示は新タブ、操作しはじめると同タブ
公開ページの構成は SSR + ハイドレーションです。
- サーバー(PHP)がサニタイズ済み HTML を描画 → この時点のリンクは
target="_blank"あり - React がハイドレーションし、同じ HTML を SPA 側の sanitizer(DOMPurify)を通して再描画
- SPA 側は
targetを許可リストに入れていない → ハイドレーション後の DOM からはtargetが消える
つまり「ページを開いた直後だけ新タブで開き、少し経つと同タブになる」。ユーザーから見ると気まぐれな退化で、再現条件を言葉にするのも難しいバグでした。
原因:許可リストが2箇所にあり、少しずつ違う
sanitizer はセキュリティ部品なので、SSR(PHP)と SPA(TypeScript)の両方がそれぞれ自前の許可リストを持ちます。実装言語が違うので共有もできません。すると、
- 片方だけ属性を足す・剥ぐ変更が入る
- どちらも「単体では正しく」動く
- 差分は同じ HTML の表示が時間差で変わるという形でだけ現れる
という構造になります。sanitizer 自体は仕事をしているので、ログにもコンソールにも何も出ません。
対策1:target は rel 強制とセットで許可する
修正は「SPA 側でも target を許可する」ですが、素通しにはしません。target="_blank" は rel="noopener noreferrer" なしだと逆タブナビング(開いた先から window.opener 経由で元ページを操作される)の口になるため、許可と同時に rel を強制付与します。
target を許可する場合のセット:
- allowlist に target を追加
- 出力時に rel="noopener noreferrer" を機械的に強制(入力の rel は信用しない)
SSR 側・SPA 側の両方に同じセットで入れて、初めて「どちらを通っても同じ DOM」に戻ります。
対策2:書く前に「双子の差」を grep する——30秒の予防
このバグの後日談が本題です。次の施策で <img> に srcset / sizes / loading を付けて出したくなったとき、書く前に PHP sanitizer の許可属性を grep しました。
grep -n 'allowed' src/PublicRecord/PublicHtmlSanitizer.php
# => img の許可属性は src / alt / title / width / height (+style) のみ
srcset も sizes も loading も SSR で剥がれることが、実装前に判明しました。そこで最初からこれらの属性に依存しない構成を選び、同型バグ(SSR では残るが SPA が剥ぐ、あるいはその逆)の再発をゼロコストで回避しました。
事後デバッグは「気まぐれな表示差の再現→ハイドレーションの疑い→2実装の突き合わせ」で丸1日級でした。事前の grep は30秒です。二重実装を抱えた瞬間から、「双子の差分を先に測る」が最安の防御になります。
対策3:構造で消すなら許可リストの単一ソース化
余力があれば、許可リスト(タグ・属性の一覧)を JSON など言語中立な1ファイルに置き、PHP と TypeScript の双方がそれを読み込む形にすると、この故障類型自体が消えます。そこまでやらない場合も、少なくとも両実装の許可リストを並べて差分を取るテスト(parity チェック)を1本置いておくと、片側だけの変更が機械的に検出できます。
学び
- sanitizer の二重実装は、許可リストの差が**「SSR とハイドレーション後の挙動差」として無言で現れる**。エラーは出ない。
- 触る前に双子の両方を grep で測る。事前30秒 vs 事後1日の落差が、この構造の全てを説明している。
-
target="_blank"の許可は単独でやらない。rel="noopener noreferrer"の強制付与とワンセット。
一次資料
- target 剥ぎ取りによる SSR/ハイドレーション挙動差と修正(rel 強制付き許可): nene-records#939 / PR #940(merge
f13842b) - 実装2ファイル:
src/PublicRecord/PublicHtmlSanitizer.php(SSR/PHP)・frontend/src/.../SanitizedHtml.tsx(SPA/DOMPurify)
関連記事
- curlでは全緑、実ブラウザで6機能が401停止 — 「ハイドレーション後の世界は curl から見えない」検証盲点つながり
- ブラウザに素の JSON を返さない|エラーの content negotiation — 同じ入力を経路で出し分ける設計の作法
── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp