CORSは認証ではない:設計原理
一言でいうと
オリジン・メソッド・ヘッダーの許可マトリクスを、実際のプリフライトで検証します。
なぜ必要なのか
フロントエンドでレスポンスを読めなかったので、すべてのオリジンにアスタリスクを許可しました。クッキーを送るリクエストでは、ポリシーがさらに複雑になり、開発者は、CORSを有効にするだけで外部からのリクエストが遮断されると誤解しました。このラボでは、ブラウザーの読み取りポリシーと、サーバーの認証を分離します。認証の機能を代わりに作ることはしません。
どう動くのか
オリジンは、scheme・host・portの組み合わせです。入力の設定にパスや認証情報が入っていたら拒否します。許可するオリジンを正確に比較し、認証情報を許可するときは、ワイルドカードを禁止します。CORSMiddlewareに、許可するメソッドとリクエストヘッダーを明示します。OPTIONSのプリフライトが成功する場合と、オリジン・メソッド・ヘッダーが原因で失敗する場合を、それぞれ再現します。
Origin + 요청 메서드 + 요청 헤더 → OPTIONS 정책 확인
허용: 출처/자격 헤더 제공 → 브라우저가 실제 요청
거절: 읽기 권한 없음 ≠ 서버 인증
契約を読んで失敗を予測するワークシート
以下は、実装をまるごと暗記するための答案ではなく、ステップごとのコードレビューです。各変更の断片は、意図的に契約を壊しています。変更したあとでも、正常な例は通ることがある点に注意してください。実行する前に、どの入力・例外・状態を観測すれば違いが表に出るかを予想し、実装したあとで、その予想と結果を比べます。
1. オリジンの形式を検証する
origin(value)は、httpまたはhttpsのURLで、hostがあり、path・query・fragment・ユーザー情報がなければ、入力の文字列を返します。それ以外は、ValueErrorです。末尾の/もpathなので、拒否します。
判断の根拠: URL全体をオリジンとして許可すると、パスやユーザー情報を混同することがあります。
レビューする誤った変更の断片:
or url.query
この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。
2. 重複したオリジンを除去する
origins(values)は、各項目をoriginで検証したあと、最初に出てきた順序で重複を除去した新しいリストです。
判断の根拠: 許可リストは、文字列の部分一致ではなく、正確なオリジンのリストです。
レビューする誤った変更の断片:
[origin(value) for value in values]
この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。
3. メソッドを許可リストで制限する
methods(values)は、GET・POST・PUT・DELETE・OPTIONSだけを許可して、大文字に変換し、重複を除去します。空のリストやそれ以外の値は、ValueErrorです。
判断の根拠: 許可していないPATCHや任意のメソッドを、黙って追加しません。
レビューする誤った変更の断片:
"DELETE","OPTIONS","PATCH"
この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。
4. 認証情報とアスタリスクを一緒に許可しない
policy(allowed, credentials)は、credentialsがboolであることを確認します。allowedに'*'があればValueErrorで、{allow_origins:origins(allowed), allow_credentials:credentials}を返します。
判断の根拠: このラボの明示的なポリシーは、認証情報の有無にかかわらず、アスタリスクを受け付けません。
レビューする誤った変更の断片:
not isinstance(credentials, (bool, int))
この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。
5. 実際のCORSミドルウェアを付ける
create_app(allowed, credentials=True)は、policyを検証して、CORSMiddlewareを設定したアプリです。GET/POSTだけを許可し、Content-Type・X-Request-IDのリクエストヘッダーを許可し、X-Traceのレスポンスヘッダーをexposeします。GET /dataは、{ok:True}、X-Trace='trace-1'を返します。
判断の根拠: preflightと実際のレスポンスに、ヘッダーを手で別々に付けると、2つのポリシーが簡単にずれます。
レビューする誤った変更の断片:
expose_headers=[]
この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。
6. プリフライトのリクエストを作る
preflight_headers(source, method, requested='X-Request-ID')は、Origin、Access-Control-Request-Method、Access-Control-Request-Headersの3つのキーを持つ辞書です。methodは大文字です。
判断の根拠: 実際のリクエストのメソッドはOPTIONSで、検査したいメソッドは、別のヘッダーにあります。
レビューする誤った変更の断片:
method.lower()
この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。
7. 拒否のマトリクスを計算する
preflight_status(app, source, method, requested='X-Request-ID')は、TestClientで/dataにOPTIONSのリクエストを送って、HTTPのステータスを返します。別のオリジン・DELETE・X-Secretヘッダーは、400でなければなりません。
判断の根拠: 拒否の理由の3種類を、1つのリクエストに混ぜないようにしてはじめて、抜けているポリシーを見つけられます。
レビューする誤った変更の断片:
client.get("/data",
この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。
8. CORSと認証の違いを観察する
cors_observation(app, source)は、GET /dataを送って、(ステータス、Access-Control-Allow-Originの値またはNone、JSONの本文)を返します。許可されていないオリジンでも、200の本文は実行されますが、許可するオリジンのヘッダーは付いていてはいけません。
判断の根拠: curlやサーバー間のリクエストは、ブラウザーのCORSの読み取り制限に従いません。
レビューする誤った変更の断片:
source
この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。
現場での姿
TestClientは、ブラウザーではありません。CORSのレスポンスヘッダーとプリフライトを検査しますが、ブラウザー自体の読み取りのブロックまでは実装しません。許可されていないOriginを送った通常のGETも、サーバーで実行されることがあります。機密性のある動作は、別の認証・権限・CSRFのポリシーで保護する必要があります。
次のラボですること
8つのステップが、1つの実行可能な成果物につながります。オリジンの形式を検証する → 重複したオリジンを除去する → メソッドを許可リストで制限する → 認証情報とアスタリスクを一緒に許可しない → 実際のCORSミドルウェアを付ける → プリフライトのリクエストを作る → 拒否のマトリクスを計算する → CORSと認証の違いを観察する、という流れです。
各ステップは、関数やファイルが存在するという事実ではなく、実際の戻り値・例外・状態の変化を検査します。正解を見たあとには、わざと境界の比較や後始末のコードを変えて、どのテストが失敗するかを確認してください。前のテストが次のステップでも維持される理由を説明し、このラボが保証しない本番の条件を1つ書いてみてください。