自作プラグインのリリース前チェックで、管理画面のリンクを一通り見直していた夜のことです。「ドキュメントを別タブで開く」のリンクに rel が付いていない箇所を、いくつか見つけました。
結果から書きます。仕様の理解が足りなかったのではありません。主要ブラウザが暗黙の noopener に対応していることも、WordPress が投稿本文の target="_blank" に rel を自動で付けてくれることも、知っていました。知っていて、その自動付与がどこまで届くのかを確かめていなかった。プラグインの管理画面に出力する HTML は wp_targeted_link_rel() の対象外なので、自分で書かなければそのまま残ります。同じ調べ直しの過程で、過去に自分がやらかしていた別の2件も掘り返すことになりました。


上の2枚が、この記事で扱う穴の形です。1枚目がタブナビングの流れ、2枚目が window.opener の参照関係と、rel="noopener" でそれが切れる様子。まず自分が直した3件を書いて、そのあとで仕組みと WordPress 側の事情に降りていきます。
見落としていたのは仕様ではなく、適用範囲だった
ひとつ目は、いま書いたテーマファイルの直接出力です。ブラウザが暗黙 noopener に対応したと聞いて、WordPress の自動付与に全部任せていました。ただし自動付与の対象は投稿本文、つまり the_content を経由して出力されるコンテンツだけです。footer.php や header.php、ウィジェット、プラグインの管理画面 UI でハードコードしたリンクは対象外でした。コアが面倒を見てくれる、という認識そのものは正しいのですが、その面倒を見てくれる範囲を確認していなかった。いまは新規のテーマファイルを書くとき、外部リンクの部分だけ別途レビューする運用にしています。
ふたつ目は、逆方向の失敗です。全外部リンクに rel="noopener noreferrer" を一括付与していたところ、半年後に GA4 のリファラ流入が想定より少ないことに気づきました。noopener と noreferrer を意味で分けず、セットで安全だと思い込んでいたのが原因です。外部リンクの大半を noopener のみに直し、参照元を本当に渡したくないリンクにだけ noreferrer を残す運用へ切り替えました。この記事で書く「計測したい外部リンクには noopener のみ」というルールは、この修正の結果として決まったものです。
みっつ目は、過去記事を一括修正したときの取りこぼしです。最初は正規表現で置換しようとして、属性順の違いで漏らしました。Cocoon の関連記事ブロックや独自ショートコードが吐く HTML は、属性の並び順がサイト内でも揺れます。DOMDocument でパースする方式に切り替えるまで、漏れていることに気づきませんでした。HTML を構造として扱うか、文字列として扱うか。安全側に倒すべき場面のほうが多いと、このとき改めて思いました。
window.opener で何が起きるのか
target="_blank" はリンク先を新しいタブで開く属性です。外部サイトの規約や地図、決済画面への遷移など、元ページでの作業を続けさせたい導線で日常的に使われます。属性そのものに問題があるわけではありません。問題は、新しく開いたタブが元のタブを操作できる仕組みになっている、というブラウザの古い仕様のほうです。これがタブナビング、Reverse Tabnabbing と呼ばれる攻撃の入り口になります。
流れはこうです。あなたのサイト A に target="_blank" の外部リンクがある。利用者がクリックしてサイト B が新しいタブで開く。サイト B の JavaScript が、元タブを別のページへこっそり遷移させる。利用者が用事を済ませて元タブへ戻ると、いつものログイン画面に見える偽ページが表示されている。そして気づかないまま ID とパスワードを入力してしまう。冒頭1枚目の図が、この流れです。
怖いのは、利用者が警戒していてもタブが書き換わる瞬間を見ていないことです。サイト B を見ているあいだに背後で書き換えが起き、戻ったときには見慣れた画面の顔をしている。技術的な穴であると同時に、人間の認知の癖を突くフィッシング手法でもあります。
target="_blank" で新しいタブを開くと、子タブには元タブへの参照が自動で渡されます。これが window.opener です。悪用する側のコードは、拍子抜けするほど短い。
| 1 2 3 4 | // 子タブ(リンク先)で実行される悪意あるコードの例 if (window.opener) { window.opener.location = "https://phishing.example/login"; } |
この1行で、元タブが別の URL に書き換わります。元タブで動いている JavaScript ではなく、子タブ側から親タブの location を書き換える方向に動くところが要点です。同一オリジンでなくても location の書き換えは許されているため、外部リンク先からでも実行できます。冒頭2枚目の図の左側が、この参照関係です。
noopener と noreferrer は、別の属性
この穴を塞ぐ手段が rel="noopener" です。
| 1 | <a href="https://example.com" target="_blank" rel="noopener">安全に別タブ</a> |
指定すると、子タブ側の window.opener が null になります。子タブから親タブを参照する経路そのものが消えるので、タブナビング対策としてはこれが本命です。もうひとつの rel="noreferrer" は、HTTP の Referer ヘッダ、つまり参照元 URL を送信しません。仕様上は noopener 相当の挙動も含まれるため、セキュリティ目的だけを見るなら noreferrer 単独でも事足ります。ただし Referer を送らない副作用が計測に直撃するので、用途を選ぶ属性です。意図を明示するために両方書くチームも多く、これも実務上は妥当な書き方だと思います。

