/dev/null

脳みそのL1キャッシュ

CORSについて勉強する

はじめに

CORS とは Cross Origin Resource Sharing の略で、クロスオリジンでのリソース共有を可能にする仕組みです。僕はこれまでふんわりとしか CORS のことを認識していなくて、いざ CORS を有効にしてくださいって言われてもどうすればいいのかわかりませんでした。今回は実際にコードを書きながら、CORS の仕組みを勉強したので、記録にとこの記事を書きました。

また、この記事に使ったコードは以下のリポジトリにあります。リポジトリにはこの記事書いてないようなパターンもいくつか試している例がありますので、よければそちらも参照してみてください。

github.com

同一生成元ポリシー (Same-Origin Policy)

生成元(Origin)については知ってる人も多いかと思います。オリジンとはプロトコル、ホスト、ポート番号の組み合わせですね。

https://sample.com:8080
-----  ----------- ----
 |           |        |
 +- protocol +-- host +-- port

そして、同一生成元ポリシーとは、異なるオリジンのリソースへのアクセスを制限する仕組みです。これによって、あるオリジンに存在する悪意のあるサイトの JavaScript コードが別のオリジンに存在するサイトのリソースを盗むといったことを防ぐことができます。同一生成元ポリシーによって制限される操作の例として、XMLHttpRequest や Fetch API を使った異なるオリジンとの非同期通信が挙げられます。

CORS

同一生成元ポリシーによって、XMLHttpRequest や Fetch API を使った異なるオリジンとの非同期通信が制限されると書きましたが、以下の例について考えてみましょう。

f:id:d2v:20200927161032p:plain

ありうる構成ではあると思いますが、同一生成元ポリシーによって、APIサーバとの通信が制限されてしまいます。この状況を打開できるのが CORS です。

単純な例

以下のような単純な例を考えてみましょう。

APIサーバ

from fastapi import FastAPI

app = FastAPI()

@app.get("/me")
def me():
    return {
        "name": "john",
        "about": "I want to be a shellfish.",
        "homepage": "https://shellfi.sh"
    }

Webサーバ

from fastapi import FastAPI
from fastapi.responses import HTMLResponse

app = FastAPI()

@app.get("/", response_class=HTMLResponse)
def index():
    with open("./index.html") as f:
        return f.read()

index.html

<!doctype html>
<html lang="en" class="h-100">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Me</title>
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
  </head>
  <body class="h-100 bg-dark">
    <div class="container h-100">
      <div class="row align-items-center h-100">
        <div class="mx-auto">
          <div class="card h-100 justify-content-center" style="width: 18rem;">
            <div class="card-body">
              <h5 class="card-title" id="name"></h5>
              <p class="card-text" id="about"></p>
              <a href="#" class="btn btn-info" id="homepage">Homepage</a>
            </div>
          </div>
        </div>
      </div>
    </div>
    <script>
      window.onload = () => {
        fetch("http://127.0.0.1:8000/me")
          .then(response => response.json())
          .then(me => {
            let name = document.getElementById("name");
            let about = document.getElementById("about");
            let homepage = document.getElementById("homepage");

            name.innerText = me.name;
            about.innerText = me.about;
            homepage.href = me.homepage;
          })
          .catch(error => {
            console.log(error);

            let name = document.getElementById("name");
            let about = document.getElementById("about");
            let homepage = document.getElementById("homepage");

            name.innerText = "unknown";
            about.innerText = "unknown";
            homepage.href = "unknown";
          });
      }
    </script>
  </body>
</html>

上記のファイルが同一ディレクトリに存在する状態で以下のコマンドを実行します。

$ uvicorn api:app --port=8000
$ uvicorn web:app --port=8001

