なぜCORSエラーは起きるのか?その原因と根本的な解決策を理解する

ウェブ開発に携わる者であれば、誰もが一度は赤い文字で表示されるコンソール上の「CORSエラー」に頭を悩ませた経験があるでしょう。


  Access to XMLHttpRequest at 'http://api.example.com/data' from origin 'http://my-app.com' has been blocked by CORS policy: 
  No 'Access-Control-Allow-Origin' header is present on the requested resource.
このようなメッセージは、特にフロントエンドとバックエンドが分離したモダンなアーキテクチャで開発を進めている際に頻繁に遭遇する壁です。多くの開発者は、このエラーを単なる厄介な障害と捉え、Stack Overflowで見つけた解決策を急いで適用しがちです。しかし、CORS(Cross-Origin Resource Sharing / オリジン間リソース共有)の本質を理解しないまま場当たり的な対応を続けることは、深刻なセキュリティ脆弱性を生む危険性をはらんでいます。この記事では、CORSエラーがなぜ存在するのか、その背後にあるウェブセキュリティの fundamental な原則から説き起こし、エラーの根本原因を解明し、安全かつ効果的な解決策を深く探求していきます。

CORSは、私たちを困らせるために存在するわけではありません。むしろ、私たちのアプリケーションとユーザーを悪意ある攻撃から守るための重要なセキュリティ機能なのです。この旅を通じて、CORSエラーを単なる「解決すべき問題」から、「理解し、正しく使いこなすべき強力なツール」へと視点を変えていきましょう。

ウェブの安全性を支える大原則 同一オリジンポリシー(Same-Origin Policy)

CORSの議論を始める前に、その存在理由である「同一オリジンポリシー(Same-Origin Policy, SOP)」について理解することが不可欠です。同一オリジンポリシーは、ウェブブラウザが持つ最も基本的なセキュリティモデルの一つであり、ある「オリジン」から読み込まれたドキュメントやスクリプトが、他の「オリジン」のリソースにアクセスすることを制限する仕組みです。

では、「オリジン」とは何でしょうか?オリジンは、以下の3つの要素の組み合わせによって定義されます。

  • プロトコル (例: http, https)
  • ホスト(ドメイン) (例: www.example.com, api.example.com)
  • ポート番号 (例: 80, 443, 3000)

これら3つがすべて一致する場合にのみ、ブラウザはそれらを「同一オリジン」と判断します。一つでも異なれば、それは「クロスオリジン」または「別オリジン」と見なされます。