上の対応表が、noopener、noreferrer、opener の3属性の違いです。私は3つのパターンで決め打ちにしています。参照元を計測したい外部リンクは target="_blank" rel="noopener"。管理画面や社内ツールなど、URL にクエリパラメータで情報が乗りやすいページからの外部リンクは noopener noreferrer。そして、なぜ別タブにしたいのかを一言で説明できないなら、そもそも target を付けずに同一タブで遷移させます。モバイルではタブが増えるだけ体験が悪化することもあり、別タブ指定は意外と無罪ではありません。
| 1 | <a href="https://example.com" target="_blank" rel="noopener noreferrer">外部サイト</a> |

rel に何か付けるとリンク評価が下がるのでは、という相談を時々受けますが、noopener も noreferrer も検索ランキングを直接動かす要素ではありません。一方で計測への影響ははっきり出ます。noreferrer を付けると参照元が送信されなくなるので、GA4 で direct 扱いが増え、アフィリエイトや紹介プログラムのリファラ計測が機能しなくなります。一律に noopener noreferrer を全件へ振る運用は、後になって計測が崩れていることに気づいて慌てるパターンになりがちです。私がそうでした。パフォーマンスの面では、window.opener が存在すると子タブと親タブのプロセス分離が制約されることがあり、noopener を付けると分離しやすくなります。体感できるほどの差ではありません。
ここまで noopener を付けようと書いてきましたが、主要ブラウザは数年前から、target="_blank" を指定するだけで暗黙的に noopener 相当の挙動になるよう変わってきました。MDN にも同等の挙動になると明記されていて、caniuse の対応表でも Chrome、Firefox、Safari、Edge が揃って対応済みです。

仕様だけを見れば、現在のブラウザを使う利用者に対しては書かなくても穴は塞がっています。それでも明示する運用を続けているのは、サポート対象をモダンブラウザだけに絞れない案件があること、アプリ内ブラウザや古い組み込み WebView が油断できないこと、WordPress やテーマが自動付与しているならわざわざ外す理由がないこと、そしてセキュリティを意識して書いた意図が HTML 上で見えるとレビューや引き継ぎが楽になることです。逆に window.opener をあえて使いたい場面もまれにあります。OAuth や SSO のポップアップログイン、決済ポップアップが完了したら親画面を更新したいケースです。このときだけ rel="opener" を明示して opener を許可します。
WordPress がどこまで面倒を見てくれるか
WordPress には、投稿本文中の target="_blank" リンクに rel を自動付与する仕組みが昔から組み込まれています。実体は wp_targeted_link_rel() という関数で、エディタで新しいタブで開くを指定したリンクに、保存時にコアが rel を補います。

