0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

埋め込みウィジェットで preflight を出さない CORS 設計|text/plain 送信+Origin allowlist

0
Posted at

はじめに

他社サイトに <script> 一行で貼る埋め込みウィジェットを作っていて、CORS の preflight に悩んでいる方へ。

他社サイトに <script> 一行で貼るタイプの埋め込みウィジェット(問い合わせフォーム、チャット、アンケートなど)を作ると、まず間違いなくぶつかるのが CORS です。ウィジェットは運用者のドメイン(https://ops.example.com)で動き、API は自社のドメイン(https://api.example.com)にある。つまり通信は常にクロスオリジンになります。

素朴に fetch で JSON を POST すると、ブラウザは本命のリクエストの前に OPTIONSpreflight(プリフライト) を飛ばします。この preflight が地味に厄介で、

  • 往復が 1 回増える(体感レイテンシと初回失敗率に効く)
  • サーバ側で OPTIONS を正しく捌く経路を別に用意しないといけない
  • CDN・WAF・リバースプロキシが OPTIONS を落とすと、本命が届く前に無言で死ぬ

という事故が起きます。埋め込み先の環境はこちらでコントロールできないので、preflight は「相手の環境次第で壊れる部品」になりがちです。

この記事では、埋め込みフォーム NeNe Contactembed.js と公開 API で実際に採っている設計、つまり リクエストを CORS の "simple request"(単純リクエスト)に収めて preflight そのものを発生させないアプローチと、それでも安全を保つための Origin allowlist × Vary: Origin をセットで紹介します。コードはすべて実装からの抜粋です。

この記事で分かること:

  • 埋め込みウィジェットで preflight(OPTIONS)が「相手の CDN/WAF 次第で壊れる依存」になる理由
  • リクエストを simple request(GETtext/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-Typeapplication/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-Typetext/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 を使います。FormDatabody に渡すと、ブラウザが 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-MethodsAccess-Control-Allow-Headers を返す preflight 応答ロジックは、この公開 API 層には存在しません(OPTIONS 自体の扱いは基盤フレームワーク側の責務)。「preflight を出さない」という設計判断が、サーバ側の CORS コードを一段シンプルにしているわけです。

テストで固定している挙動

この設計は壊れると気づきにくい(ブラウザ上でしか再現しない CORS エラーになる)ので、ミドルウェアの挙動は単体テストで固定しています。

  • 許可 Origin → 実レスポンスに ACAO と Vary: Origin が付く
  • 許可外 Origin → ACAO が付かない
  • allowlist 空 → オープン扱いで Origin を反射
  • 公開パス以外 → 触らない
  • Origin ヘッダなし → 触らない

学び

  • 埋め込みウィジェットにとって preflight は「相手の環境次第で壊れる依存」。CDN/WAF/プロキシの OPTIONS の扱いに首根っこを掴まれる。避けられるなら避けるのが堅い。
  • simple request の制約は、埋め込みでは制約ではなく武器GETtext/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/plain JSON submit / multipart attachments、いずれも 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

関連記事


── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?