React.js をはじめとする、いわゆる「フロントエンドフレームワーク」の勉強をしながら色んな機能を実装している中で「あれ?これって・・・」と違和感のように感じた実装内容がありました。自分なりに色々調べたり、ベンダーのサポートに問い合わせたりした結果として「どうやらこれは問題ではないらしい」という結論に落ち着きました。
ただ「これは問題点と分類されることではない」という説明を受けている一方で、「本当に大丈夫なんだろうか?」という違和感は今でもまだ少し残っています。内容が内容だけにどこまで詳しく書くべきかも迷っていましたが、「ベンダーが問題ではないと言っているのだからいいだろう」と割り切って書くことにしました。簡単にいうと「フロントエンドフレームワークを使ったアプリケーションでの認証機能の安全性」についてです。
以下では Auth0 を使ったサンプルを紹介していますが、同様の現象が AWS Cognito や IBM AppID でも再現することを確認していることをはじめに記載しておきます(Auth0 を例として選んだのは、この件でのやり取りの中で最も闊達な議論が出来、様々な情報をいただくことができたのでその内容を交えて紹介したいためです。悪意はありません)。細かな実装の違いはありますが、IDaaS に分類される ID 管理のマネージドサービスほぼ全てに共通の事象であると考えていただいていいと思います。
【通常の認証実装と、フロントエンドフレームワークを使った場合の実装との違い】
認証の実装手段による違いを説明するために、以下3種類のアプリケーション例を使うことにします:
A ユーザー管理機能ごと自分で実装し、サーバーサイドでユーザー認証を行うパターン
ある意味で最もシンプルなアーキテクチャです。自分たちで用意するデータベース等でユーザー情報(IDやパスワードハッシュ)も管理する形です。ログイン時はアプリケーションのログイン画面から入力された ID とパスワード(のハッシュ)がデータベース内に格納されているものと一致するかどうかを調べ、一致していればログインを許可する、という流れになります:
これだけなら非常にシンプルとも言えますが、オンラインサインアップやパスワード変更、パスワード忘れといった機能も自分達で用意する必要があったり、既存の他のユーザー情報(社内ユーザーDBなど)との連携時なども自分たちで機能を実装する必要があります。そこまで意識すると必ずしもシンプルな開発が実現できる、とはいえなくなります。
B ユーザー管理機能にマネージドサービス(今回の例では Auth0)を使い、サーバーサイドでユーザー認証を行うパターン
ユーザー管理にマネージドサービスを使うパターンです。Auth0 や AWS Cognito、IBM AppID などが代表的であると思っています。多くの場合で OAuth 2.0 の仕組みが用意されていて、ログイン時のみマネージドサービスのサイトへ飛んでログインし、正しい ID &パスワード情報が入力された場合のみコールバック関数と呼ばれる事前に登録された URL が呼び出される形でアプリケーションの認証が実現できるものです:
上述のAで問題になる、オンラインサインアップやパスワード変更といった面倒な手間も予め用意されているので、ユーザー管理機能まで含めた形で比較的簡単に実装ができるものです。また多くの場合でプログラミング言語ごとの SDK が提供されており、比較的簡単にログイン機能を実装することができます。
C ユーザー認証機能にマネージドサービス(今回の例では Auth0)を使い、クライアントサイドでユーザー認証を行うパターン
Bの仕組みをフロントエンドフレームワーク内で実現するものです。詳しくは後述しますが、フロントエンドフレームワークではコールバック機能が使えないため、PKCE と呼ばれるプロトコルを実装した専用の JavaScript SDK が提供されていることが多いです。今回、自分が問題が発生するのではないかと思ったのはこのパターンです:
ではA、B、Cそれぞれのケースごとに実装やアーキテクチャの違いをセキュリティ面を意識しながら改めて説明します。
まずAのケース。このケースでは利用者が入力した ID とパスワード(ハッシュ)の組み合わせがデータベースのユーザーテーブル内に存在するかどうかを調べることになります。ログイン処理自体はバックエンド側で実装されるケースが多く、フロントエンド側から ID とパスワードを受け取ってパスワードをハッシュ化し、データベースのユーザーテーブル内で同じ ID とパスワードハッシュを持つレコードが存在するかどうかを調べます。存在していた場合は正しい ID とパスワードが入力されたことになるためログインを許可します。この ID とパスワードを受け取った後の一連の処理はバックエンド側で実装される形になるため、利用者からは何をしているのか、どのホストで動いているデータベースにどのような認証情報やプロトコルで接続して、どのような SQL を実行して情報が正しいかどうかを調べたか、といった裏側の情報は一切見ることができません。
一方、悪意を持った利用者がログイン画面から当てずっぽうに ID やパスワードを何度も試す、といった攻撃行為を行った場合でも、そのような記録を(どの IP アドレスから利用しているか、も含めて)アプリケーション・サーバー側のログに残すことができます。したがって「どこまでセキュリティを意識して実装するかにもよるが、比較的安全な実装ができる」と考えることができます。
次にBのケースです。こちらは OAuth 2.0 のコールバックという仕組みを使って実装する想定で説明します。利用者がログインする時に、一時的にプロバイダー(例えば Auth0)が用意するログイン画面に行きます。ここでプロバイダーに登録されたユーザーの ID とパスワード等を入力することが出来た場合にアクセストークンを含めた形で元のアプリケーションの画面に戻ります。アプリケーションではアクセストークンがあるので各種 API を実行できるようになり、ユーザーの詳細情報を取得することもできるようになります。
つまりログイン時に入力される情報は(プロバイダーの画面で入力されるため)アプリケーションからも知ることはできません。またこのログイン画面にリダイレクトする機能や、ログイン成功後のコールバックを受け取る機能については、バックエンド側で ClientID や ClientSecret 、コールバック URL を正しく設定することで可能になります。つまりアプリケーション側でもこの認証部分についてはバックエンド側で処理されることになるため、Aのケースと同様に利用者からは裏側の情報は一切見ることができません。
悪意を持った利用者が何度も当てずっぽうにログインを試みる攻撃(ブルートフォースアタック)については、プロバイダー側で「どの IP アドレスから頻繁にログインを試みているか」を特定することができるため、例えばその IP アドレスからのリクエストを遮断する、といった対処が可能になります。
最後のCのケースはどうでしょう?こちらはフロントエンドフレームワークを使っているため、バックエンド側で処理を行うことはできなくなります。そこで PKCE という仕組みを使うのですが、この仕組は充分に安全なのでしょうか? PKCE は悪意のあるアプリケーションからアクセストークンを横取りされないようにするための仕組みでもあるのですが、ではアクセストークンを横取りされなければ大丈夫なのか?という観点で考えてみました。
このCのケースで自分が気になったのは「認証は(アプリケーションから見て外部にある)認証サーバーで」行いつつ、「アプリケーション側にはバックエンドの仕組みがない」という点だと思っています。したがって認証サーバーで認証を行う際に必要な client_id などの情報を安全なバックエンドに持たせることができない、ということになります。
ではどうしているか、というと、実はこれらの情報は全く隠されていません。ビルド時に JavaScript の難読化処理は行われていますが、HTTP でアクセス可能な JavaScript 内に平文で保存されています。この点がフロントエンドフレームワークを使った場合の実装が他のケースと大きく異なる点といえます。
【フロントエンドフレームワークを使った場合に漏れる情報を確認】
実際にこれらの情報が平文で格納されていることを確認してみます。例として実証用に用意したこのサイトを使います:
まず上述のサイトはこちらのサンプルをベースに作っています。このサンプルからはアプリケーション画面の背景色を変えた程度のものを公開しています:
また上述のサイトは Auth0 と、Auth0 が提供するフロントエンドフレームワーク用の SDK を使って実装しています。Auth0 側の設定ではログイン/ログアウトの許可 URL およびコールバック URL は全て同じで、上述の URL そのものを指定しています(これらは本来管理者以外からは見えていないはずのものです)。localhost など、開発/テスト時に指定しがちなもの(=推測される可能性の高いもの)は今回使っていません:
このアプリケーションサイトの URL にアクセスすると、以下のようなページが表示されます(元々のサンプルの背景は黒でしたが、後述の比較のため青に変更しています):
"LOGIN" と書かれたボタンをクリックすると Auth0 の認証ダイアログが表示され、ここで認証できます。今回はログインしませんが、ログインに成功すると元の画面の "LOGIN" ボタンが "LOGOUT" に変わって、ログアウトできるようになります(ここはサンプルのまま)。今回はそのままブラウザの「戻る」機能で1つ前のページに戻ります:
元の画面に戻りました。では改めて公開されている情報をブラウザから確認してみます。画面を右クリックして「ページのソースを表示」を選択します:
React によってビルドされた index.html の内容が表示されます。1行で表示されているため少し見にくいのですが、"<script" を検索すると中程にこのような JavaScript をロードしている箇所が見つかります。この JavaScript 部分をクリックして内容を確認します:
React と Auth0 SDK によってビルドされた JavaScript ファイルの内容が表示されます。難読化処理が施されているためこれも見にくくなっていますが、この画面で "clientId" という文字列を検索してみます:
"clientId" は数か所見つかりますが、その最後の1つの周辺が以下のようになっています:
※これ、本当に公開しちゃっていいのか、少し怖いんですけど、後述のように規格としては機密情報ではないらしいので、それを信じて表示しちゃってます。
上図のように "clientId" と "domain" の情報が平文で格納されていることがわかります(わざと clientId が全て見えない位置でスクリーンショットを撮っていますが、実際に試すと全部表示されるはずです)。繰り返しますが、この画面は管理者や開発者だけが見ることのできる画面ではなく、利用者が誰でも見ることのできる画面です。A や B とは異なり、C のケースではこれらの情報をバックエンド側に安全に隠すことができないためです。
ここでは Auth0 を使っている場合の結果を紹介していますが、AWS Cognito や IBM AppID を使った場合でも同様の手順で JavaScript ファイルから設定に使った情報が見えてしまうことを確認しています。
【フロントエンドフレームワークを使った場合に漏れた情報で別のアプリケーションを稼働させる】
さて、上述したようにフロントエンドフレームワークではビルドされた JavaScript から一部の設定内容が見えてしまうことがわかりました。問題はこの状況は安全なのか? です。
ベンダー側の説明をまとめると「これはこのような仕様であり、表示されている情報は機密情報ではない」との認識でした。OAuth 2.0 を定義する RFC6749 でもクライアントを識別する ClientID 自体は単独では機密情報ではない、と明文化されており、これが画面を通じて表示されていることが即イコール危険にさらされている、という認識にはならないと判断しているようでした:
まあ確かにアクセストークンが奪取されるわけではないし、これがわかってもユーザー権限でログインできるわけではない(後述のような環境を作ることができるため、誰にも知られずに好きなだけログインを試すことができる、というだけ)のでギリギリセーフ、といった認識なのだと思います。
(RFC6749 2.2 より抜粋)The authorization server issues the registered client a client identifier -- a unique string representing the registration information provided by the client. The client identifier is not a secret; it is exposed to the resource owner and MUST NOT be used alone for client authentication
まあ確かにアクセストークンが奪取されるわけではないし、これがわかってもユーザー権限でログインできるわけではない(後述のような環境を作ることができるため、誰にも知られずに好きなだけログインを試すことができる、というだけ)のでギリギリセーフ、といった認識なのだと思います。
とはいえ、それでも全く安心してよいとは思えない点もあります。例えば Auth0 の場合、このフロントエンドフレームワークを使ってアプリケーションを作るために必要な情報は ClientID と Domain 情報、そしてコールバック URL の3つです。前者2つは上述のようにブラウザから確認することができ、また SPA であるためコールバック URL はブラウザのアドレス欄に表示されている文字列(今回の例だと https://dotnsf-auth0.herokuapp.com/)と一致しているはずです。つまり同じ認証機能を使うアプリケーションを作るために必要な3つの情報が全て開示されてしまっていることになります。後は SSL のオレオレ証明書を作って /etc/hosts ファイルを編集すれば、全く同じ Auth0 のサービスを使う、別の(悪意を持った)アプリケーションを作り上げてしまうこともできるわけです:
本当にできるか、実際に試してみました。上述の方法で Auth0 の ClientID, Domain, CallbackURL(今回の場合は https://dotnsf-auth0.herokuapp.com/) の3つを取り出し、これら取り出した情報だけで別のサーバーに別途自分で作ったアプリケーションを適当なサーバーにデプロイして、自己証明書を作って /etc/hosts ファイルも編集した上でアクセスしてみました。自己証明書なので「信頼してアクセス」するための手順は必要ですが、自分でアクセスするだけなので問題なくできてしまいます(オリジナルのアプリケーションとの比較のため、背景を青から赤に変えています):
全く同じ認証情報を使って作られているため、Auth0 への認証も(本来のものと同じ条件で)動きます。管理者の知らない所でログインを試すこともできますし、アタックプロテクションが有効になっていれば、アタックがされたことを検知させることもできてしまうはずです:
繰り返しますが、上述のアプリケーションでは Auth0 を使ってアプリケーションをビルド&デプロイしていますが、同様のことが AWS Cognito や IBM AppID でも再現します。
【フロントエンドフレームワークを使った場合にこれらの情報が漏れても安全なのか?】
さて問題はこれができてしまうことがどれだけ危ないのか/危なくないのか という判断を行うことになると思います。これを危険だという根拠は以下のようになると思っています:
・本来の管理者の知り得ないところで、自分達が使っているのと同じ認証サービスが使われている
・ブルートフォースアタックが行われていても気付けない
・アタックプロテクションなどの機能が有効になっていると、管理側の知り得ない所からのアタックによってサービスが止まってしまう可能性がある
一方で、規格としてはこれは仕様どおりというか想定通りというか・・・であって、この時点で危険ではないと判断しているようです。以下のような理由であると思います:
・ユーザーのパスワードなどの情報が直接漏れるわけではない
・特定の攻撃者からのみ攻撃できる(一般ユーザーが知らないうちに攻撃に参加している、とか、一般ユーザーが知らないうちに偽サイトに誘導されている、ということは考えにくい)
・アタックプロテクション機能で攻撃を防げる、または攻撃元を特定できる※
※ただし特定の条件下でこの C の構成になっている場合、認証サーバー側からも実際のアプリケーションサーバーからも攻撃元を特定できなくなる可能性があると思っています。その具体的な内容はここに書かない方がいいと思ったので控えます。
繰り返しますが、これは OAuth 2.0 の規格としては仕様どおり/想定どおりの挙動であって、(個人情報漏洩といった)直接の被害が起こる内容ではないと思っています。ベンダーとしては規格の上で機密情報とされていない以上は大丈夫、と判断することになるでしょうから、ここまでの情報を元に自分達が管理するサービスにおいて、この機能を使うか使わないか(許可してもよいかよくないか)、を判断すべきことだと思っています。「管理者の知らない所で同じサービスを都合よく使われる事自体が許されない」と判断するのであれば、上述のCのケースのようなフロントエンドフレームワークを使ってのアプリケーション構築を避けて、バックエンド併用の B のような設計にするべき、という意味です。
なんとなく、スッキリとした判断はならないのですが、規格やサービスを提供する側としては「ギリギリセーフ」と判断しているのだと思っています(事実、これが許されないとフロントエンドフレームワークを使ったアプリケーションからの認証サービス利用はできなくなってしまいます)。実際にフロントエンドフレームワークを使って IDaaS の認証サービスを利用するアプリケーションを開発・運用する(可能性がある)場合は、少なくともここで説明したような情報は外部に漏れる可能性がある、という点を理解しておくべきであると思いました。