AngularのXSRF対策
以前Angularでログインページを作成しました。
その時にはXSRF(Cross-Site Request Forgery)に関連するセキュリティについては考慮していなかったので、Angular:「セキュリティ」を参考に実装していきたいと思います。
ログイン構造の脆弱性
ログインシステムの構造は、Webサーバー側でセッション変数として認証情報を保持しておくものでした。
クライアントはセッションを復元できるセッションIDをCookieと一緒に送ることにより、ログイン状態を維持できます。
認証サイトとアプリのサイトが違う際でも利用できるようにsamesite属性を「None」にしましたが、この状態だとXSRF攻撃に対して脆弱です。
悪意のあるサイトが作成したページ上のスクリプトからの正規のサーバーに対してAJAXする際にも、セッション復元するためのCookieが送信されます。
サイトの構造によってはユーザーの意図していない操作を第三者が実行できてしまうことになります。
※図中のアイコンは CC BY 4.0 SAKURA internet Inc. を利用させていただきました。
AngularにおけるXSRF対策
XSRF対策の概要は次の通りです。
まずユーザーがサーバーへアクセスした際、サーバーはXSRF用のトークンをCookieの値としてセットします。
Cookieを受け取ったユーザーは、その中からトークンを取り出して次回アクセス時からはトークンをHTTPヘッダーにセットします。
サーバーはCookieで送信されるトークンの値と、HTTPヘッダーに送信されてくる値を照合し不一致の場合は不正アクセスと判断してしかるべき対応をします。
悪意のあるサーバーから受信されるスクリプトは、ドメインが違うので本来のサーバーのCookieの中身までは見ることはできません。
※図中のアイコンは CC BY 4.0 SAKURA internet Inc. を利用させていただきました。
Angularに備わっているHttpClientはデフォルトでこのXSRF対策用の実装がありますが、この実装はサーバーとセットで対策がなされていないと意味がありません。
また、AngularのHttpClientのXSRF保護の実装では、稼働の条件があり次のようになっています。
- 相対URLへのアクセス時
- メソッドがすべての変更リクエスト(POSTの他はPUT,DELITE,PATCHだと思われます)
条件が成立した時にCookieにトークンがあれば、AngularのHttpClientはCookieから「XSRF-TOKEN」の値をを取得し、HTTPヘッダーの「X-XSRF-TOKEN」にその値をセットしたうえでAJAX通信をします。
どこへでもCookieの中身を送信したら対策の意味がないので相対URLという条件は理にかなっていますが、もしかしたら絶対パスを使いたいケースがあるかもしれませんので、手動で実装するコードの例を紹介します。
ただし、攻撃者がCookieを読み込めないのと同じ理由で、アプリを動かすサーバーと認証サーバーが別の場合も、Cookieの値を読み込めないので(JSはアプリのドメインで動きますが、Cookieは認証サーバーのものが必要です)、この方法は使えません。
component.ts
...
// cookieからトークン取得
public getToken(): string {
let cookieLine = document.cookie;
if (cookieLine=='') {
return 'none';
}
let cookies = cookieLine.split(';');
for(let i = 0; i < cookies.length; i++) {
if (cookies[i].trim().startsWith('XSRF-TOKEN')) {
let kv = cookies[i].trim().split('=');
if (1 < kv.length) {
return kv[1].trim();
}
}
}
return 'none';
}
const httpOptions = {
headers: new HttpHeaders({
'Content-Type': 'application/json',
'X-XSRF-TOKEN': this.getToken();
}),
withCredentials: true,
};
// AJAX
this.http.post<any>(this.LOGIN_APP_URL, authParam, httpOptions).subscribe(...
...
HttpHeadersのインスタンスはイミュータブルで一度生成してしまうと通常の方法では変更できないので注意が必要です。ここでは常に新しいインスタンスを生成しています。
サーバー側の実装
次にサーバー側の実装です。たとえばPHPサーバーならsetcookieメソッドを利用して、XSRF-TOKENというCookieの値をセットします。
JavaScriptからCookieの内容を確認する必要があるので、JavaScriptでのCookie操作を拒否するhttponly属性はfalseにする必要があります。
公式のガイドによればサーバー読み込み時か初回のGETアクセス時にCookieをセットするそうですが、セッションの開始時(ログイン状態の確認時)にXSRF-TOKENがCOOKIEに存在しなかったらセットするようにし、存在した場合はその照合とログイン状態を含めて結果を返すようにしました。
チェックでは$_COOKIE['XSRF-TOKEN']と、ヘッダーの「X-XSRF-TOKEN」の項目を比較します。不一致の際は、エラーページやログインページに誘導されるようにコードを組みます。
デバッグ時のみCORS環境にして、その際はサーバー側でXSRFチェックをしないという設定にした場合は、デバッグ環境に接続する時はサーバー側のHTTPヘッダーのCORS設定である「Access-Control-Allow-Headers」にも「X-XSRF-TOKEN」も追加する必要があります。Chromeのデバッグ環境で「Request Headers」を参照した際、「Provisional headers are shown」というワーニングがでている場合は、CORSの設定でうまくヘッダが送信できていない可能性があります。
他のCORSの設定については、Angularでログインページを作成した際の記事を参照してください。
login.php
...
//CORS許可ヘッダー(サンプルです。必要に応じて追加してください)
header('Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, X-XSRF-TOKEN');
//cookieにXSRF-TOKENをセット
setcookie(
'XSRF-TOKEN',
bin2hex(random_bytes(12)),//トークン
[
'expires' => time()+60*60*24*1,
'path' => '/',
'domain' => 'localhost',
'secure' => true,
'httponly' => false,
'samesite' => 'None'
]
);
...
function checkXsrf() {
//XSRFチェック falseの際が不正アクセス
$headers = getallheaders();
if (isset($_COOKIE['XSRF-TOKEN'],$headers['x-xsrf-token'])) {
if ($_COOKIE['XSRF-TOKEN']==$headers['x-xsrf-token']) {
return true;
} else {
return false;
}
} else {
return false;
}
}
...
getallheaders()で取得できる連想配列のインデックスが、大文字か小文字か混在か確認の上で設定してください。Angular側で「X-XSRF-TOKEN」とすべて大文字で設定しても、その通りに設定されるわけではないようです。