ログイン後のリダイレクト先URLを扱うときのセキュリティの注意点
ログイン後のリダイレクト先 URL を ?redirect= で渡すと、フィッシングや XSS に悪用されます。すり抜けられない検証の書き方と、リダイレクト先を URL で渡さずに保存する方法との比較を紹介します。
はじめに
こんにちは、とりかつ(@torikatsu923)です。
ログインしていない状態でページを開くと、ログイン画面へ遷移し、ログインした後に元のページへ戻すことがあります。このとき、ログインした後に遷移させるページの URL(以下、リダイレクト先)を、/login?redirect=/mypage のようにログイン画面の URL のクエリで渡す実装があります。しかし、リダイレクト先に偽サイトや javascript: で始まる値を仕込まれると、フィッシングや XSS になります。
このフィッシングと XSS は、リダイレクト先を URL として解析し、オリジンを自分のサイトと比べれば防止できます。また、リダイレクト先を URL で渡さずにアプリ側で保存すれば、攻撃者がリダイレクト先を指定できなくなり、検証が漏れたときの備えになります。
筆者は業務で、リダイレクト先を URL で渡さず、サーバーのセッションに結び付けて DB に保存する設計を見かけました。そこで、一般的な URL で渡す方法と何が違うのかを考えました。
この記事では、リダイレクト先を URL で渡すときに起きる攻撃と検証の書き方、リダイレクト先を保存する方法との比較を紹介します。
ログイン後のリダイレクト先を URL で渡すと起きるフィッシングと XSS
リダイレクト先を URL で渡す実装では、ユーザーが開いたページからログイン画面を経由して、元のページへ戻ります。この記事で扱うのは、ログインした後の遷移をブラウザで動くフロントエンド(以下、FE)の JavaScript で行う実装です。ログインの API を呼び出し、成功したら redirect の値を location.href に代入して遷移させます。
以下は、ログインしていないユーザーが https://example.com/mypage を開いてから、ログインして /mypage へ戻るまでのシーケンス図です。図では、ユーザーが使うブラウザをユーザーエージェントと呼びます。矢印は、リクエストやリンク、ID とパスワードなど、送信するものを表します。ユーザーエージェントの中や攻撃者の側で起きることは、矢印ではなく枠の中の注記で表します。
Fetch Standardでは、サーバーが Location ヘッダで返した URL のスキームが http か https でなければネットワークエラーとします。そのため、サーバーが Location ヘッダで javascript: の URL を返しても、スクリプトは実行されません。後で説明する XSS は FE で遷移させる実装で起きる問題なので、この記事ではサーバーのリダイレクトで遷移させる実装は扱いません。ただし、別のサイトへ誘導するフィッシングは、サーバーのリダイレクトでも起きます。
redirect の値は URL の一部なので、攻撃者は好きな値を入れたリンクを作れます。このリンクをメールや SNS で配られると、ログインした後の遷移を悪用されます。
フィッシングでは、redirect に偽サイトの URL を入れたリンクを使います。以下は、そのシーケンス図です。矢印の意味は前の図と同じで、ログインした後に開くページが偽サイトにすり替わります。
偽サイトのログイン画面は本物に似せてあるので、ユーザーにはログイン画面へ戻されたように見えます。そのため、ログインに失敗したと思って、ID とパスワードを入れ直してしまいます。
DOM-based XSS では、redirect に javascript: で始まる値を入れたリンクを使います。以下は、そのシーケンス図です。矢印の意味は前の図と同じで、ログインした後に開くページが攻撃者のスクリプトにすり替わります。
スクリプトは自分のサイトのページで実行されるので、localStorage に保存したログインのトークンなど、そのページから読める値を読み取られます。
リダイレクト先の検証をすり抜けられない判定の書き方
リダイレクト先を自分のサイトのページに限れば、どちらの攻撃も防止できます。そのための判定として、redirect の値が / で始まるかを文字列で確認する書き方があります。
// すり抜けられる判定
if (redirect.startsWith('/')) {
location.href = redirect;
}
しかし、/ で始まっていても、ブラウザが別のサイトの URL として扱う値があります。表の <タブ> は、タブ文字 1 文字を表します。
redirect の値 |
遷移先 |
|---|---|
//evil.example |
https://evil.example/。// で始まる URL は、今のページと同じスキームの別のホストを指す |
/\evil.example |
https://evil.example/。\ は / と同じに扱われる |
/<タブ>/evil.example |
https://evil.example/。URL の中のタブと改行は取り除かれ、//evil.example と同じになる |
自分のサイトのオリジンで始まるかを確認する判定(redirect.startsWith('https://example.com'))も、https://example.com@evil.example ですり抜けられます。@ より前はユーザー名として扱われ、ホストは evil.example になります。
どの手口でも、文字列での判定とブラウザでの URL の扱いとで、規則が違います。そこで、文字列で判定せず、ブラウザと同じ URL のパーサで解析してから判定します。new URL() で解析した結果は、遷移のときと同じ規則に従います。
export function redirectUrl(redirect: string | null): string {
const fallback = '/';
if (redirect === null) return fallback;
let url: URL;
try {
url = new URL(redirect, location.origin);
} catch {
return fallback;
}
if (url.origin !== location.origin) return fallback;
return url.href;
}
redirectUrl は、ログインした後の遷移先にする URL を返す関数です。new URL(redirect, location.origin) で、/mypage のような相対的な値を、今のサイトを基準にした絶対 URL として解析します。解析した URL のオリジンが今のサイトと一致すればその URL を返し、一致しなければ既定のページ(ここではトップページ)の / を返します。呼び出し側は、戻り値を location.href に代入して遷移させます。
これにより、表の手口と @ の手口は、どれも解析するとオリジンが https://evil.example になり、一致しないので既定のページへ遷移します。javascript:alert(1) も自分のサイトの URL ではないので、オリジンが一致せず既定のページへ遷移します。
戻り値には、解析した URL 全体(url.href)を使用しています。解析した後の pathname を返し、呼び出し側で location.href に代入すると、/..//evil.example で別のサイトへ遷移します。.. は 1 つ上のパスを指しますが、最上位より上は無いので取り除かれ、パスは //evil.example になります。オリジンは今のサイトのままなので判定を通りますが、この //evil.example を location.href に代入すると、別のサイトを指します。url.href を返せば、遷移先は https://example.com//evil.example という自分のサイトの URL のままなので、別のサイトへは遷移しません。
リダイレクト先を URL で渡さずに保存する方法との比較
攻撃者は別のサイトのリンクで、URL で渡すリダイレクト先に好きな値を指定できます。そのため、前節の検証が必要になります。
リダイレクト先を URL で渡さずにアプリ側で保存する方法では、ログインが必要なページを開いたときに、開いたページをアプリ自身がリダイレクト先として保存します。
ただし、攻撃者も、ログインが必要な自分のサイトのページへのリンクを作り、ユーザーに開かせることはできます。そのため、保存するのがパスだけだと、攻撃者はパスで別のサイトを指定できます。https://example.com//evil.example のリンクを開かせるとパスの //evil.example が保存され、ログインした後にこのパスへ遷移させると別のサイトへ遷移します。そこで、パスではなく、自分のオリジンを付けた絶対 URL を保存し、その URL へ遷移させます。これにより、リダイレクト先は自分のサイトのページに限られ、攻撃者は別のサイトをリダイレクト先に指定できません。
保存する値を ?redirect= から作る場合は、これが成り立ちません。FE が ?redirect= の値を読んでサーバーに送って保存する作りでは、URL のクエリで渡す方法と同じく攻撃者がリダイレクト先を指定できます。
また、オリジンを付けた URL 全体を保存するという条件は、実装の変更で崩れることがあります。パスだけを保存するように変えたり、別の経路から書き込んだりした場合です。そこで、URL のクエリで渡す方法と保存する方法のどちらも、遷移の直前に同じ redirectUrl() を通します。
保存する方法の価値は、検証を省けることではなく、検証と重ねられることにあります。たとえば redirectUrl() をすり抜ける新しい手口が見つかっても、保存する方法では、攻撃者には別のサイトの値をリダイレクト先に入れる手段がありません。そのため、検証が漏れたりすり抜けられたりしても、攻撃が成り立ちにくくなります。
保存先には、ブラウザの sessionStorage と、サーバーのセッションがあります。サーバーのセッションでは、Cookie に保存したセッション ID と結び付けて、リダイレクト先を DB などへ保存します。この保存と、ログインした後に取り出す処理は、サーバーに追加しなければなりません。
保存する方法には、ログインした後にリダイレクト先が分からず、元のページへ戻れない場面もあります。ログインの方法によっては、ログイン画面でメールアドレスを入力し、メールで届いたログイン用のリンクを開いてログインを終えます。このリンクは、新しいタブや、スマートフォンなどの別の端末で開かれることがあります。URL のクエリで渡す方法なら、ログイン用のリンクを作るサーバーが、リンクにもリダイレクト先を含めれば、どこで開いても戻れます。保存する方法では、リンクを開いたタブや端末で元のページへ戻れるかどうかが、保存先によって変わります。
URL のクエリで渡す方法と、sessionStorage・サーバーのセッションに保存する方法の 3 つを、4 つの観点で比べます。
- 攻撃者がリダイレクト先を指定できない: 攻撃者が別のサイトのリンクで、リダイレクト先に好きな値を指定できない
- メールのリンクを新しいタブで開いても戻れる: メールで届いたログイン用のリンクを新しいタブで開いても、そのタブで元のページへ戻れる
- 別の端末で開いても戻れる: 同じリンクをスマートフォンなどの別の端末で開いても、その端末で元のページへ戻れる
- サーバーの変更が不要: ID とパスワードでログインする場合に、サーバーを変更せずに導入できる。メールのリンクでログインする場合は、どの方法でもリンクを作るサーバーを変更するので、ID とパスワードの場合で比べる
3 つの方法をこの観点で比べると、表のようになります。
| 攻撃者がリダイレクト先を指定できない | メールのリンクを新しいタブで開いても戻れる | 別の端末で開いても戻れる | サーバーの変更が不要 | |
|---|---|---|---|---|
| URL のクエリ | × | ○ | ○ | ○ |
sessionStorage |
○ | × | × | ○ |
| サーバーのセッション | ○ | ○ | × | × |
sessionStorage はタブごとに分かれており、メールのリンクから開いた新しいタブからも、別の端末のブラウザからも、元のタブで保存した値を読めません。サーバーのセッションは、同じブラウザならタブをまたいで Cookie を共有するので、メールのリンクから開いた新しいタブでも戻れます。別の端末のブラウザにはその Cookie が無いので戻れません。
sessionStorage に保存する場合は、ログインが必要なページを開いたときに URL 全体を保存し、ログインが成功したら読み出し、redirectUrl() を通してから遷移させます。
// ログインが必要なページを開いたとき
sessionStorage.setItem('redirectTo', location.href);
location.href = '/login';
// ログインが成功したとき
const redirectTo = sessionStorage.getItem('redirectTo');
sessionStorage.removeItem('redirectTo');
location.href = redirectUrl(redirectTo);
表のとおり、メールのリンクを新しいタブで開いた場合と別の端末で開いた場合の両方で戻れるのは、URL のクエリで渡す方法だけです。サーバーの変更も不要です。フィッシングと XSS は前節の検証で防止できるので、既定は URL のクエリで渡す方法でよいと考えています。
保存する方法は、検証に加えて、攻撃者がリダイレクト先を指定できないようにしたいときの選択肢です。ログインが同じタブで完結するなら sessionStorage へ、メールのリンクから開いた新しいタブでも戻したいならサーバーのセッションへ保存します。冒頭で触れた業務の設計は、このうちサーバーのセッションへ保存する方法です。
URL で渡したリダイレクト先が漏れて困る場合
URL で渡さない理由として、リダイレクト先の URL の漏洩を挙げる説明があります。URL に組織名やユーザー名を含めていると、ログイン画面の URL に含まれるリダイレクト先から、それが漏れるという説明です。
しかし、リダイレクト先の URL が漏れて困るなら、それはリダイレクト先の渡し方ではなく、URL の設計の問題です。ログインした後にリダイレクト先のページを開けば、そのページの URL は、ブラウザの履歴やサーバーのログ、ページで読み込んでいるアナリティクスにどのみち残ります。漏れて困る値は URL に含めず、推測できない ID などで表す設計にすれば、リダイレクト先を URL で渡して漏れても問題はありません。また、今のブラウザは既定の Referrer-Policy が strict-origin-when-cross-origin で、別のサイトへ送信する Referer ヘッダにはオリジンだけを含めます。そのため、既定のままなら、ログイン画面の URL に入ったリダイレクト先を Referer ヘッダで別のサイトへ送信することはありません。
例外は、招待リンクや共有リンクのように、URL そのものが秘密になっている場合です。こうした URL はトークンを含み、URL を知っている人だけが使えるので、秘密を URL から外せません。招待リンクのページは、トークンを含む URL をアナリティクスへ送信しないように作れます。しかし、その URL をリダイレクト先として URL で渡すと、トークンを含む URL がログイン画面の URL にコピーされます。その結果、ログイン画面で読み込んでいるアナリティクスやログイン画面のアクセスログのように、本来その URL を見ない相手にも、トークンを含む URL が送信・記録されることがあります。この場合は、リダイレクト先を保存するほうが安全です。
リダイレクト先の URL の漏洩とは別に、攻撃者が機密を含むページをリダイレクト先に指定して、その中身を得ようとする場合も考えられます。しかし、そのページは、ログインしたユーザー本人の権限で認可を通して、本人のブラウザに表示されるだけです。ページの中身が攻撃者に届くことはないので、攻撃は成功しません。
URL そのものが秘密になっているページをリダイレクト先にする場合を除けば、多くの場合、保存する理由は漏洩の対策ではなく、攻撃者にリダイレクト先を指定させないことにあります。
おわりに
この記事では、ログイン後のリダイレクト先を URL で渡すときに起きるフィッシングと XSS、それを防止する検証の書き方、リダイレクト先を保存する方法との比較を紹介しました。リダイレクト先を URL で渡し、解析したオリジンを比べる検証を入れれば、フィッシングと XSS を防止できます。