同一オリジンポリシーの判定例 (基準URL: http://www.example.com/index.html)
アクセス先URL 判定結果 理由
http://www.example.com/app/main.js 同一オリジン プロトコル、ホスト、ポートがすべて一致
https://www.example.com/index.html 別オリジン プロトコルが異なる (http vs https)
http://api.example.com/data 別オリジン ホスト(サブドメイン)が異なる (www vs api)
http://www.example.com:8080/index.html 別オリジン ポート番号が異なる (80 vs 8080)

このポリシーがなければ、悪意のあるウェブサイトが、ユーザーがログインしている銀行やSNSサイトの情報を、ユーザーの知らないうちにJavaScriptを使って盗み出すことが可能になってしまいます。例えば、あなたがオンラインバンクにログインしている状態で、悪意のある別のサイトを開いたとします。もしSOPがなければ、その悪意あるサイトのスクリプトがあなたのブラウザから銀行サイトのAPIにリクエストを送り、残高照会や送金といった操作を勝手に行えてしまうかもしれません。SOPは、このようなクロスサイトリクエストフォージェリ(CSRF)攻撃などを防ぐための、ウェブの砦なのです。

// 同一オリジンポリシーによる保護のイメージ

+----------------------------------+         +----------------------------------+
|      Origin A (my-bank.com)      |         |   Origin B (evil-site.com)       |
|                                  |<---X----|                                  |
|  [ User's Sensitive Data ]       |         |  [ Malicious JavaScript ]        |
|  - Account Balance               |         |  // tries to fetch data from A   |
|  - Personal Info                 |         |  fetch('https://my-bank.com/api')|
+----------------------------------+         +----------------------------------+
          ^
          |
    BROWSER BLOCKS THIS REQUEST
    (Due to Same-Origin Policy)

しかし、現代のウェブアプリケーション開発では、APIサーバーをフロントエンドと別のドメイン(またはサブドメイン)で運用したり、CDNからフォントやライブラリを読み込んだり、複数のマイクロサービスが連携したりと、正当な理由でオリジンを越えたリソース共有が必要になる場面が非常に多くなりました。この厳格なセキュリティポリシーの壁を、安全に乗り越えるための仕組みこそがCORSなのです。

CORSの仕組み Webブラウザとサーバーの対話

CORSは、サーバーが特定のオリジンからのリクエストを許可するかどうかをブラウザに伝えるための、HTTPヘッダーに基づいたメカニズムです。重要なのは、CORSの制御はブラウザが行うという点です。サーバー側でリクエストをブロックしているわけではありません。実際には、サーバーはリクエストを受信してレスポンスを返しますが、そのレスポンスに適切なCORSヘッダーが含まれていない場合、ブラウザがレスポンスをJavaScriptに渡すことを拒否し、コンソールにエラーを表示するのです。

このプロセスは、リクエストの種類によって「シンプルリクエスト」と「プリフライトリクエスト」の2つの主要なシナリオに分かれます。

シナリオ1 シンプルリクエスト(Simple Requests)

特定の条件を満たすリクエストは「シンプルリクエスト」として扱われ、事前の確認なしに直接リクエストが送信されます。条件は以下の通りです。

  • メソッドが以下のいずれかであること:
    • GET
    • HEAD
    • POST
  • HTTPヘッダーが以下のもののみで構成されていること:
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type (ただし、値が application/x-www-form-urlencoded, multipart/form-data, text/plain のいずれかの場合に限る)
  • リクエストに使用されるどのXMLHttpRequestUploadオブジェクトにもイベントリスナーが登録されていないこと。
  • リクエストでReadableStreamオブジェクトが使用されていないこと。

これらの条件は、HTMLの<form>タグが歴史的に送信できたリクエストの形式と似ており、後方互換性を保ちつつ、比較的安全と見なされるリクエストです。

シンプルリクエストのフロー

  1. リクエスト送信: ブラウザは、リクエストヘッダーに自動的に Origin ヘッダーを追加して、サーバーにリクエストを送信します。Origin ヘッダーには、リクエスト元のオリジン(例: http://my-app.com)が含まれます。
  2. サーバーの処理とレスポンス: サーバーはリクエストを受け取り、リソースへのアクセスを許可するかどうかを判断します。許可する場合、レスポンスヘッダーに Access-Control-Allow-Origin を含めて返します。このヘッダーの値には、許可するオリジン(例: http://my-app.com)またはワイルドカード(*)を指定します。
  3. ブラウザの検証: ブラウザはレスポンスを受け取ると、Access-Control-Allow-Origin ヘッダーを確認します。このヘッダーが存在し、その値がリクエストの Origin ヘッダーの値と一致する(または * である)場合、リクエストは成功と見なされ、レスポンスデータがJavaScriptコードに渡されます。一致しない、またはヘッダーが存在しない場合、ブラウザはレスポンスを破棄し、CORSエラーを発生させます。
   Browser (my-app.com)                            Server (api.example.com)
            |                                                 |
            |   1. Request with Origin header                 |
            |  --------------------------------------------> |
            |   GET /data                                     |
            |   Host: api.example.com                         |
            |   Origin: http://my-app.com                     |
            |                                                 |
            |                                    (Process Request)
            |                                                 |
            |   2. Response with ACAO header                  |
            |  <-------------------------------------------- |
            |   200 OK                                        |
            |   Access-Control-Allow-Origin: http://my-app.com|
            |   Content-Type: application/json                |
            |   {"message": "Success!"}                       |
            |                                                 |
(Check if Origin matches ACAO)                                |
            |                                                 |
 (Success! Provide data to JS)                                |
            |

シナリオ2 プリフライトリクエスト(Preflighted Requests)

シンプルリクエストの条件を満たさないリクエスト、例えば PUT, DELETE, PATCH といったメソッドを使用する場合や、Content-Typeapplication/json である場合、あるいはカスタムヘッダー(例: X-Auth-Token)を送信する場合、ブラウザは実際のリクエストを送信する前に、「プリフライト(preflight)」と呼ばれる事前確認リクエストを自動的に送信します。

プリフライトリクエストは、HTTPのOPTIONSメソッドを使用し、サーバーに対して、これから送ろうとしている実際のリクエストが許可されるかどうかを問い合わせるものです。これは、サーバーに影響を与える可能性のあるリクエスト(データの作成、更新、削除など)を不用意に送信してしまうことを防ぐための、非常に重要な安全策です。

プリフライトリクエストのフロー

  1. プリフライトリクエスト送信: ブラウザは、実際のリクエストを一時停止し、OPTIONSメソッドでプリフライトリクエストを送信します。このリクエストには、以下のヘッダーが含まれます。
    • Origin: 実際のリクエスト元のオリジン。
    • Access-Control-Request-Method: 実際のリクエストで使用するHTTPメソッド(例: PUT)。
    • Access-Control-Request-Headers: 実際のリクエストで使用するカスタムヘッダー(例: Content-Type, X-Auth-Token)。
  2. サーバーの処理とプリフライトレスポンス: サーバーはプリフライトリクエストを受け取り、指定されたメソッドやヘッダーでのリクエストを許可するかどうかを判断します。許可する場合、HTTPステータスコード 200 OK または 204 No Content とともに、以下のCORSヘッダーを含むレスポンスを返します。
    • Access-Control-Allow-Origin: リクエストを許可するオリジン。
    • Access-Control-Allow-Methods: 許可するHTTPメソッドのリスト(例: GET, POST, PUT, DELETE)。
    • Access-Control-Allow-Headers: 許可するリクエストヘッダーのリスト(例: Content-Type, X-Auth-Token)。
    • Access-Control-Max-Age (任意): プリフライトリクエストの結果をブラウザがキャッシュしてよい秒数。これにより、同じリクエストを繰り返す際に毎回プリフライトを送信する必要がなくなり、パフォーマンスが向上します。
  3. ブラウザの検証: ブラウザはプリフライトレスポンスを受け取り、その内容がこれから送る実際のリクエストの条件を満たしているか検証します。
  4. 実際のリクエストの送信(または中止):
    • 許可された場合: ブラウザは、プリフライトが成功したと判断し、本来送りたかった実際のリクエスト(例: PUTリクエスト)を送信します。この後の流れはシンプルリクエストと同様です。
    • 許可されなかった場合: サーバーが適切なCORSヘッダーを返さなかったり、リクエストされたメソッドやヘッダーが許可リストに含まれていなかったりした場合、ブラウザはプリフライトが失敗したと判断し、実際のリクエストを送信することなく、コンソールにCORSエラーを表示します。
Browser (my-app.com)                                     Server (api.example.com)
        |                                                          |
        |  1. Preflight Request (OPTIONS)                          |
        | -------------------------------------------------------> |
        |  OPTIONS /items/123                                      |
        |  Host: api.example.com                                   |
        |  Origin: http://my-app.com                               |
        |  Access-Control-Request-Method: PUT                      |
        |  Access-Control-Request-Headers: Content-Type            |
        |                                                          |
        |                                              (Check permissions)
        |                                                          |
        |  2. Preflight Response                                   |
        | <------------------------------------------------------- |
        |  204 No Content                                          |
        |  Access-Control-Allow-Origin: http://my-app.com          |
        |  Access-Control-Allow-Methods: GET, PUT, DELETE          |
        |  Access-Control-Allow-Headers: Content-Type              |
        |  Access-Control-Max-Age: 86400                           |
        |                                                          |
(Preflight OK. Now send actual request)                            |
        |                                                          |
        |  3. Actual Request (PUT)                                 |
        | -------------------------------------------------------> |
        |  PUT /items/123                                          |
        |  Host: api.example.com                                   |
        |  Origin: http://my-app.com                               |
        |  Content-Type: application/json                          |
        |  {"name": "new name"}                                    |
        |                                                          |
        |                                               (Process Request)
        |                                                          |
        |  4. Actual Response                                      |
        | <------------------------------------------------------- |
        |  200 OK                                                  |
        |  Access-Control-Allow-Origin: http://my-app.com          |
        |  {"message": "Item updated"}                             |
        |

このプリフライトの仕組みを理解することが、CORSエラーのトラブルシューティングにおいて極めて重要です。「APIを叩いても、なぜかDELETEリクエストだけが失敗する」といった問題は、多くの場合、サーバーがプリフライトリクエストに対してAccess-Control-Allow-MethodsヘッダーでDELETEを許可していないことが原因です。

実践編 サーバーサイドでのCORS設定

CORSエラーを解決するためには、フロントエンドのコード(JavaScript)を修正するのではなく、バックエンドのサーバーアプリケーションを修正する必要があります。サーバーが正しいCORSヘッダーをレスポンスに含めるように設定することが唯一の根本的な解決策です。ここでは、一般的なウェブサーバーやフレームワークでの設定例を見ていきましょう。

Node.js (Express) での設定

Node.jsのフレームワークであるExpressでは、corsというミドルウェアパッケージを使用するのが最も簡単で一般的です。まず、パッケージをインストールします。


npm install cors

そして、Expressアプリケーションにミドルウェアとして組み込みます。


const express = require('express');
const cors = require('cors');
const app = express();

// --- 基本的な設定: すべてのオリジンからのリクエストを許可 ---
// ※ 本番環境では非推奨!
// app.use(cors());

// --- 推奨される設定: 特定のオリジンのみを許可 ---
const allowedOrigins = ['http://localhost:3000', 'https://my-app.com', 'https://www.my-app.com'];

const corsOptions = {
  origin: function (origin, callback) {
    // originがない場合(Postmanなどからのリクエスト)も許可する場合
    if (!origin || allowedOrigins.indexOf(origin) !== -1) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', // 許可するメソッド
  allowedHeaders: ['Content-Type', 'Authorization'], // 許可するヘッダー
  credentials: true, // Cookieなどの認証情報を含むリクエストを許可する場合
  optionsSuccessStatus: 200 // 一部の古いブラウザのための設定
};

app.use(cors(corsOptions));

// プリフライトリクエストに明示的に対応する場合(corsミドルウェアが自動でやってくれるが、理解のために)
// app.options('*', cors(corsOptions));

app.get('/api/data', (req, res) => {
  res.json({ message: 'This is CORS-enabled for specific origins!' });
});

const PORT = 8080;
app.listen(PORT, () => {
  console.log(`Server listening on port ${PORT}`);
});

この例では、許可するオリジンのリストを定義し、リクエストのOriginヘッダーがそのリストに含まれている場合のみアクセスを許可しています。これにより、Access-Control-Allow-Origin: * を使うよりもはるかに安全な設定になります。

Apacheでの設定

Apacheウェブサーバーでは、.htaccessファイルや設定ファイル (httpd.confなど) にmod_headersモジュールを使って設定を追加します。


# mod_headersが有効になっていることを確認してください
# LoadModule headers_module modules/mod_headers.so

<IfModule mod_headers.c>
    # 特定のオリジンからのリクエストを許可
    SetEnvIf Origin "^(https?://(www\.)?my-app\.com)$" AccessControlAllowOrigin=$0
    Header set Access-Control-Allow-Origin %{AccessControlAllowOrigin}e env=AccessControlAllowOrigin
    Header set Vary Origin

    # 許可するメソッド
    Header set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"

    # 許可するヘッダー
    Header set Access-Control-Allow-Headers "Content-Type, Authorization"

    # 認証情報(Cookieなど)を許可
    Header set Access-Control-Allow-Credentials "true"

    # プリフライトリクエストのキャッシュ時間(秒)
    Header set Access-Control-Max-Age "86400"

    # OPTIONSメソッドへのレスポンスを正しく処理
    # プリフライトリクエストは本文なしで204を返すのが一般的
    RewriteEngine On
    RewriteCond %{REQUEST_METHOD} OPTIONS
    RewriteRule ^(.*)$ $1 [R=204,L]
</IfModule>

SetEnvIfディレクティブを使ってリクエストのOriginヘッダーを動的にチェックし、許可リストにあるオリジンの場合にのみレスポンスヘッダーを設定しているのがポイントです。Vary: Originヘッダーは、同じURLでもOriginヘッダーによってレスポンスが異なることをキャッシュサーバーに伝え、意図しないキャッシュが返されるのを防ぐために重要です。

Nginxでの設定

Nginxでは、serverブロックまたはlocationブロックに設定を記述します。


server {
    listen 80;
    server_name api.example.com;

    # ... 他の設定 ...

    location / {
        # 許可するオリジンを正規表現で指定
        # 複数のオリジンを許可する場合は '|' で区切る
        set $cors_origin "";
        if ($http_origin ~* (https?://(www\.)?my-app\.com|https?://localhost:3000)) {
            set $cors_origin $http_origin;
        }

        # プリフライトリクエスト(OPTIONS)への対応
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' $cors_origin;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
            add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
            add_header 'Access-Control-Allow-Credentials' 'true';
            add_header 'Access-Control-Max-Age' 86400;
            add_header 'Content-Type' 'text/plain charset=UTF-8';
            add_header 'Content-Length' 0;
            return 204;
        }

        # 実際のリクエストへの対応
        if ($request_method ~* '(GET|POST|PUT|DELETE)') {
            add_header 'Access-Control-Allow-Origin' $cors_origin;
            add_header 'Access-Control-Allow-Credentials' 'true';
            add_header 'Vary' 'Origin';
        }

        # アプリケーションサーバーへのプロキシ
        proxy_pass http://your_backend_app;
    }
}

Nginxの設定は少し複雑に見えるかもしれませんが、ifディレクティブを使ってリクエストメソッド(OPTIONSかそれ以外か)に応じて返すヘッダーを細かく制御しています。これにより、プリフライトリクエストと実際のリクエストの両方に正しく対応できます。

よくあるCORSの落とし穴とトラブルシューティング

CORSの仕組みと設定方法を理解しても、なお予期せぬエラーに遭遇することがあります。ここでは、開発現場でよく見られるいくつかの典型的な問題とその解決策を探ります。

1. `Access-Control-Allow-Origin: *` と認証情報の組み合わせ

問題: 開発初期段階で手っ取り早くCORSを許可するために Access-Control-Allow-Origin にワイルドカード(*)を設定した。しかし、フロントエンドからCookieやAuthorizationヘッダーを含むリクエスト(fetchcredentials: 'include'など)を送信すると、エラーが発生する。

原因: セキュリティ上の理由から、ブラウザは Access-Control-Allow-Credentials: true が指定されたリクエストに対して、Access-Control-Allow-Origin ヘッダーにワイルドカード(*)を使用することを許可しません。認証情報を不特定のオリジンに送信するのは危険だからです。認証情報を扱う場合、サーバーはリクエストを許可する特定のオリジンを明示的に指定する必要があります。

解決策: サーバーサイドのロジックを修正し、リクエストのOriginヘッダーの値をそのままAccess-Control-Allow-Originヘッダーの値として返すようにします(ただし、事前に許可リストで検証することが強く推奨されます)。


// Expressでの動的オリジン設定の例
const allowedOrigins = ['https://my-app.com'];
const corsOptions = {
  origin: function (origin, callback) {
    if (allowedOrigins.indexOf(origin) !== -1) {
      callback(null, true) // originをそのまま返す
    } else {
      callback(new Error('Not allowed by CORS'))
    }
  },
  credentials: true, // これをtrueにする
};
app.use(cors(corsOptions));

2. リダイレクトとCORS

問題: APIエンドポイントがリダイレクト(HTTP 301, 302など)を返す設定になっている。クロスオリジンリクエストを行うと、リダイレクト先でCORSエラーが発生する。

原因: クロスオリジンリクエストがリダイレクトされる際、ブラウザのセキュリティモデルは複雑な挙動を示します。リダイレクトの過程でOriginヘッダーが失われたり、プリフライトリクエストが正しく処理されなかったりすることがあります。また、リダイレクト元とリダイレクト先の両方のサーバーが、正しくCORSヘッダーを返す必要があります。

解決策: 最もシンプルな解決策は、サーバーサイドでリダイレクトを返さず、直接最終的なリソースを返すようにAPIの設計を見直すことです。それが難しい場合は、リダイレクトに関わるすべてのエンドポイントで、CORSが正しく設定されていることを確認する必要があります。

3. `Origin`ヘッダーが `null` になるケース

問題: ローカルのHTMLファイルをブラウザで直接開いて(file:///...)、APIにリクエストを送信するとCORSエラーになる。コンソールを見ると、リクエストのOriginヘッダーがnullになっている。

原因: file:// スキーマから読み込まれたドキュメントや、サンドボックス化されたiframeからのリクエストなど、特定の状況下ではオリジンが明確に定義できないため、ブラウザはOriginヘッダーの値をnullとして送信します。サーバー側がAccess-Control-Allow-Origin: nullを返すように設定していない限り、これはCORSエラーを引き起こします。

解決策: ローカルでの開発であっても、必ずローカルウェブサーバー(Live Server for VS Code, Pythonのhttp.server, Node.jsのserveなど)を立てて、http://localhost:xxxxのようなオリジンからアプリケーションにアクセスするようにしてください。これにより、リクエストには常に有効なオリジン(例: http://localhost:3000)が含まれるようになります。本番環境でnullオリジンを許可することはセキュリティリスクになり得るため、避けるべきです。

4. エラーレスポンスとCORS

問題: APIが404 Not Foundや500 Internal Server Errorなどのエラーレスポンスを返す際にCORSエラーが発生する。成功時(200 OK)のレスポンスでは問題ない。

原因: CORSヘッダーを付与するロジックが、正常系の処理パスにしか実装されていない可能性があります。アプリケーションでエラーが発生した場合、ミドルウェアやフレームワークのデフォルトのエラーハンドリング機構が働き、CORSヘッダーを付与する処理がスキップされてしまうことがあります。

解決策: CORSヘッダーを付与するミドルウェアが、エラーレスポンスを含むすべてのレスポンスに対して適用されるように設定してください。Expressであれば、ルートハンドラの前にapp.use(cors())を配置することで、後続のすべてのレスポンスにヘッダーが適用されます。フレームワークのエラーハンドリング機構をカスタマイズして、エラー時にもCORSヘッダーを付与するようにすることも有効です。

まとめ CORSは敵ではなく、味方である

この記事を通じて、CORSエラーが決して単なる開発上の障害ではなく、ウェブの安全性を根幹から支える「同一オリジンポリシー」という原則を、現代的なウェブアーキテクチャの要求に合わせて柔軟に運用するための洗練された仕組みであることを理解いただけたかと思います。

CORSエラーに遭遇したとき、私たちは反射的に「どうすればこのエラーを消せるか?」と考えてしまいがちです。しかし、本当に問うべきは「なぜこのリクエストはブラウザによってブロックされているのか?」そして「このオリジン間の通信を許可することは、セキュリティ上本当に安全か?」ということです。

CORSの裏側にあるブラウザとサーバーの対話(シンプルリクエスト、プリフライトリクエスト)を理解し、サーバーサイドで適切なヘッダー(Access-Control-Allow-Origin, Access-Control-Allow-Methodsなど)を正しく設定することで、私たちはエラーを解決するだけでなく、アプリケーションをより堅牢で安全なものにすることができます。安易にワイルドカード(*)に頼るのではなく、許可するオリジン、メソッド、ヘッダーを最小限の範囲で明示的に指定することが、プロフェッショナルなウェブ開発における責務と言えるでしょう。

次にあなたがCORSエラーに直面したときには、コンソールの赤い文字に怯えることなく、この記事で得た知識を武器に、冷静に問題の核心を突き止め、自信を持ってエレガントな解決策を実装できることを願っています。

Post a Comment