💡gntk.dev

CORSを知る

雰囲気でしか知らなかったCORSについて調べて、動作が確認できるモック作るところまでやりました。サムネイル画像にはなんの意味もないです。

post-cover

CORSとは

概要

Cross Origin Resource Sharingの略。

通常、ブラウザは同一オリジンポリシーによって、オリジンAの文書やスクリプトなどのリソースからオリジンBのリソースにはアクセスできないように制限されている。CORSは、追加のHTTPヘッダを使用することで、同一オリジンポリシーによるリソース間のアクセスの制限を緩和するためのブラウザの仕組み。XMLHttpRequestやFetch APIを使用してクロスドメインのリソースにリクエストを送信する場合は、CORSの仕様に則ってリクエストを送信する必要がある。

何を解決したか

Ajaxの普及により異なるオリジンのAPIを呼び出したいという需要が生まれたが、CORSの仕組みがないブラウザでは同一オリジンポリシーによって異なるオリジンのリソースへのアクセスは拒否されていた。こういった状況の中で、クロスドメインアクセスを実現したいという要求に答えるため考案されたのがCORS。CORSの規定に則ってブラウザとサーバーでアクセス制御に関する情報をやりとりすれば、安全にクロスドメインアクセスを実現ができる。

CORSを使用したリクエストのシナリオ

CORSの仕様に則ってクロスドメインのリソースへアクセスする方法は2パターンがある。

  • クロスドメインのリソースにアクセスするリクエストを直接送信する「シンプルなリクエスト」のパターン
  • クロスドメインアクセスが可能か確認するリクエスト(プリフライトリクエスト)を送信して、そのレスポンスを受けた後に改めてクロスドメインのリソースアクセスを行うパターン

CORSで定義された条件を満たせばシンプルなリクエストが送信され、そうでなければプリフライトリクエストからやりとりが始まる。

シンプルなリクエスト

以下の条件をすべて満たすリクエストは、クロスドメインのリソースに直接送信できる。

  • メソッドが以下のいずれかである。

    • GET
    • HEAD
    • POST
  • 以下のHTTPヘッダ以外のHTTPヘッダが設定されていない(ブラウザによって自動的に追加されたものを除く)

    • Accept
    • Accept-Language
    • Content-Language
    • Content-type
    • DPR
    • Downlink
    • Save-Data
    • Viewport-Width
    • Width
  • Content-Typeヘッダに以下の値以外の値が設定されていない

    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/plain
  • リクエストに使用されるどのXMLHttpRequestUploadにもイベントリスナーが登録されていない
  • リクエストにReadableStreamオブジェクトが使用されていないこと

以下は、https://foo.exampleのコンテンツがhttps://bar.otherにあるコンテンツを呼び出すときのコードの例。

const xhr = new XMLHttpRequest();
const url = 'https://bar.other/resources/public-data/';

xhr.open('GET', url);
xhr.onreadystatechange = someHandler;
xhr.send();

このとき送信されるリクエストは以下の通り。

GET /resources/public-data/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: https://foo.example

Originヘッダでhttps://foo.exampleからのリクエストであることをサーバーに伝えている。 サーバーから返ってくるレスポンスは以下の通り。

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Server: Apache/2
Access-Control-Allow-Origin: *
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml

[…XML データ…]

Access-Control-Allow-Originヘッダで全てのドメインからのアクセスを許可することをクライアントに伝えている。サーバー側でリソースへのアクセスを制限したい場合、例えば以下のように設定すればhttps://foo.exampleからのリクエストのみリソースへアクセスできるよう制限できる。

Access-Control-Allow-Origin: https://foo.example

プリフライトリクエスト

シンプルなリクエストを送信できる条件に当てはまらない場合、クライアントはまずサーバーにプリフライトリクエストを送信する。例えば以下のように作成したリクエストは、Content-Typeapplication/xmlを指定しているため、プリフライトリクエストが行われる。

const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://bar.other/resources/post-here/');
xhr.setRequestHeader('X-PINGOTHER', 'pingpong');
xhr.setRequestHeader('Content-Type', 'application/xml');
xhr.onreadystatechange = handler;
xhr.send('<person><name>Arun</name></person>');

プリフライトリクエストは、以下のようなOPTIONメソッドのリクエストである。

OPTIONS /doc HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: http://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type

以下の内容をサーバーに伝えている。

  • Originヘッダでhttps://foo.exampleからのリクエストであること
  • Access-Control-Request-MethodヘッダでPOSTメソッドを送信すること
  • Access-Control-Request-HeadersヘッダでX-PINGOTHER, Content-Typeをヘッダとして設定すること

プリフライトリクエストが成功した場合、以下のようなレスポンスがサーバーから返ってくる。

HTTP/1.1 204 No Content
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
Vary: Accept-Encoding, Origin
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive

Access-Control-Allow-xxxヘッダで、許可するオリジン・メソッド・ヘッダを伝えている。

以上のようにプリフライトリクエストが完了したら、実際のリクエストを送る。

POST /doc HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
X-PINGOTHER: pingpong
Content-Type: text/xml; charset=UTF-8
Referer: https://foo.example/examples/preflightInvocation.html
Content-Length: 55
Origin: https://foo.example
Pragma: no-cache
Cache-Control: no-cache

<person><name>Arun</name></person>

