0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

target="_blank"がハイドレーション後だけ消える|SSR/SPA二重sanitizerの罠

0
Posted at

ユーザー入力の 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 + ハイドレーションです。

  1. サーバー(PHP)がサニタイズ済み HTML を描画 → この時点のリンクは target="_blank" あり
  2. React がハイドレーションし、同じ HTML を SPA 側の sanitizer(DOMPurify)を通して再描画
  3. 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) のみ

srcsetsizesloading も 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)

関連記事

── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?