logohsb.horse
← ブログ一覧に戻る

ブログ

MV3でレスポンスヘッダーを次のリクエストへ中継するChrome拡張「Header Relay」を公開した

API開発と検証のためのChrome拡張 Header Relay を公開しました。レスポンスヘッダーから値をキャプチャして、以降の対象originへのリクエストに自動で付与する拡張です。仕組みだけ聞くと簡単そうですが、Manifest V3にはこれをやるための素直な実装手段が最初から存在しません。この記事では、何を解決するものか、その制約下でどう設計したかを書きます。

公開日:更新日:

MV3でレスポンスヘッダーを次のリクエストへ中継するChrome拡張「Header Relay」を公開した

API開発と検証のためのChrome拡張 Header Relay を公開しました。

レスポンスヘッダーから値をキャプチャして、以降の対象originへのリクエストに自動で付与する拡張です。仕組みだけ聞くと簡単そうですが、Manifest V3にはこれをやるための素直な実装手段が最初から存在しません。この記事では、何を解決するものか、その制約下でどう設計したかを書きます。

解決したい問題

サーバーがセッショントークンやトレースID、ゲートウェイ用ヘッダーをレスポンスヘッダーで返し、次のリクエストで送り返すことを期待するAPIがあります。ブラウザで検証するときの選択肢は、実質3つでした。

  1. DevToolsからの手動コピペ。トークンがローテーションするたびにやり直しになります。
  2. ModHeader系の静的ヘッダー拡張。決まった値しか付けられず、毎回違う値を返すサーバーには追従できません。
  3. mitmproxyやCharlesなどのプロキシ。追従はできますが、TLS介入込みのセットアップは「ブラウザにトークンを送り返させたいだけ」の用途には過剰です。

2と3のあいだが空いています。Header Relayはそこを埋めます。プロキシなし、コピペなしで、ブラウザ自身がヘッダーを中継します。

機能概要

  • Captured Header:対象originのレスポンスから指定ヘッダーの値を保持し、以降のリクエストへ付与する
  • Fixed Header:常時付与する静的ヘッダー
  • Profile:origin単位の設定モジュール。複数を同時に有効化できる
  • Excluded Path:globパターンで除外する(保存前に試せるテスター付き)
  • URL Probe:未保存のドラフト設定に対しても「このURLに何が付くか」をプレビューする
  • Audit Logs:ローカルのみ、直近1000件かつ7日以内で自動prune。リクエストURLは永続化しない

UIは英語、日本語、韓国語に対応しています。

設計

観測と書き換えの分離

ブラウザ自身に中継させる、という方針までは自然に決まります。問題はその先です。かつてなら blocking webRequest のリスナーでリクエスト直前にヘッダーを差し込めば終わりでしたが、Manifest V3はまさにその書き換えを廃止しました。一番素直な手が、最初から選択肢にないわけです。

そこで役割を分離しました。

  • 観測:non-blockingな webRequest.onHeadersReceived が有効originのレスポンスヘッダーを見て、設定されたCaptured Headerの値を storage.session(メモリのみ)へ保存する
  • 書き換え:有効Profile群とProfile別セッション状態をコンパイルし、単一のdeclarativeNetRequest session rulesetへ同期する。付与はattach rule、Excluded Pathは該当Profileが付与するヘッダーだけを外すremove rule

リクエスト時にJavaScriptは介在せず、付与はChromeのDNRエンジンがネイティブに行います。Service Workerが寝ていてもsession ruleは効き続けるので、MV3のライフサイクル問題とも相性の良い構成です。

単一コンパイルパス

origin一致判定や除外パスの判定は、DNR ruleの生成にも、URL Probeにも、Runtime Statusの表示にも必要です。素直に書くと、同じマッチングロジックが3箇所に育ちます。3箇所は、いつかズレます。「Probeでは付くと表示されたのに、実リクエストには付かない」という類のバグの出どころがこれです。