この例では、Web サーバのオリジン(http://127.0.0.1:8001)に存在するサイトから API サーバのオリジン(http://127.0.0.1:8000) に対して、Fetch API を使って、クロスオリジンなリソース要求をしています。データを取得できた場合は、プロフィールが表示され、取得できなかった場合はプロフィールに unknown が表示されます。

ブラウザで確認してみると

f:id:d2v:20200927162210p:plain

開発者コンソールを確認すると

f:id:d2v:20200927162432p:plain

通信がブロックされていることがわかります。また、エラーメッセージで親切に Access-Control-Allow-Origin ヘッダがありませんと書いていますね。これに従って、API サーバ側にこのヘッダをつけてみましょう。

from typing import Optional
from fastapi import FastAPI, Response, Header

...

def me(response: Response, origin: Optional[str] = Header(None)):
    response.headers["Access-Control-Allow-Origin"] = "http://127.0.0.1:8001"

...

今度はどうでしょう。

f:id:d2v:20200927162857p:plain

表示されましたね。このように、Access-Control-Allow-Origin ヘッダを使って、自身のリソースをどのオリジンに対してアクセス可能にするか指定することができます。また、今回は Access-Control-Allow-Origin: http://127.0.0.1:8001 と指定しましたが、Access-Control-Allow-Origin: * としても動くはずです。* はすべてのオリジンからそのリソースにアクセスできることを意味しますので。

プリフライトリクエストが必要な例

今度は、次の場合を考えてみましょう。PUT メソッドでリソースの一部(今回はプロフィール内の名前)を更新します。

API サーバ

...

name_list = ["john", "joshua"]

profile = {
    "name": "john",
    "about": "I want to be a shellfish.",
    "homepage": "https://shellfi.sh"
}

...

@app.put("/me")
def put(response: Response):
    response.headers["Access-Control-Allow-Origin"] = "http://127.0.0.1:8001"

    global name_list
    name_list = name_list[1:] + name_list[:1]

    profile["name"] = name_list[0]
    return profile

index.html

              <a href="#" class="btn btn-info" id="homepage">Homepage</a>
              <button class="btn btn-warning" onclick="update()">Update</button>

...

    <script>
      window.onload = () => {
...
      }

      function update() {
        fetch("http://127.0.0.1:8000/me", {
          method: "PUT",
        }).then(response => {
          let name = document.getElementById("name");
          let about = document.getElementById("about");
          let homepage = document.getElementById("homepage");

          let me = response.json();
          name.innerText = me.name;
          about.innerText = me.about;
          homepage.href = me.homepage;
        }).catch(error => {
          console.log(error);
        });
      }
    </script>

API サーバでも、Access-Control-Allow-Origin ヘッダをセットしているので大丈夫そうに見えますが、Update ボタンを押しても以下のようにうまくいきません。

f:id:d2v:20200927171303p:plain

Access-Control-Allow-Origin ヘッダをセットしたのに、Access-Control-Allow-Origin ヘッダがないと言われています。なぜこんなことが起きたのでしょう。Networkパネルにヒントがあります。

f:id:d2v:20200927171637p:plain

このように、我々が預かり知らぬところで OPTIONS メソッドのリクエストを密かにサーバに飛ばしています。実は、PUTメソッドのような 単純リクエストではないリクエス (単純リクエストとは何かというのは MDN を確認してみてください)を送信する場合、事前にサーバに「これからこんな感じのリクエストを送りますが大丈夫ですか」ということを確認するためのリクエストを送信します。これが プリフライトリクエス です。

そもそも、なぜプリフライトリクエストが必要なのでしょうか。MDN には次のような記述があります。

サーバーの情報に副作用を引き起こすことがある HTTP のリクエストメソッド (特に GET 以外の HTTP メソッドや、特定の MIME タイプを伴う POST) のために、ブラウザーが HTTP の OPTIONS リクエストメソッドを用いて、あらかじめリクエストの「プリフライト」 (サーバーから対応するメソッドの一覧を収集すること) を行い

つまり、そもそもリクエストが送信されること自体に問題があるような場合 (CSRF とか) には、予めプリフライトリクエストが送信されるのです。

プリフライトリクエストへのレスポンスでは、対象リソースに対して許可されているメソッド、ヘッダの種類などを含めます。

...

@app.options("/me", status_code=204)
def options(response: Response):
    response.headers["Access-Control-Allow-Origin"] = "http://127.0.0.1:8001"
    response.headers["Access-Control-Allow-Methods"] = "GET,PUT,OPTIONS"

OPTIONSメソッドのハンドラを定義してから、ブラウザで Update ボタンを押して見ると次のように名前が更新されていることが確認できると思います。

f:id:d2v:20200927172713p:plain

その他

CORS に関連するヘッダーは Access-Control-Allow-OriginAccess-Control-Allow-Methods 以外にもあります。たとえば、Access-Control-Allow-Headers でリクエストに使用できるヘッダを指定できたり、Access-Control-Allow-Credentials でフロントエンドの JavaScript コードに Cookie などの資格情報を公開するか制御できたりします。これらのコード例に関しても、記事冒頭の GitHub リポジトリに含めています。

おわりに

CORS をちゃんと勉強したことがなかったので、ここらへんのこと何もわかっていませんでした… 今後は、Content Security Policy について勉強しようかなと思います。

参考文献

developer.mozilla.org

www.slideshare.net

www.youtube.com