Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

Permissions-Policy: microphone=() は自オリジンの音声入力も塞ぐ

公開 読了時間 約3分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

Permissions-Policy: microphone=() は「どのオリジンにも許可しない」という意味であり、selfを含まないため、自オリジンのWeb Speech APIも許可プロンプトを出す前にポリシー段階で拒否される。

結論

Permissions-Policy: microphone=() は「どのオリジンにもマイク機能を許可しない」という意味であり、self(自オリジン)を含まない。多層防御として自社アプリに追加したところ、自社の教員向け音声入力機能(Web Speech API)がブラウザの許可プロンプトすら出さずに全面遮断された。microphone=(self) に変更し、自オリジンだけを許可リストに含めることで解消した。

症状

セキュリティヘッダの強化として、使っていないブラウザ機能を無効化する Permissions-Policy を追加した。

{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },

camerageolocation はアプリが使っていない機能なので問題は起きなかったが、microphone は教員が音声で入力するための Web Speech API(lib/teacher-input/use-speech-to-text.ts)が使っている。このヘッダを追加した本番環境で、音声入力を試みると画面に「マイクの使用が許可されていません」と即座に表示され、ブラウザ側の許可ダイアログが一度も出ないまま入力できなくなった。

原因

Permissions-Policy はブラウザがマイクの許可を尋ねるより手前、ポリシーの評価段階で効く。microphone=() の丸括弧の中身は「許可するオリジンのリスト」で、空リストは自オリジンを含めてどのオリジンにも許可しないことを意味する。self を明示的に含めない限り、ページを配信しているオリジン自身も対象から除外されない。

そのため、Web Speech API(SpeechRecognition)を呼び出しても、ユーザーに許可を尋ねる通常のフローには進まず、ポリシー違反として拒否だけが返る。

recognition.onerror = (event) => {
  setError(event.error);
};

event.error にはこのとき "not-allowed" が渡る。UI 側はこの文字列をエラーメッセージへ変換して表示するだけの実装だったため、「ユーザーが拒否した」場合と「ポリシーで拒否された」場合を区別できず、原因がヘッダの設定にあることに気づくまでに時間がかかった。

直す

camerageolocation は未使用のまま () で全面禁止を維持し、実際に使っている microphone だけを self 付きに変更した。

-{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
+{ key: "Permissions-Policy", value: "camera=(), microphone=(self), geolocation=()" },

self を含めることで、自オリジンからの利用だけがポリシー違反の対象から外れる。これで Web Speech API の呼び出しはポリシー段階を通過し、ブラウザが通常どおりユーザーに許可を尋ねるプロンプトを表示できるようになった。外部オリジンへは引き続きマイク機能を許可しないため、多層防御としての効果は維持している。

再発防止

未使用の機能を () で全面禁止するという方針自体は変えていない。変えたのは、使っている機能を足すときは「使うオリジンを self で明示する」という一段階を必ず踏むことで、これを設定変更のコメントとして残した。

// 不要なブラウザ機能を無効化しつつ、使う機能だけ自オリジンに許可する(最小権限)。
// microphone は F02 教員音声入力(Web Speech API、`lib/teacher-input/use-speech-to-text.ts`)が
// 使う → `microphone=()`(全面禁止)だとブラウザが許可プロンプトを出す前にポリシーで遮断し、
// 「許可されていない」エラーになるため `microphone=(self)` で自オリジンのみ許可する。
// camera / geolocation は未使用なので `()` で全面禁止のまま(多層防御 / 最小権限)。

「不要な機能を塞ぐ」というセキュリティヘッダの目的と、「自社が使っている機能まで巻き込む」という副作用は、機能を1つ追加するたびに毎回チェックしないと再発する組み合わせだと分かった。

よくある質問

Q1なぜブラウザの許可プロンプトが一度も出なかったのですか?

Permissions-Policy はブラウザの許可プロンプトより手前で評価される。microphone=() は自オリジンを含むどのオリジンにもマイク機能自体を許可しないため、ユーザーに許可を尋ねる段階まで進まず、getUserMedia 相当のAPIがポリシー違反として即座に拒否だけを返す。

Q2camera や geolocation も同時に直したのですか?

直していない。この2つはアプリが使っていない機能なので () のまま全面禁止を維持した。直したのは実際に使っている microphone だけで、使っていない機能まで緩めると多層防御そのものが崩れる。

Q3アプリ側のエラーハンドリングでは検知できなかったのですか?

検知はできる。Web Speech API の onerror は event.error に "not-allowed" を渡し、UIはそれを「マイクの使用が許可されていません」という文言に変換して表示する。ただしこの文字列だけでは、ユーザーがブラウザのダイアログで拒否したのか、ポリシー段階で拒否されたのかを区別できない。

Q4microphone=(self) にすると何が変わりますか?

self、つまり自オリジンからの利用だけを許可リストに含める。外部オリジンへは引き続き () 相当で許可しないまま、自オリジンのページだけがブラウザの通常の許可プロンプト(ユーザーの許可/拒否)まで進めるようになる。

確認した環境

  • Next.js 16.2.6 / React 19.2.6
  • 2026-06-05 に本番で発生・同日修正

この記事の根拠

  • TypeScriptファイル 60〜60行目コミット cdeb255
  • TypeScriptファイル 64〜64行目コミット 37a19de
  • TypeScriptファイル 70〜90行目コミット 37a19de
  • TypeScriptファイル 113〜120行目コミット b3ea98d

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。