トークンの保持方法について
セキュアなトークンの保持方法について、検証と調査を行なって、実際に実装してみました。
トークンを保持する際に考えなければならない脆弱性について
XSS(クロスサイトスクリプティング)
- ユーザー(被害者)の Web ブラウザで任意の JavaScript を実行させることを許す脆弱性または攻撃手法
- 攻撃者が脆弱性のある Web アプリケーションを見つける。
- 不正なスクリプトを含んだ罠を用意する。
- 罠に誘導するための URL をユーザー(被害者)に SNS / メールなどで配る。
- 罠にかかったユーザーが URL にアクセスし、脆弱性のある Web アプリケーションにアクセスする。
- Web アプリケーションから不正なスクリプトを含んだ Web ページが返される。
- ユーザーの Web ブラウザで不正なスクリプトが実行される
CSRF(クロスサイトリクエストフォージェリ)
- 悪意を持つ攻撃者が作成したページなどにアクセスすると、知らない間に情報が送信されてしまう攻撃手法
- ユーザーが特定の Web サービスにログインする
- 攻撃者が用意した罠ページにユーザーがアクセスしてしまう
- 罠ページから攻撃コードが Web サービスに送られてしまう
- ユーザーはログインが完了しているので、Web サービスにリクエストが届いてしまう
パターン 1:トークン を localstorage に保存(master ブランチで実装)
- 認証(mail + password)
- トークン を返却(レスポンスボディ)
- トークン を localStorage に保存
- リクエスト時は トークン を一緒に送信(リクエストヘッダやリクエストボディ)
XSS について
localStorage は Origin ごとに独立しているため、別の Origin から JS で トークン を盗まれることはない。
しかし、同一ドメイン上では Javascript で簡単に読めてしまうので、XSS があった場合意図しないスクリプトを動かされてしまい、結果として token が盗まれる可能性がある。
CSRF について
「不正なサイトから当該サイトにリクエストを投げたときに自動で cookie が付与される」というパターンが起きえないので考慮不要。
パターン 2:トークン を Cookie に保存 + CSRF トークンを利用(token-in-cookie ブランチで実装)
- 認証(mail + password)
- トークン(Set-Cookie, httpOnly:true, secure:true)、CSRF トークン(レスポンスヘッダ)を返却
- リクエスト時は、トークン(リクエストヘッダに自動的に Cookie が付与される)と CSRF トークン(レスポンスヘッダに付与)を返却
XSS について
XSS が起きても httpOnly:true なので、javascript から読むことができない。
また、CSRF トークンも JS のグローバルから参照できない変数に保持していれば流出しない。
しかし、攻撃者が自身のサーバーを作って、fetch で credentials: "include" をしてしまえば、http only cookie を含めた全部を攻撃者のサーバーに送れてしまう。
参考:localStorage vs Cookies for Auth Token Storage - Why httpOnly Cookies are NOT better!
https://www.youtube.com/watch?v=mBd-SMPp3kI
CSRF について
悪意のあるサイトから当該サイトにリクエストを投げたとき、トークンは Cookie として自動で送信されるが、それだけでは CSRF トークンは送信できないので、不正なアクセスとしてサーバ側で判断できる。
パターン 3: トークンを ServiceWorker 上の変数に保存(token-in-serviceworker ブランチで実装)
サービスワーカーとは
ブラウザが Web ページとは別にバックグラウンドで実行するスクリプト。
メインスレッドとは別のライフサイクルで実行されるため、タブを閉じたりブラウザを終了させても、意図的に終了させない限り動いている。
これによってプッシュ通知やバックグラウンド同期などが可能となる。
- 認証(mail + password)
- トークンを返却(レスポンスボディ)
- トークンをサービスワーカーで抜き取って変数に保存。レスポンスボディからトークンを削除してメインに返す
- リクエスト時はサービスワーカー側で変数に保存したトークンをレスポンスヘッダに付与
let lastSavedToken = null;
// fetchの動きを監視する
self.addEventListener('fetch', (event) => {
destURL = new URL(event.request.url);
// whitelistedに定義されたURLのみ通す
if (isWhitelistedUrl(destURL)) {
const headers = new Headers(event.request.headers);
if (lastSavedToken && shouldAppendTokenTo(destURL)) {
// tokenを付与
headers.append('Authorization', `Basic ${lastSavedToken}`);
}
const authReq = new Request(event.request, { headers });
event.respondWith(hackResponse(authReq, destURL));
}
});
const hackResponse = async (authReq, url) => {
const response = await fetch(authReq);
const changeResponse = response.ok && tokenUrls.includes(url.pathname);
if (!changeResponse) {
return response;
}
// リクエストボディからトークンを抜き出す
const data = await response.json();
const { token, ...body } = data;
lastSavedToken = token;
// トークンを抜き出した状態でレスポンスを返す
return new Response(JSON.stringify(body), {
headers: response.headers,
status: response.status,
statusText: response.statusText,
});
};
XSS について
メインスレッドから独立していて、かつメモリ上にあるので、攻撃者側からはどこにトークンが保存されているのか一見わからないので攻撃することが困難。
CSRF について
「不正なサイトから当該サイトにリクエストを投げたときに自動で cookie が付与される」というパターンが起きえないので考慮不要。
まとめ
- localStorage にそのまま保存した場合は、簡単に javascript 上から抜き出せてしまうためあまりセキュアではない
- cookie に保存して httpOnly:true, secure:true の設定にした場合 javascript 上から抜き出すことはできないので localStorage よりセキュアだが、攻撃者側が独自サーバーを用意した場合に抜き取られてしまう。
- serviceWorker を利用した場合、メインスレッド側の javascript からは読み込めない上、メモリ上の変数に格納されているだけなので抜き出すことが困難。しかし実装コストがかかる