2026 年、ブラウザのダッシュボードや社内 SPA を TCP 8787 で待ち受ける OpenClaw ゲートウェイに接続するとき、最初の「謎ダウン」はしばしばモデル API ではなく CORS だ。curl は通るのに Safari と Chrome が blocked by CORS policy を出す典型原因は、ブラウザが送った OPTIONS プリフライトがエッジで処理されなかったこと、または Access-Control-Allow-Origin: * なのに credentials: 'include' を使ったことにある。本稿では明示的な Origin 許可リスト、シンプル/クレデンシャル付きリクエストの差、nginx でのヘッダー順序と二重付与の罠、チケットに貼れる curl プローブを整理し、初回 PATH・Node・LaunchAgent スモーク、ポート占有とヘルスプローブ整合、doctor ゲートウェイ診断 と横断できるようにする。
読み終えると意思決定表、ヘッダーのレシピ、数値ガードレール(204 または 200 の OPTIONS が妥当、クレデンシャル時は ワイルドカード禁止)、MacHTML 公開価格に近い 1 日あたり約 16.9 ドル のレンタル基準が手元に残る。顧客と同じ Safari ビルドで検証するための土台になる。
ブラウザと curl の違い
curl https://gateway.example/health は到達性の証明にすぎず、ブラウザのポリシーは証明しない。ユーザーエージェントはクロスサイト呼び出しで Origin を付け、カスタムヘッダーがあると単純な GET でもプリフライト付き POST に格上げされうる。JavaScript がレスポンス本文を読むには Access-Control-Allow-Origin が開始オリジンと一致するか、非クレデンシャルで * である必要がある。CORS はフロントとプラットフォームの二当事者契約と捉えよ:フロントは正確な Origin 文字列を所有し、プラットフォームは TLS を終端するホップでヘッダーを正しい順で出す。
スキーム・ホスト・ポートで各呼び出し元を文書化する——https://app.corp.internal:8443 と https://app.corp.internal は別オリジンだ。ポートを許可リストから漏らすと、非標準 TLS ポートのステージングだけが断続的に失敗する。経営陣のノートと同じ Safari で試すために専用 Mac mini を借りる価値はここにある。
シンプルとクレデンシャル付きフェッチ
カスタムヘッダーのないシンプルな GET はプリフライトを省略しうるが、クロスオリジンで本文を読むなら Access-Control-Allow-Origin は依然必要だ。Authorization、Cookie、fetch(..., { credentials: 'include' }) のいずれかを足すと、仕様は具体的な Origin のエコーを要求しワイルドカードは禁止される。Access-Control-Allow-Credentials: true を併記する。
匿名メトリクスだけを公開するなら * で Cookie を避けられる。一部ルートだけクレデンシャルという混在モードでは nginx の location を分割し、マーケティング用ピクセルが管理 UI の CORS を誤継承しないようにする。
OPTIONS プリフライトの仕組み
プリフライトは OPTIONS とともに Access-Control-Request-Method、任意で Access-Control-Request-Headers を送る。ゲートウェイは Access-Control-Allow-Methods で許可動詞(多くは GET,POST,OPTIONS)を列挙し、Access-Control-Allow-Headers で要求された名前を大文字小文字を区別せずエコーし、Access-Control-Max-Age でブラウザキャッシュを制御する——開発では 300 秒、本番ではヘッダー回転頻度に応じ 600–86400 が一般的だ。
204 無本文か 200 空本文のどちらも広く受容される。失敗例は 405 Method Not Allowed(nginx が OPTIONS を誤った上流へ転送)、動的エコー時に Vary: Origin が欠け CDN が誤った ACAO を全テナントに配るケースだ。
Origin 許可リストと localhost の罠
http://localhost:5173(Vite)と http://127.0.0.1:5173 は別オリジンだ。ローカル開発では両方を入れるか、dev コマンドを一つのホスト名に統一する。SSH ローカル転送で 127.0.0.1:8787 を公開しても、ブラウザの Origin は公開 SPA の URL のまま——許可リストには SPA の Origin が必要で http://127.0.0.1 だけでは足りない。
受信 Origin をそのまま Access-Control-Allow-Origin に写す動的反射は便利だが、サーバ側で固定集合を検証しない限り危険だ。マップ検索が安全:集合 S に含まれるならエコーし、そうでなければヘッダーを省略してブラウザにフェイルクローズさせる。拒否された Origin は INFO でサンプリングログし、セキュリティが濫用を監査できるようにする。
nginx と二重ヘッダー
nginx が TLS を終端しループバックの OpenClaw に転送するとき、CORS を nginx と Node のどちらが担うか決める——両方が付与すると重複ヘッダーとなり一部ブラウザは拒否する。実務的には nginx が静的とマーケページ、OpenClaw が CLI 互換の API ヘッダーを既に出しているなら API ルートだけ Node に任せる、といった切り分けが多い。どちらを選んでも、グレースフルシャットダウンと同じ運用台帳に書き、インシデント中に二重修正しない。
HTTP/2 を nginx で終端する場合、古い設定では add_header が 2xx にしか付かず always が必要なことがある。OPTIONS が誤った server ブロックに落ちるのは、ワイルドカードとクレデンシャルの不一致に次ぐ SPA 謎障害の主因だ。
Vary: Origin、CDN キャッシュ、古い ACAO
リクエストごとに Origin をエコーすると応答は Origin に依存するバリアントになる。Vary: Origin が無いと中間キャッシュが顧客 A の Access-Control-Allow-Origin を顧客 B のセッションに返しうる——微妙な情報漏えいと機能バグの両方だ。CDN が Vary を剥がす正規化をしている例もある;エッジルールが CORS を書き換えるならベンダにチケットを切る。
CORS デバッグ中はエラー本文のキャッシュ TTL を 60 秒未満にし、悪いデプロイがエッジに数時間張り付くのを防ぐ。キャッシュバストは構造化ログの相関 ID と組み合わせ、ユーザーがスクリーンショットを匿名化しても HAR とゲートウェイログを突き合わせられるようにする。
JWT、カスタムヘッダー、ツール固有の CORS
OpenClaw のツールルートは X-Request-Id や X-OpenClaw-Profile 風のヘッダーを足すことがある。ブラウザのセーフリスト外のヘッダーはすべてプリフライトを誘発するため、Access-Control-Allow-Headers に明示列挙する——クレデンシャルフローで * に頼るな。JWT が Authorization: Bearer なら、プリフライトと実 POST の両方で ACAO 行が一致しているか確認する。不一致だとサーバ側で POST が成功していても「Access-Control-Allow-Origin がない」という有名なエラーが出る。
署名鍵ローテーション時は一時的にプリフライトキャッシュ窓を倍にし、ブラウザが新しいヘッダー名を 15 分 以内に拾うようにしてから max-age を締める。TLS 証明書更新カレンダーと同じ運用ボードに書くと同期が崩れにくい。
意思決定マトリクス
| 呼び出し元 | クレデンシャル | ACAO 戦略 | 注記 |
|---|---|---|---|
| 公開ドキュメント | なし | * または静的許可 | 匿名のまま、Cookie なし |
| 社内管理 SPA | あり | 明示 Origin をエコー | Allow-Credentials: true と併用 |
| モバイル WebView | 場合による | カスタムスキーム Origin | WebView URL パターンを検証 |
| 第三者 SaaS 埋め込み | 稀 | 静的パートナー Origin | 契約上の許可リストのみ |
アーカイブ向け curl プローブ
# プリフライト(ホストとパスを置換)
curl -i -X OPTIONS "https://gw.example/v1/chat" \
-H "Origin: https://app.example" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization,content-type"
# Origin 付き GET で ACAO を確認
curl -i "https://gw.example/health" \
-H "Origin: https://app.example"
レスポンスヘッダー全文をチケットに保存し、各デプロイ後に diff する。エッジ OPTIONS の p95 が 150 ms を超えても GET ヘルスが 12 ms 台なら、まず冷えた上流へのルーティングを疑い、すぐに OpenClaw 本体のせいにしない。
リリースチェックリスト
- 本番とステージングの SPA Origin をスキーム・ホスト・ポート込みで列挙する。
- CORS ヘッダーの単一オーナー(nginx か OpenClaw)を決め、重複を取り除く。
- CI で開発者がローカルで使うのと同じ
Origin文字列で OPTIONS を試す。 - クレデンシャルフローがワイルドカード ACAO を拒否することを自動テストで保証する。
- ポートやヘッダー変更後に
openclaw doctorを再実行し、ヘルスが 200 のままか確認する。 - レンタルした mini から Safari と Chrome の HAR を取得し 90 日 保管する。
セキュリティレビューでは動的 Origin 反射が境界付きであることの証明を求められることが増えている。許可リストファイルを git で管理しプラットフォームチームを CODEOWNERS に置けば、「一時的」ワイルドカードコミットが恒久的な攻撃面になるのを防げる。
FAQ
Safari だけ失敗して Chrome は成功するのはなぜ?
Safari は重複 CORS ヘッダーや混合コンテンツに厳しい。実機 macOS で両エンジンを試すこと。
プリフライトは永久キャッシュすべき?
いいえ。境界のある Access-Control-Max-Age で、ヘッダー変更が分〜時間で伝播するようにする。
WebSocket も同じ CORS ルール?
ハンドシェイクは Origin ヘッダー付きの HTTP アップグレードであり、REST と同じ許可ロジックで検証する。
Apple Silicon Mac mini のレンタルは、意思決定者がデモに使うのと同じ Safari/WebKit スタックでゲートウェイ UI を検証できる。MacHTML のクラウドノードはスクリプト化された curl スイート用の SSH と、デザイナーが Network パネルをライブで見るための任意 VNC を組み合わせられる。待機電力はしばしば 12 W 前後で、スプリント週にリハーサル用 mini を載せ続けても、誤った CORS を本番に出して取締役会の最中にロールバックするより安いことが多い。
レンタルは重い資産調達サイクルも迂回できる。公開価格ページのおよそ 1 日 16.9 ドル で課金し、統合後に遊休になる金属箱を買い足す必要はない。CORS 作業が終わればインスタンスを止めればよく、ヘッダーは git に残り、ハードウェアは帳簿上 36 か月 にわたって減価償却されない。
実機 macOS ゲートウェイで OpenClaw CORS をリハーサル
クラウド Mac mini を借りて、ゲートウェイ変更をマージする前に Safari で OPTIONS・クレデンシャル付きフェッチ・nginx エッジヘッダーを検証する。