Basic認証の領域へプリフライトでリクエストしてハマった


概要

CORS(クロス オリジン リソース シェアリング)となるリクエストを出したのにうまくレスポンスが戻らない。こんな時、プリフライトという仕組みに悩まされたエンジニアも多いでしょう。私もそんなエンジニアの1人でしたが、もう克服したものだと思っていました。
先日、Basic認証下の領域にあるJSONファイルについて、CORSで取得する必要がありました。まぁいつものプリフライトかと思い、過去の経験から適切な設定をしたリクエストを投げたのですが、401エラーのみが戻りうまくいきません。
手元でサーバーサイドも再現させたところ、Basic認証とプリフライトの相性の悪さを知ることができたので、共有したいと思います。

前提

少々前置きが長くなりますが、今回の問題を理解するためには前提知識が必要となります。わかっている方は飛ばしてください。

Chromeでプリフライト リクエストの観察

標準では、Inspectを使ってもプリフライト リクエストは隠されていて見ることはできません。以下にアクセスして、Out of blink CORS を Disabled に設定する必要があります。
chrome://flags/#out-of-blink-cors

Basic認証

Basic認証は2通りの方法があります(ありました)。

  • Authorizationヘッダー
    HTTPリクエストの際にAuthorizationヘッダーに情報を付与することで、認証に成功する。
  • URLエンコーディング
    RFC3986 3.2.1 ユーザ情報 に定義されている方法で、URLの中に認証情報を埋め込むことができる。
    が、Chrome等の最近のブラウザでは、セキュリティ上の理由からURLにこれらの認証情報がURLに含まれている場合、リクエスト前に削除するようになった。

つまり、Basic認証にはAuthorizationヘッダーを付与しなければならない、という状況です。

CORS

こちらについては多くのサイトでも説明されているので大幅に省略します。以下、今回の問題に関連する重要な部分のみを説明。

  • CORSとなるリソースを取得する場合、クロスオリジン リクエストを行う必要がある。クロスオリジン リクエストは、シンプル リクエストとプリフライト リクエストに分かれる。
  • 今回は、Basic認証のために Authorizationヘッダー が付与されるため、プリフライト リクエストとなる。

プリフライトリクエスト

仕様は W3C のこのあたりですが、英語でも日本語でも(私には)わかりにくいです(汗
https://www.w3.org/TR/2020/SPSD-cors-20200602/#resource-preflight-requests
以下、今回の問題に関係するポイント

  • CORSに該当するリクエストで、単純なリクエストではない場合、ブラウザが 勝手に プリフライトリクエストを投げる
    ※ "勝手に" というか仕様です
  • OPTIONS メソッドとなる(GETやPOSTではない)
    OPTIONSメソッドは安全(変更や削除ではなくサーバーから付加的情報を得るためのメソッドのため)
  • カスタムヘッダーはリストとして、Access-Control-Request-Headersに追加される
  • カスタムヘッダーは削除される
    ※ Basic認証のAuthorizationヘッダーはカスタムヘッダーなので削除される
  • リクエストボディは空となる

試行錯誤

  • サーバー側
    • CentOS7
    • Apache 2.4.6
  • クライアント側
    • Chrome 80.0

同一オリジン Basic認証あり でのリクエストの流れ

同一オリジンのためCORSとならないが、Basic認証が必要なリクエストとなります。
※ ちなみに図はブラウザでアクセスした場合の流れで、②と③の間でIDとPasswordを入力するダイアログが出る感じですね。

  1. クライアントからJSONをもらうためのリクエストを投げる
  2. サーバーからのレスポンス
    Basic認証領域のため、認証情報がないとダメだよと401が戻る
  3. クライアントからJSONをもらうためのリクエストを認証情報付きで投げる
  4. 200 OK でJSONデータが戻る

別オリジン Basic認証なし でのリクエストの流れ

次に別のオリジンのためCORSではあるのですが、Basic認証のない流れを見てみましょう。

  1. クライアントからJSONをもらうためのリクエストを投げようとするが、CORSのためプリフライトリクエストとなる
    OPTIONSメソッドで、これから何のメソッドやヘッダーを使いたいのかリクエストする
  2. サーバーからのプリフライトリクエストに対して200 OKが戻る
  3. JSONを取得するGETリクエストを投げる
  4. 200 OK でJSONデータが戻る

別オリジン Basic認証あり でのリクエストの流れ(NGパターン)

では別オリジンでCORSとなり、Basic認証がある場合の流れを見てみましょう。

  1. クライアントからJSONをもらうためのリクエストを投げようとするが、CORSのためプリフライトリクエストとなる
    OPTIONSメソッドで、これから何のメソッドやヘッダーをを使いたいのかリクエストする
    このとき、Basic認証のAuthorizationヘッダーも投げているのだが、プリフライトリクエストのためブラウザが勝手にヘッダーを削除してしまう
  2. サーバーからのレスポンス
    Basic認証領域のため、認証情報がないとダメだよと401が戻る

ここが今回ハマったポイントでした。Basic認証はヘッダーに認証情報を入れないと渡せない。しかし、プリフライトでは特定のヘッダーしか送れない。つまり、このままではBasic認証を通れないということになります。

対応方法

2パターンの対応を思いつきました。

  • Basic認証ではなく、サーバーの下のアプリケーションレベルでの認証を行う
  • プリフライトリクエストはBasic認証を行わない

今回、後者の方法で解決してみました。具体的には、Webサーバーの設定でOPTIONSメソッドの場合はBasic認証を行わない、という設定を行います。例えば .htaccess であれば、下記のLimitExcept を使うことで「OPTIONSを除いてBasic認証する」と設定することができます。

.htaccess
# アクセスを許可するURLを指定
Header set Access-Control-Allow-Origin "https://example.hoge"

# 許可するリクエストヘッダのメソッドを指定
Header set Access-Control-Allow-Methods "POST, GET, OPTIONS"

# 許可するリクエストヘッダの種類を指定
Header set Access-Control-Allow-Headers "Content-Type, origin, authorization"

# プリフライトレスポンスをキャッシュする時間を指定
Header set Access-Control-Max-Age "600"

# OPTIONSに対する反応
RewriteEngine On
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule ^(.*)$ $1 [R=200,L]

<LimitExcept OPTIONS>
  AuthType Basic
  AuthUserFile /hoge/.htpasswd
  AuthName "ID and password"
  Require valid-user
</LimitExcept>

こうすることで、以下のような流れになります。

別オリジン Basic認証あり でのリクエストの流れ(OKパターン)

別オリジンでCORSとなり、Basic認証がある場合です。NGパターンと違い、OPTIONSの場合、Basic認証が除外されます。

  1. クライアントからJSONをもらうためのリクエストを投げようとするが、CORSのためプリフライトリクエストとなる
  2. サーバーからのレスポンス
    Basic認証領域だがOPTIONSのため認証は除外される
  3. クライアントからJSONをもらうためのリクエストを認証情報付きで投げる
    ※ この例では手動ではなくJavaScriptで送信しているためAuthorizationが最初から付いている
  4. 200 OK でJSONデータが戻る

まとめ

結局の所コンテンツを管理しているサーバー側にて対応してもらう、ということになります。まぁコンテンツを管理するのはサーバー側ですからね。
基本的に公開されているデータなので、Basic認証付きのCORSって少ないのかなぁ、とは思うのですが、いろいろな大人の事情によりこういうケースもあるかと思います。

ここまでくれば、今後はプリフライトで悩むこともないだろー。とフラグを立てて締めさせていただきます。