導入当初の 4.7.4 では、投稿本文の target="_blank" リンクに noopener noreferrer を強制的に付ける挙動でした。当時はブラウザの暗黙 noopener がまだ整っておらず、利用者が書いたコンテンツに対する安全側の判断としては妥当だったと思います。2020年12月の 5.6 で、デフォルトの付与から noreferrer が外れました。計測や参照元分析に影響が出る、必要かどうかはサイト運営者が決めるべき、という方向の判断です。noreferrer はセキュリティの属性であると同時に、アクセス解析を壊しうる属性でもあるので、これは妥当な変更だったと感じています。6.5 以降は、主要ブラウザが暗黙 noopener に対応してきたことで、この関数を将来どうするかという議論が出ています。ただし投稿本文まで一気に自動付与を外すと、古い WebView 経由で見る利用者や過去テンプレートとの整合性が崩れるため、いまのところ残す方向で落ち着いています。
繰り返しになりますが、自動付与の対象は投稿本文です。テーマファイルで直接 <a> タグを書いた場合、プラグインの管理画面 UI、ナビゲーションメニューの外部リンクは対象外です。私が見落としたのもここでした。
運用中のサイトでどれだけ漏れているかを知るには、ページを表示して開発者ツールの Elements パネルを開き、target="_blank" で検索して rel の有無を見るのが手早い方法です。

記事数が多い場合は、Screaming Frog SEO Spider や Sitebulb といったクローラーで、target="_blank" はあるが rel がないリンクを抽出するのが楽です。私は Screaming Frog の Custom Extraction で XPath を指定して該当アンカーだけ吐かせ、CSV にして対応漏れを潰しました。
これから保存される投稿に対しては、wp_targeted_link_rel フィルターで自サイトのポリシーを定義しておけます。
| 1 2 3 4 5 6 7 | // noopener を必ず付与する例(functions.php に追加) add_filter('wp_targeted_link_rel', function ($rel) { $rels = preg_split('/\s+/', trim((string) $rel)); $rels[] = 'noopener'; $rels = array_unique(array_filter($rels)); return implode(' ', $rels); }); |

参照元も渡したくないなら、同じ配列に noreferrer も足します。計測への影響を把握してから入れてください。このフィルターが効くのは、あくまでこれから保存される投稿です。すでに DB に入っている過去記事の rel は変わりません。過去分を一括で補正するなら、WP-CLI からカスタムコマンドを叩く形が安全です。HTML を正規表現で置換するアプローチは、属性順序や属性内のクォート種別、コメント中の文字列など、考慮漏れで壊しやすい。私は DOMDocument でパースして該当アンカーだけ書き換え、wp post update で戻す流れにしました。本番でいきなり一括置換を走らせるのは事故のもとなので、バックアップを取り、ステージングで挙動を確認してから本番に反映してください。
テーマやコアの動きにフィルターや子テーマで手を入れる発想は、Cocoon の標準挙動を子テーマ側で上書きした別記事にも書いています。WP-CLI を Xserver で実際に動かした記録は、WP-Cron を wget から乗り換えたこちらの記事にあります。
いま自分のサイトに適用されているルール
結果として、手元のルールはこの形に落ち着きました。target="_blank" を使う理由を一言で説明できないリンクには、そもそも付けない。外部リンクで別タブにするなら原則 rel="noopener" を明示する。noreferrer は参照元を渡したくない場面でだけ追加する。管理画面や社内ツール内の外部リンクは noopener noreferrer をデフォルトにする。既存コンテンツの一括修正は、バックアップとステージング検証を済ませてから実行する。
そして、新しいテーマファイルとプラグインの管理画面を書いたら、外部リンクの部分だけ別に見る。自動付与が届かない場所は、いまのところこの2つです。




コメント