そこで、すべての判定を compileConfig() の出力に集約しました。有効Profile群とセッション状態を中間表現 CompiledConfig へ変換し、DNR rule生成もURL ProbeもRuntime Status表示も、この結果だけを入力にします。マッチングロジックが1箇所にしかないので、表示と実挙動のズレは構造的に入り込みにくくなります。ヘッダー解決の優先順位(同名ならfixed優先など)も同じモジュールに集約しています。

競合はエラーにする、priorityで自動解決しない

複数の有効Profileが同一originの同一ヘッダー名を付与しようとしたら、どうするべきでしょうか。priorityで自動解決する手はあります。DNRのruleはもともとpriorityフィールドを持っているので、実装も楽です。ただ、検証ツールで「どちらの値が付いたのか分からない」状態は、静かに壊れているのと同じです。何も付かないほうが、まだましです。

なのでHeader Relayはこれをコンパイルエラーとし、owned ruleを全削除して何も付与しない状態に落とします。UIとRuntime Statusにエラーが出るので、どちらかのProfileをdisableすれば復旧します。

権限モデル

「通信のヘッダーを読む拡張」は、構造的に疑われて当然のカテゴリです。だから権限はfail-closedに振りました。

required host permissionは http://localhost/* のみです(Chromeのmatch patternはポートを指定できないため、全ポートが対象になります)。それ以外はすべて optional_host_permissions とし、Target Originの保存やProfileの有効化といったユーザー操作を起点に、permissions.request() で対象ホストの分だけ要求します。

  • 未許可originにはDNR ruleを生成しない。webRequestイベントも届かない
  • Chromeの設定から権限が取り消されたら permissions.onRemoved で検知し、該当Profileのキャプチャ値を消去してruleを再同期する

ヘッダー名ポリシー

Set-Cookie(レスポンス専用)、HostContent-Length(message framing)、Transfer-Encoding など、connectionやproxyのsemanticsを持つ名前は保存自体を拒否します。一方で AuthorizationCookie まで拒否すると開発用途で困るので、UIで警告した上で許可しています(Cookie はブラウザのCookieストアと競合し得る旨を明示)。

プライバシー

  • キャプチャ値は storage.session のみ。ブラウザ再起動、拡張のリロードや更新、Profile無効化、権限取消、手動クリアのいずれでも消える。UIはデフォルトでマスク表示
  • 外部送信は一切なし。production buildでは分析イベントの送信もコンソール出力も無効
  • Audit LogsにリクエストURLを永続化しない(origin一致判定はメモリ上のみ)

拡張全体をheadlessに駆動するテスト

ブラウザ拡張のテストは、放っておくと chrome.* APIのmodule mockだらけになります。そうならないよう、実行時にブラウザへ触る箇所を StoragePort / DnrPort / AuditPort の3ポートに限定しました。

テストではin-memory adapterを差し込んだ createTestRuntime() で拡張全体を組み立て、

  1. 設定を投入する
  2. 偽レスポンスを流す
  3. 実際に適用されたrulesetをassertする

という一連の流れを、module mockなしのVitestで書けます。ただしin-memory DnrPortはChrome DNRの意味論までは再現しません。そこはrulesetの意味論をparityテストで、実挙動をPlaywrightのe2eで押さえる多層構成にしています。

スタック

  • WXT:MV3拡張のビルド、HMR、zip生成まで面倒を見てくれる
  • SolidJS + Tailwind CSS:popupと管理画面
  • TypeScript + Zod:設定スキーマ
  • changesets + GitHub Actions:mainマージだけでタグ、Release、ストア提出用zipまで自動生成

おわりに

インストール直後から、localhost:3000向けのデフォルトProfile(x-session-token のキャプチャと固定 x-client-id: header-relay)で試せます。DevToolsからのコピペで消耗している方はぜひ。固定ヘッダー用途であれば、ストアから削除されたModHeaderの置き換え先としてもそのまま使えます。

ソースコードは非公開ですが、不具合報告や対応してほしいユースケースの要望はGitHub issuesで受け付けています。

関連記事