はじめに
他社サイトに <script> 一行で貼る埋め込みウィジェットを作っていて、CORS の preflight に悩んでいる方へ。
他社サイトに <script> 一行で貼るタイプの埋め込みウィジェット(問い合わせフォーム、チャット、アンケートなど)を作ると、まず間違いなくぶつかるのが CORS です。ウィジェットは運用者のドメイン(https://ops.example.com)で動き、API は自社のドメイン(https://api.example.com)にある。つまり通信は常にクロスオリジンになります。
素朴に fetch で JSON を POST すると、ブラウザは本命のリクエストの前に OPTIONS の preflight(プリフライト) を飛ばします。この preflight が地味に厄介で、
- 往復が 1 回増える(体感レイテンシと初回失敗率に効く)
- サーバ側で
OPTIONSを正しく捌く経路を別に用意しないといけない - CDN・WAF・リバースプロキシが
OPTIONSを落とすと、本命が届く前に無言で死ぬ
という事故が起きます。埋め込み先の環境はこちらでコントロールできないので、preflight は「相手の環境次第で壊れる部品」になりがちです。
この記事では、埋め込みフォーム NeNe Contact の embed.js と公開 API で実際に採っている設計、つまり リクエストを CORS の "simple request"(単純リクエスト)に収めて preflight そのものを発生させないアプローチと、それでも安全を保つための Origin allowlist × Vary: Origin をセットで紹介します。コードはすべて実装からの抜粋です。
この記事で分かること:
- 埋め込みウィジェットで preflight(
OPTIONS)が「相手の CDN/WAF 次第で壊れる依存」になる理由 - リクエストを simple request(
GET・text/plainの JSON・multipart・独自ヘッダなし)に収めて preflight を消す実装 -
*に逃げず Origin をフォーム単位 allowlist で反射し、Vary: Originでキャッシュ汚染を防ぐサーバ設計
製品としての全体像(Docker で自社サーバーに持つ問い合わせフォーム)は別記事「埋め込み問い合わせフォームを自社サーバーで持つ — NeNe Contact を Docker で試す」にまとめています。本記事はそのうち CORS 設計だけを深掘りする実装レシピです。
なぜ preflight を避けたいのか
CORS のリクエストには 2 種類あります。
- simple request(単純リクエスト): 条件を満たすと preflight なしで本命がそのまま飛ぶ
-
preflighted request: 本命の前に
OPTIONSで「そのメソッド/ヘッダを送っていいか」を問い合わせる
preflight が発生する主なトリガーは、ざっくり次のとおりです。
- メソッドが
GET/HEAD/POST以外(PUT/DELETE/PATCHなど) -
Content-Typeがapplication/jsonなど安全リスト外 -
Authorizationや独自ヘッダ(X-*)など、安全リスト外のリクエストヘッダを付ける
逆に言えば、メソッドを GET/POST に絞り、Content-Type を安全リストの 3 種(application/x-www-form-urlencoded / multipart/form-data / text/plain)に収め、独自ヘッダを一切付けないなら、ブラウザは preflight を出しません。
埋め込みウィジェットは「相手の環境で確実に動くこと」が最優先なので、この制約をあえて逆手に取って設計します。「JSON を送りたいから Content-Type: application/json を付ける」という当たり前の一手を捨てるだけで、preflight という壊れやすい依存が丸ごと消えます。
simple request に収める(クライアント側)
embed.js が公開 API に投げるのは 3 種類のリクエストだけです。実装のヘッダコメントにも設計意図が明記されています。
/* embed.js (抜粋)
* ... CORS "simple" requests only (GET schema, multipart upload, text/plain JSON submit) so no preflight.
*/
1. スキーマ取得: GET
フォームの項目定義は GET で取ります。GET は無条件で simple 側なので何も工夫は要りません。ポイントは credentials: 'omit'、つまり Cookie を送らないことです。公開エンドポイントは認証情報に依存しないので、資格情報付きリクエスト(credentials: 'include')が要求する厳しい CORS 条件(Access-Control-Allow-Credentials や、* を許さないなど)を最初から回避します。
fetch(api + '/schema', { credentials: 'omit' })
2. 送信: POST + text/plain
本命の送信です。ここが肝で、中身は JSON なのに Content-Type は text/plain を名乗ります。
return fetch(api + '/submissions', {
method: 'POST',
credentials: 'omit',
headers: { 'Content-Type': 'text/plain;charset=UTF-8' },
body: JSON.stringify(payload)
});
text/plain は CORS の安全リストに載っている Content-Type なので preflight は出ません。ボディは普通に JSON.stringify した JSON 文字列で、サーバ側は Content-Type を信用せずにボディを JSON としてパースする(後述)だけです。「正直に application/json と書く」のをやめる、この一手が preflight 回避の中心です。
3. 添付アップロード: POST + multipart/form-data
ファイル添付は FormData を使います。FormData を body に渡すと、ブラウザが Content-Type: multipart/form-data; boundary=... を自動で付けます。これも安全リストの 3 種のうちの 1 つなので preflight は出ません。ここでも自分で Content-Type ヘッダを手動で設定しないのがコツです(設定すると boundary が壊れ、かつ安全リスト判定から外れることがあります)。
var fd = new FormData();
fd.append('file', file);
return fetch(api + '/attachments', { method: 'POST', credentials: 'omit', body: fd });
この 3 本はいずれも「GET/POST + 安全リストの Content-Type + 独自ヘッダなし」に収まっているので、どのリクエストも preflight を発生させません。埋め込み先の CDN や WAF が OPTIONS をどう扱おうが関係ない、という状態を作れます。
サーバ側: Content-Type を信用しない
text/plain を名乗って JSON を送る以上、サーバは Content-Type ヘッダに頼らず、ボディを JSON としてパースします。送信ハンドラは受け取ったボディをそのまま JSON パーサに渡し、フォーム定義に沿ってバリデーションします(SubmitPublicFormHandler)。ここは「Content-Type ヘッダはブラウザの preflight 回避のための方便であって、実体は JSON」という割り切りです。
Origin allowlist × Vary: Origin(サーバ側)
preflight を消しても、CORS レスポンスヘッダ(Access-Control-Allow-Origin, 以下 ACAO)は依然として必要です。ブラウザは「本命レスポンスに、その Origin を許可する ACAO が付いているか」を実レスポンスの受信後にチェックし、無ければ JS からのレスポンス読み取りをブロックするからです。
ここで安直に Access-Control-Allow-Origin: * を返すと、任意のサイトからそのフォームを叩けてしまう。ADR(設計判断記録)でも「本番で * は使わない・許可した Origin だけを反射する」と決めています。
ADR 0010 §6 CORS — reflect only allowed origins; no
*in production config.
実装は公開エンドポイント専用のミドルウェア PublicCorsMiddleware で、リクエストの Origin を **フォーム単位の DB allowlist(allowed_origins)**で検証し、許可された場合だけその Origin をそのまま反射します。
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$response = $handler->handle($request);
$formKey = $this->publicFormKey($request);
$origin = $request->getHeaderLine('Origin');
if ($formKey === null || $origin === '' || !$this->originAllowed($formKey, $origin)) {
return $response;
}
return $response
->withHeader('Access-Control-Allow-Origin', $origin)
->withHeader('Vary', 'Origin');
}
設計上のポイントが 3 つあります。
1. Origin を「反射」する(* ではなく)
許可された Origin のとき、その Origin 文字列をそのまま ACAO に入れて返します。ワイルドカードは一切使いません。許可外・Origin ヘッダなし・公開パス以外のときは、CORS ヘッダを付けずに素通しします(=ブラウザ側でブロックされる)。
2. allowlist はフォーム単位で DB を引く
Origin の許可判定は、そのフォームの allowedOrigins(DB の allowed_origins_json)を引いて行います。フォームごとに「どのサイトへの埋め込みを許すか」を運用者が設定できる、という粒度です。
private function originAllowed(string $formKey, string $origin): bool
{
$form = $this->forms->findByPublicFormKey($formKey);
if ($form === null) {
return false;
}
// No configured origins => open form (mirrors SubmitPublicFormHandler::originAllowed).
return $form->allowedOrigins === [] || in_array($origin, $form->allowedOrigins, true);
}
allowlist が空配列のフォームは「オープン(どの Origin でも許可)」で、その場合も * ではなくリクエスト Origin を反射します。この「空なら許可」という挙動は、送信ハンドラ側の Origin チェック(SubmitPublicFormHandler::originAllowed)とわざと同じ仕様に揃えています。CORS ヘッダの許可判定と、実際の送信可否判定がズレていると事故るためです。
なお、対象パスは公開エンドポイントだけに絞られています。ミドルウェアは ^/public/forms/{key}/(schema|submissions|attachments)$ にマッチするパスにしか反応せず、管理画面など他の経路のレスポンスには触りません。
3. Vary: Origin を必ず付ける
ACAO の値はリクエストの Origin によって変わります。にもかかわらずキャッシュ(CDN・ブラウザ・リバースプロキシ)が Origin を考慮せずにレスポンスを使い回すと、サイト A 向けに反射した ACAO をサイト B に配ってしまう(あるいはその逆で、許可済みのはずのサイトに ACAO 無しのレスポンスが返る)というキャッシュ汚染が起きます。Vary: Origin を付けることで「このレスポンスは Origin ごとに別物だ」とキャッシュ層に伝え、これを防ぎます。Origin を反射する設計では Vary: Origin はオプションではなく必須です。
preflight を消したので OPTIONS はここで扱わない
このミドルウェアが装飾するのは 実レスポンス(非 preflight)だけです。クライアントを simple request に寄せた結果 OPTIONS は飛んでこないので、Access-Control-Allow-Methods や Access-Control-Allow-Headers を返す preflight 応答ロジックは、この公開 API 層には存在しません(OPTIONS 自体の扱いは基盤フレームワーク側の責務)。「preflight を出さない」という設計判断が、サーバ側の CORS コードを一段シンプルにしているわけです。
テストで固定している挙動
この設計は壊れると気づきにくい(ブラウザ上でしか再現しない CORS エラーになる)ので、ミドルウェアの挙動は単体テストで固定しています。
- 許可 Origin → 実レスポンスに ACAO と
Vary: Originが付く - 許可外 Origin → ACAO が付かない
- allowlist 空 → オープン扱いで Origin を反射
- 公開パス以外 → 触らない
- Origin ヘッダなし → 触らない
学び
-
埋め込みウィジェットにとって preflight は「相手の環境次第で壊れる依存」。CDN/WAF/プロキシの
OPTIONSの扱いに首根っこを掴まれる。避けられるなら避けるのが堅い。 -
simple request の制約は、埋め込みでは制約ではなく武器。
GET+text/plainの JSON +multipart+ 独自ヘッダなし、に寄せるだけで preflight という部品を丸ごと外せる。Content-Type: application/jsonを正直に付けたい気持ちをぐっとこらえるのがコツ。 -
*に逃げず、Origin をフォーム単位の allowlist で検証して反射する。許可判定は「送信可否のチェック」と同じ仕様に揃えて、CORS と実処理のズレをなくす。 -
Origin を反射したら
Vary: Originは必須。付け忘れるとキャッシュが Origin をまたいで ACAO を配り、断続的でデバッグしにくい事故になる。 - CORS のように「ブラウザ上でしか壊れない」層こそ、サーバ側の単体テストで挙動を固定しておくと安心。
一次資料
- 実装: NeNe Contact
src/Http/PublicCorsMiddleware.php(Origin 反射・allowlist 検証・Vary: Origin・公開パス判定) - 実装:
public_html/embed.js(GET schema /text/plainJSON submit /multipartattachments、いずれもcredentials: 'omit') - 実装:
src/Submission/SubmitPublicFormHandler.php(送信ハンドラ側の Origin allowlist・「空なら許可」の仕様) - テスト:
tests/Http/PublicCorsMiddlewareTest.php - 設計判断: ADR 0010 “Embed and Public API Security” §1 Allowed origins / §6 CORS
- 実装: issue #93 per-form CORS for public endpoints → 実装 PR #94(
feat/93-public-cors)
関連記事
- 製品全体の紹介(別物・製品紹介): 埋め込み問い合わせフォームを自社サーバーで持つ — NeNe Contact を Docker で試す
── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp