質問と適用される場面
ページは https://app.example.com で動作しており、API は https://api.example.com にあります。フロントエンドはユーザー設定を送信します。
await fetch("https://api.example.com/profile/preferences", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify({ compactMode: true }),
})サーバーは現在、OPTIONS に対して以下のレスポンスを返しています。
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POSTcurl による直接の POST は成功しますが、ブラウザコンソールにはクロスオリジンエラーのみが報告されます。以下を説明してください。
- なぜ2つのサブドメインが依然としてクロスオリジンであるのか。
- なぜブラウザは最初に OPTIONS を送信し、いつ POST を続行するのか。
- 現在のレスポンスヘッダーの何が間違っており、最小権限の修正をどのように適用するか。
- CORS、Cookie、認証、認可、および CSRF の個別の責務。
- 開発者ツールと自動テストで修正を証明する方法。
これはフロントエンドおよびフルスタックの面接で役立ちます。テストはヘッダーのセットを 暗記して暗唱できるかどうかではありません。ブラウザのネットワークフェーズから逆算し、「リクエストは送信されたか」「クレデンシャルは付与されたか」「JavaScript はレスポンスを読み取れるか」「アプリケーションはその操作を認可したか」という4つの問いを分離できるかどうかです。
面接官が評価している点
まず、候補者はオリジンを正確に識別できる必要があります。オリジンはスキーム、ホスト、ポートで構成されます。 2つの URL はともに HTTPS を使用しており通常は same-site ですが、ホストが異なるため、リクエストは same-site かつ cross-origin となります。CORS はオリジンベースであり、Cookie の SameSite 属性はサイトベースです。これらは互換ではありません。
次に、候補者はプリフライトのトリガーを理解している必要があります。application/json は CORS セーフリストの リクエストヘッダー値ではなく、X-CSRF-Token はセーフリストのリクエストヘッダーではありません。そのため、ブラウザは OPTIONS を送信して実際のリクエストメソッドとヘッダーが許可されているかを問い合わせます。プリフライトが失敗した場合、POST は送信されません。
第3に、候補者は2つの直接的な設定エラーを見抜く必要があります。クレデンシャル付きレスポンスで Access-Control-Allow-Origin: * を使用することはできず、プリフライトレスポンスでは Content-Type も X-CSRF-Token も許可されていません。許可メソッドに POST をリストするだけでは不十分です。
最後に、候補者はセキュリティ境界を維持しなければなりません。CORS はブラウザがクロスオリジンのレスポンスを スクリプトと共有するかどうかを制御します。認証、認可、または CSRF 保護の代わりにはなりません。 サーバーは、単にコンソールエラーを消すためだけに任意のオリジンを反映させたり、すべてのメソッドやヘッダーを許可したりしてはなりません。
まず確認すべき質問
- どのネットワークフェーズで失敗しているか? リクエストが送信されない、OPTIONS のみ、または完了した POST のレスポンスが
JavaScript から利用できない、などはそれぞれ異なる障害を示しています。
- リクエストは Cookie を運ぶ必要があるか? Bearer トークンや匿名の公開リソースの場合、クレデンシャルおよび
許可オリジンポリシーが変わります。
- 具体的にどのフロントエンドオリジンが許可されているか? 本番環境、ステージング環境、ローカル開発環境には、スキームとポートを含む
明示的なオリジンが必要です。「自社のドメイン」では十分に正確ではありません。
- ゲートウェイ、認証ミドルウェア、またはリダイレクトが OPTIONS をインターセプトしていないか? プリフライトには通常
ユーザー認証情報が含まれないため、最初にログイン認証を通過することを要求することはできません。
- Cookie の Domain、Path、Secure、SameSite 属性はどうなっているか? CORS を通過したからといって、ブラウザが
Cookie を送信するとは限りません。
- 実際のリクエストおよびエラーレスポンスのすべてに CORS ヘッダーが付与されているか? 401、403、500 のレスポンスで許可オリジンヘッダーが
欠落すると、ブラウザは一般的な CORS エラーの背後に実際のステータスを隠してしまうことがあります。
- CDN や共有キャッシュは存在するか? レスポンスが Origin によって変化する場合は、あるオリジンのレスポンスが
別のオリジンに再利用されないよう Vary: Origin を送信してください。
30秒の回答フレームワーク
「これを4つの関門(オリジン、プリフライトの許可、クレデンシャルの転送、レスポンスの共有)に分割します。ホストが異なるためリクエストはクロスオリジンです。JSON の Content-Type と X-CSRF-Token が OPTIONS プリフライトをトリガーします。ブラウザは、レスポンスが正確なオリジン、POST、および両方のヘッダーを許可している場合にのみ POST を送信します。fetch が credentials: include を使用しているため、Allow-Origin をワイルドカードにすることはできません。厳密な許可リスト一致の後、サーバーは https://app.example.com、Allow-Credentials、および Vary: Origin を返すべきです。OPTIONS 自体はログイン Cookie に依存しません。POST には依然として認証、認可、および CSRF 検証が必要です。Network パネルで OPTIONS、POST、Cookie、レスポンスヘッダー、およびアプリケーションの状態を確認し、ポジティブテストおよび悪意あるオリジンのテストマトリクスを実行します。」
ステップごとの詳細解説
ステップ1:ブラウザが実際に実行するステートマシンをモデル化する。
プリフライトは概ね以下のようになります。
OPTIONS /profile/preferences HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type,x-csrf-tokenブラウザはフロントエンドとバックエンドが同じ会社に属しているかどうかを尋ねるわけではありません。 レスポンスがこの Origin、メソッド、およびヘッダーのセットを明示的に許可しているかどうかを尋ねます。以下のネットワーク証拠により、各フェーズを切り分けることができます。
| ネットワーク証拠 | 意味 | 次の確認事項 |
|---|---|---|
| OPTIONS も POST もない | JavaScript が実行されなかったか、CSP などのより早い段階のルールが URL をブロックした | まず呼び出しとコンソールを調査する |
| 失敗した OPTIONS のみ | POST は送信されなかった | ステータス、リダイレクト、allow ヘッダーを調査する |
| OPTIONS は成功したが POST に Cookie がない | CORS の許可は通過したが、クレデンシャルの転送が通過しなかった | credentials と Cookie 属性を調査する |
| POST が 401/403/500 を返すが、スクリプトには CORS としか見えない | エラーレスポンスに有効な CORS ヘッダーがない | ステータスを維持しつつ、許可オリジンヘッダーを追加する |
| POST が成功し、スクリプトがそれを読み取れる | CORS パスを通過した | アプリケーションの状態とセキュリティのネガティブケースを検証する |
ステップ2:リクエストから最小限の許可セットを導出する。
質問にある単一の本番フロントエンドに対しては、以下を返します。
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type, X-CSRF-Token
Vary: Originサーバーは Origin を厳密な許可リストと比較し、一致した場合にのみそれを書き戻す必要があります。クライアントから提供された Origin を無条件にコピーしないでください。ステージング環境やローカル環境も許可されている場合は、対応する環境ごとに完全なオリジンをリストします。接尾辞の一致では、類似した攻撃者のドメインを受け入れてしまう可能性があります。
ワイルドカードは、公開されている匿名でクレデンシャルのないリソースには適切な場合があります。このユーザー設定エンドポイントは Cookie を保持し、センシティブな操作を実行するため、特定のオリジンが必要です。プリフライトの OPTIONS は Cookie を保持しません。ゲートウェイは、Origin、メソッド、およびリクエストヘッダーを厳密に検証しつつ、認証前にこれを処理できます。
ステップ3:実際のリクエストとクレデンシャルを個別に検査する。
ブラウザは、プリフライトが成功した後にのみ POST を送信します。credentials: "include" によりクロスオリジンリクエストが クレデンシャル処理に入ることができますが、Cookie が添付されるかどうかは、Domain、Path、Secure、SameSite、およびブラウザのプライバシーポリシーが決定します。
これら2つの HTTPS サブドメインは通常 same-site かつ cross-origin です。CORS が適用されている間も SameSite によって Cookie が許可される場合があります。「CORS を通過した」ことは「Cookie が送信された」ことを意味せず、「Cookie が送信された」ことは「スクリプトがレスポンスを読み取れる」ことを意味しません。Network パネルで Request Cookies、レスポンスステータス、および CORS レスポンスヘッダーを独立した事実として検査してください。
ステップ4:CORS をアプリケーションセキュリティから分離する。
CORS はブラウザのレスポンス共有プロトコルであり、サーバー側のアクセス制御ではありません。curl やサーバー側の HTTP クライアントは、ブラウザの同一オリジンポリシー(same-origin policy)を強制しません。curl の呼び出しの成功は API がリクエストを処理できることを証明するものであり、ブラウザのパスが正しいことを証明するものではありません。
POST は依然として以下の処理を行わなければなりません。
- ユーザーを認証する。
- そのユーザーに対象リソースの変更を認可する。
- CSRF トークンを検証するか、同等に強力な防御策を使用する。
- 入力を検証し、冪等性または重複送信時の挙動を定義する。
- 許可されたオリジンからの 401、403、500 レスポンスに正しい CORS ヘッダーを返し、フロントエンドが実際のエラーを確認できるようにする。
「悪意あるページがレスポンスを読み取れない」ことは、「リクエストに副作用がなかった」ことを意味しません。一部のフォーム風の クロスオリジンリクエストはプリフライトを必要とせず、サーバーに到達可能です。センシティブな書き込み処理において、CSRF 保護を CORS のみに依存することはできません。
ステップ5:ポジティブテストとネガティブテストで境界を証明する。
修正後、少なくとも以下を検証します。
| ケース | 期待される結果 |
|---|---|
| 許可されたオリジンが指定されたリクエストを送信する | OPTIONS 204、その後に POST。スクリプトが実際のレスポンスを読み取る |
| 許可されたオリジンが CSRF トークンを省略する | プロトコル設定によってプリフライトが決定され、アプリケーションの検証が書き込みを拒否する |
| 許可されていないオリジンが同じリクエストを送信する | そのオリジンを許可する CORS ヘッダーがなく、プリフライトを通過しない |
| リクエストが許可されていないメソッドまたはヘッダーを使用する | プリフライトが失敗し、POST は送信されず、状態は変更されない |
| ログイン Cookie が期限切れである | POST が読み取り可能な 401 を返し、フロントエンドがログインフローを開始する |
| サーバーが 500 を返す | フロントエンドに一般的な CORS メッセージではなく、500 と構造化されたエラーが表示される |
| 繰り返しリクエストがプリフライトキャッシュを使用する | 挙動が正しいままである。設定の変更はコールドキャッシュでもテストされる |
これをサーバーログと照合します。失敗したプリフライトには、一致する POST や設定の更新があってはなりません。ログには Origin、メソッド、ヘッダー名、および拒否理由を記録できますが、Cookie や CSRF トークンの値は記録してはなりません。
優れた回答例
「まずネットワークフェーズを特定します。app.example.com と api.example.com はホストが異なるため、 same-site であってもクロスオリジンです。POST は JSON の Content-Type と X-CSRF-Token を使用しているため、 ブラウザはまず Cookie なしで OPTIONS リクエストを送信します。これには Origin、POST、および2つの 非シンプルヘッダーが宣言されます。ブラウザは、プリフライトレスポンスが3つすべてを許可している場合にのみ POST を送信します。
現在の設定には2つの直接的な欠陥があります。第1に、フロントエンドが credentials: include を設定しているため、 クレデンシャル付きレスポンスで Access-Control-Allow-Origin にワイルドカードを使用することはできません。第2に、レスポンスに Access-Control-Allow-Headers: Content-Type, X-CSRF-Token が含まれていません。私は Origin をサーバーの厳密な 許可リストと照合して検証します。https://app.example.com に対しては、その特定の値、Allow-Credentials、POST、 両方の許可ヘッダー、および Vary: Origin を返します。OPTIONS はユーザー認証ミドルウェアの前に処理できますが、 許可されていないオリジン、メソッド、またはヘッダーは依然として拒否されます。
プリフライト通過後、POST の Request Cookies を確認します。credentials: include は必要ですが十分ではありません。 Domain、Path、Secure、SameSite、およびブラウザのポリシーが依然として適用されます。CORS はアクセス制御ではなくレスポンス共有を制御するため、POST は引き続き認証、認可、および CSRF 検証を行わなければなりません。
検証のために、Network パネルで OPTIONS が成功した後にのみ POST が現れることを確認し、 Cookie、実際のステータス、およびレスポンスヘッダーを検査します。許可されたオリジン、許可されていないオリジン、 許可されていないメソッド、期限切れのログイン、および 500 レスポンスをテストします。サーバーログは、失敗したプリフライトに POST がなく状態の変更もないことを証明しなければなりません。curl は API のベースラインであり、ブラウザの CORS 証拠の代わりにはなりません。」
よくある間違い
- same-site を same-origin として扱う → 異なるサブドメインは依然として CORS を呼び出す → **スキーム、ホスト、
ポートを比較する。**
- Allow-Methods に POST があれば十分だと考える → 非シンプルヘッダーが許可されていない → **Origin、
Method、および Headers をセットで確認する。**
- クレデンシャル付きで
Allow-Origin: *を使用する → ブラウザがレスポンスの公開を拒否する → **許可リストにある
正確なオリジンを返す。**
- リクエストされたすべての Origin を反映する → 任意のサイトがクレデンシャル付きレスポンスへの読み取りアクセス権を取得する可能性がある → **まず
厳密な許可リストと照合する。**
- OPTIONS をログイン済みユーザーとして認証する → プリフライトには通常クレデンシャルが含まれない → **ユーザー認証の前に
制限されたプリフライトチェックを実行する。**
- 解決策として
mode: "no-cors"を使用する → フロントエンドが読み取り不能な opaque レスポンスを受け取る → **サーバーの
CORS プロトコルを修正する。**
- CORS を CSRF 保護として扱う → リクエストがサーバーに到達して副作用を引き起こす可能性がある → **センシティブな書き込み処理では
CSRF チェックと認可チェックを維持する。**
- CORS ヘッダーを 2xx レスポンスにのみ追加する → 401 や 500 がクロスオリジンエラーに偽装される → **許可されたオリジンに対するエラーには
一貫した CORS ヘッダーを使用する。**
- curl だけでテストする → curl はブラウザの同一オリジンポリシーを強制しない → **実際のブラウザの
ネットワーク証拠で検証する。**
フォローアップの質問
なぜリクエストが OPTIONS をスキップしても CORS で失敗することがあるのですか?
セーフリストのメソッド、ヘッダー、および Content-Type を使用するリクエストは直接送信できます。ブラウザはそれでも 実際のレスポンスの CORS ヘッダーをチェックします。レスポンスの共有が許可されていない場合、サーバーがリクエストを処理したとしても JavaScript はそれを読み取ることができません。ネットワークの証拠により、「送信されたが共有されなかった」と「プリフライトの失敗」を区別する必要があります。
なぜ Access-Control-Allow-Credentials はプリフライトリクエストに現れてはいけないのですか?
これは、ブラウザがクレデンシャルモードで行われた実際のリクエストのレスポンスを公開してよいことを示すサーバーレスポンスヘッダーです。クライアントは Fetch の credentials モードを通じてその意図を表明します。プリフライト自体にはユーザー認証情報が含まれません。実際のレスポンスも、特定のオリジンおよび Allow-Credentials の条件を満たす必要があります。
Vary: Origin はどのような問題を解決しますか?
サーバーがリクエストの Origin に基づいて Access-Control-Allow-Origin を選択する場合、共有キャッシュはそのキャッシュキーに Origin を含める必要があります。そうしないと、ある許可されたオリジン用のレスポンスが別のオリジンに再利用され、 不適切な拒否や情報の漏洩が発生する可能性があります。
CORS を修正した後でも Cookie が送信されないのはなぜですか?
credentials: "include" を確認し、次に Cookie の Domain、Path、Secure、SameSite、有効期限、および ブラウザのプライバシー制限を検査してください。CORS の許可と Cookie の転送は隣接していますが、独立した関門です。
複数の正当なフロントエンドドメインはどのようにサポートすべきですか?
厳密な許可リストを維持し、完全なリクエスト Origin を検証し、一致した値を書き戻して Vary: Origin を送信します。複数の Allow-Origin 値を返したり、寛容な正規表現、接尾辞の一致、 または無条件の反映を使用したりしてはいけません。
フロントエンド単体でサーバーの CORS エラーを修正できますか?
いいえ。フロントエンドは不要な非シンプルヘッダーを削除したり、意図した credentials モードを選択したりすることはできますが、 クロスオリジンの共有を許可するレスポンスヘッダーを返す必要があるのは API、リバースプロキシ、またはゲートウェイです。本番環境の コードがブラウザの拡張機能やセキュリティ制御の無効化に依存してはなりません。
プリフライトはどのくらいの期間キャッシュすべきですか?
設定の変更頻度、取り消しの必要性、およびブラウザの制限に基づいて期間を選択してください。CORS の変更を リリースする際は、コールドキャッシュと既存のキャッシュされたパスの両方をテストしてください。キャッシュは OPTIONS のトラフィックを削減しますが、 不正な許可を長引かせる可能性があるため、設定の欠陥を覆い隠してはなりません。