Chrome MV3拡張機能でhost_permissions配下にfetchするとPOST以外でOriginヘッダが送られない問題
Chrome拡張機能(Manifest V3)からAPIを叩く実装をしていると、ローカルでは何の問題もなく動くのに、本番にデプロイした拡張機能からのGETやPATCHだけが403で弾かれる、という不可解な現象に遭遇することがあります。原因はサーバー側のCORS検証……ではあるのですが、その引き金は「host_permissions配下へのfetchではPOST以外でOriginヘッダが送られない」というChromiumの仕様にあります。DevToolsにも痕跡が残りにくく原因特定に時間がかかるので、挙動と対処方針を整理しておきます。
何が起きるか
Manifest V3の拡張機能から、host_permissionsでカバーされているURLに対してfetch()を実行した場合、POST以外のHTTPメソッド(GET / PATCH / DELETEなど)ではOriginヘッダがリクエストに付与されません。
これはChromiumの意図された設計であり、バグではありません。しかし、サーバー側でOriginヘッダの完全一致検証をCORS防御として実装していると、本番でのみ403でリクエストが弾かれる症状として表面化します。ローカル開発では気付きにくく、ChromeのDevToolsのNetworkタブにもそもそもOriginの項目自体が表示されないため、原因の切り分けに苦労しがちです。
再現条件
以下の条件がすべて揃ったときに発生します。
- Chrome拡張機能(Manifest V3)である
manifest.host_permissionsにリクエスト先のドメインが含まれている(例:["https://*/*"]、["https://api.example.com/*"]など)- 拡張機能のページ(popup / options / background service worker)から
fetch()を実行する - HTTPメソッドがPOST以外(GET / HEAD / PUT / PATCH / DELETE)
再現コード
拡張機能側(popup)
// manifest.json (一部){ "manifest_version": 3, "host_permissions": ["https://*/*"]}
// popup から fetchawait fetch('https://api.example.com/v1/me', { method: 'GET', headers: { 'X-Client-Id': 'xxx' }})
サーバー側で観測されるリクエスト
GET /v1/me HTTP/1.1Host: api.example.comX-Client-Id: xxxUser-Agent: Mozilla/5.0 ...# ← Origin ヘッダが無い
同じエンドポイントにPOSTすると、今度はOrigin: chrome-extension://<拡張機能ID>が付与されます。メソッドを変えただけで挙動が分岐するのがこの問題の厄介なところです。
原因:Chromiumの公式見解
chromium-extensions Google Groupのスレッドで、Chrome ExtensionsのDevRelであるSimeon Vincent氏が以下のように明言しています。
Chrome will not send an Origin header for most HTTP methods if the extension has host permissions for the target domain. The only exception to that is POST as the HTTP spec requires an origin on post. If you don't have host permissions, the chrome-extension origin should be included.
要約すると次のようになります。
| 条件 | Originヘッダ |
|---|---|
host_permissionsにカバーされている宛先 + POST | 送信される(HTTP仕様で必須) |
host_permissionsにカバーされている宛先 + POST以外 | 送信されない |
host_permissionsにカバーされていない宛先 | 常にOrigin: chrome-extension://を送信 |
なぜPOSTだけ例外なのか
Fetch StandardおよびRFC 9110において、POSTのようなstate-changingメソッドには、CSRF防御の観点からOriginの付与が事実上必須とされています。Chromeはhost_permissionsを持つ拡張機能のfetchを「ファーストパーティ的な権限」として扱うためOriginを省略しますが、POSTだけは仕様の要請で省略できない、というわけです。
設計意図
host_permissionsを宣言した時点で、ユーザーは拡張機能にそのドメインへのアクセスを許可しています。このためChromeは、CORSの通常フロー(preflight + Originチェック)をバイパスし、拡張機能のfetchを「同一オリジン相当」として扱います。Originを省略するのは、サーバー側のACAO(Access-Control-Allow-Origin)検査を不要にするための副作用と捉えると理解しやすいでしょう。
検証方法
1. DevToolsでの観察
popupを右クリック → 「検証」→ Networkタブ → 失敗しているリクエストを開いてRequest Headersを確認します。GET / PATCH / DELETEでは**Originの項目自体が存在しません**(空ではなく不在)。POSTだとOrigin: chrome-extension://が見えます。
2. サーバー側ログでの観察
サーバーのmiddlewareに診断ログを仕込むと、不在の事実をはっきり確認できます。
const origin = c.req.header('Origin')console.log({ receivedOrigin: origin ?? null, originMissing: origin === undefined || origin === '' || origin === 'null', method: c.req.method})
GET / PATCHではreceivedOrigin: null, originMissing: trueとなります。
影響:サーバー側CORS検証への落とし穴
特定の拡張機能のみを許可する目的で、サーバー側に以下のような実装をしているケースは少なくありません。
// アンチパターン (Chrome MV3 で破綻する)app.use('/api/*', (c, next) => { const expected = `chrome-extension://${EXTENSION_ID}` const origin = c.req.header('Origin') if (origin !== expected) { return c.json({ error: 'forbidden_origin' }, 403) } return next()})
このコードは、
- ローカル開発(curlテスト)では機能するように見える
- 本番デプロイ後の拡張機能からGET / PATCHすると403で全滅する
という形で破綻します。
hono/corsやcorsnpmパッケージなどの一般的なCORS middlewareは、この拡張機能特有の挙動には対応していません。これらはAccess-Control-Allow-Originのエコーを目的としているため、Originが無いリクエストはCORSチェックの対象外として通します。問題が顕在化するのは、自前で「Origin完全一致」を強制している場合です。
対処方針
サーバー側のOrigin検証を、メソッドに応じて分岐させます。
const expectedOrigin = `chrome-extension://${EXTENSION_ID}`const origin = c.req.header('Origin')const originMissing = !origin || origin === 'null'
// 非 POST: Chrome MV3 の host_permissions 経由 fetch では Origin が省かれるため// 欠落を許容する。HMAC 署名等のアプリ層認証を一次防御とする。// POST: Origin は必ず送られる前提のまま strict 検証を維持。// HMAC 無しの登録系エンドポイント (例: ユーザー作成) を curl 大量発行から守る。const allowMissingOrigin = originMissing && c.req.method !== 'POST'
if (!allowMissingOrigin && origin !== expectedOrigin) { return c.json({ error: 'forbidden_origin' }, 403)}
設計上のポイント
- OriginチェックをCORSの主防御にしない。
host_permissionsありの拡張機能では、Originは信頼できる識別子になりません。HMAC署名 + nonce、あるいはAPIトークンなど、アプリケーション層の認証を主防御とします - POSTでだけOriginをstrict検証することで、HMAC無しの登録エンドポイントへのcurl大量発行を防ぐ二段目の防御としては機能します
- Originが送られてきているのに値が異なる場合は全メソッドで403にします(別拡張機能からの攻撃を弾く)
なぜ気付きにくいか
この問題が厄介なのは、原因にたどり着くまでの手がかりが乏しい点にあります。
- ローカル開発時のcurl / PostmanではOriginが空でも自然なので、サーバー側のstrict検証はテストで通ってしまう
- 拡張機能のdev buildはブラウザのhost_permissions解釈が緩いケースがある(manifestが
localhostを含むかなど) - DevToolsのRequest Headersに
Originが出ない——「null」ではなく「項目が無い」ため、見落としやすい - エラーメッセージ(
forbidden_origin403)からhost_permissionsを疑うのは飛躍が必要
「ローカルで通って本番で落ちる」「POSTは通るのにGETだけ落ちる」という二重のねじれが、原因の切り分けをさらに難しくしています。
まとめ
- Chrome MV3拡張機能で
host_permissions配下にfetchすると、POST以外ではOriginが省略される(仕様) - サーバー側でOrigin完全一致を主防御にしていると、ローカルで通って本番で403になる
- 主防御はHMAC / トークン認証などアプリケーション層に置き、Origin検証は補助に留めるべき
- POSTだけはOriginが必ず送られるので、HMAC無しの登録系エンドポイント保護には依然有効
Origin検証は手軽な反面、host_permissionsを持つMV3拡張機能では前提が崩れます。「Originがあれば一致を強制、無ければメソッドで分岐」という二段構えにしておくと、本番限定の403障害を避けつつ、curl直叩きへの一定の抑止も両立できます。
参考リンク
- Why is the background script setting header origin:chrome-extension://myextensionid? — chromium-extensions — Simeon Vincent氏(Chrome Extensions DevRel)の公式見解
- Origin header not being sent from service worker when Site Access is set to "On click" — chromium-extensions — Site Access設定での挙動差異
- Cross-origin network requests | Chrome for Developers — host_permissionsとCORSの関係
- Fetch Standard §3.1 — Origin header — POSTにOriginが必須となる仕様根拠
- MDN: Origin header
- MDN: host_permissions