レスポンスがサーバーから返ってくる。

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:40 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Vary: Accept-Encoding, Origin
Content-Encoding: gzip
Content-Length: 235
Keep-Alive: timeout=2, max=99
Connection: Keep-Alive
Content-Type: text/plain

[Some XML payload]

CORSによってリクエストが失敗した場合

CORSによってリクエストが失敗した場合、ブラウザの開発者ツールのコンソールには下記のようなメッセージが表示される。

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://some-url-here. (Reason: additional information here).

以上のように、CORSによってリクエストが失敗したことは通知されるが、セキュリティ上の理由から詳細な原因は特定できないようになっている。

認証情報を含むリクエストを送信する場合

異なるオリジン間でXMLHttpRequestまたはFetchによってCookieなどの資格情報を含むリクエストは、デフォルトでは送信されない。資格情報を含むリクエストを送信するためには、下記のようにフラグを設定する必要がある。

XMLHttpRequestの場合、withCredentialsをtrueに設定する必要がある。

const invocation = new XMLHttpRequest();
const url = 'http://bar.other/resources/credentialed-content/';

function callOtherDomain() {
  if (invocation) {
    invocation.open('GET', url, true);
    invocation.withCredentials = true;
    invocation.onreadystatechange = handler;
    invocation.send();
  }
}

Fetchの場合、credentials: 'include'を設定する必要がある。

fetch('http://bar.other/resources/credentialed-content/', {
  mode: 'cors',
  credentials: 'include'
}).then(onLoadFunc);

以上のような設定をした場合、以下のようなリクエストが作成・送信される。

GET /resources/credentialed-content/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Referer: http://foo.example/examples/credential.html
Origin: http://foo.example
Cookie: pageAccess=2

リクエストが成功した場合、サーバーからは下記のようなレスポンスが返ってくる。

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:34:52 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Credentials: true
Cache-Control: no-cache
Pragma: no-cache
Set-Cookie: pageAccess=3; expires=Wed, 31-Dec-2008 01:34:53 GMT
Vary: Accept-Encoding, Origin
Content-Encoding: gzip
Content-Length: 106
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

レスポンスを受信したクライアントは、Access-Control-Allow-Credentials: trueが設定されているかどうかを確認する。設定されていなかった場合、レスポンスは無視される。また、Access-Control-Allow-Originは明確にオリジンを指定せねばならず、ワイルドカード*が設定された場合、リクエストは失敗する。

モックの実装

まず、クロスサイトのリクエストにはlocalhost:8081からのリクエストしか受け付けないサーバーを実装する。 CORSに則ったHTTPヘッダを設定し、プリフライトリクエストが来た場合の扱いを定義したミドルウェアを実装。

import { Request, Response, NextFunction } from "express";

const allowCrossDomain = (
  req: Request,
  res: Response,
  next: NextFunction,
): void => {
  res.header("Access-Control-Allow-Origin", "http://localhost:8081");
  res.header("Access-Control-Allow-Methods", "POST, OPTIONS");
  res.header("Access-Control-Allow-Headers", "Content-Type");

  if (req.method === "OPTIONS") {
    res.sendStatus(200);
  } else {
    next();
  }
};

export default allowCrossDomain;

ルーティングを定義したミドルウェアは特に工夫なく。

import express, { Request, Response } from "express";

const router = express.Router();

router.post("/", (_req: Request, res: Response) => {
  try {
    res.status(200).json({ message: "success!!" });
  } catch {
    res.status(400).json({ message: "Sorry, something went wrong." });
  }
});

export default router;

これらのミドルウェアをapp.useする。

import express from "express";
import allowCrossDomain from "./allowCrossOrigin";
import router from "./router";
import staticPageServer from "./staticPageServer";

const app = express();
app.use(allowCrossDomain);
app.use(router);
app.listen(8080);

const staticPage = express();
staticPage.use(staticPageServer);
staticPage.listen(8081);

staticPageは、このサーバーにリクエストを送るモック。

import express from "express";

const staticPageServer = express.static("public");

export default staticPageServer;

express.staticしてディレクトリ(ここではpublic)を指定すると、指定したディレクトリ配下の静的コンテンツが配信できる。public/index.htmlを作成する。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>CORSについて理解する</title>
  </head>
  <body>
    <p>CORSについて理解する</p>
    <button onclick="sendSimpleRequest()">シンプルなリクエストを送信する</button>
    <button onclick="sendPreflightRequest()">プリフライトリクエストを送信する</button>
    <script>
      const url = "https://494e29cf9cb7.ngrok.io"
      const sendSimpleRequest = () => {
        const xhr = new XMLHttpRequest();
        xhr.open("POST", url);
        xhr.send();
      };

      const sendPreflightRequest = () => {
        const xhr = new XMLHttpRequest();
        xhr.open("POST", url);
        xhr.setRequestHeader("Content-Type", "application/json");
        xhr.send();
      }
    </script>
  </body>
</html>

urlには、ngrokで生成したURLを設定する。ngrokを使えば、簡単に別オリジンを再現できる。 localhost:8081で静的ページを表示すると、ボタンが2つ表示される。表示されたボタンをそれぞれクリックしてみて、開発者ツールのNetworkタブなどをみれば、シンプルなリクエストとプリフライトリクエストを使ったリクエストをそれぞれ確認できる。

© 2021 gntk.dev