Node.js + Express で CSRF対策 を行う 方法

0 件のコメント

Node.js + Express 環境において CSRF対策 を行う方法について今回はまとめていきます。 ちなみに、本記事で CSRF対策 として利用するミドルウエアは csrf です。 他の記事で csurf を利用したものがあったので参考に実装してみたのですが… csurf は一度発行したトークンが再利用できた点がセキュリティ的に怪しい動作なので、使うのを止めました。。

CSRFとは

詳しい内容は別記事がかけそうなくらいなので、ここでは概要だけおさらいします。

CSRF (cross-site request forgeries) は 掲示板や問い合わせフォームなどを処理するWebアプリケーションが、 本来拒否すべき他サイトからのリクエストを受信し処理してしまう脆弱性。

主な対策はこの記事でも取り上げる「正規ページであることを示すトークンを検証する」方法になります。

対策すべき画面は「データ作成・更新・削除を行う画面」です。 例えば上記であげている「掲示板の書き込み」や「問い合わせフォームの送信」、「ユーザー登録」、「パスワード変更」などが対象になります。

csrf ミドルウェア の 使い方

csrf の使い方として、大まかな流れを以下に記載します。

var Tokens = new require("csrf");
var tokens = new Tokens();
var secret = tokens.secretSync();    // secret は セッション に保存
var token = tokens.create(secret);   // token は フォーム に返却
if (tokens.verify(secret, token) === false) {
  throw new Error("invalid token");
}

L1-2
まずは Tokens インスタンス を生成します。 生成した tokens を利用して実際に利用する 秘密文字 (secret) や トークン (token) の生成 および それらの検証(verify) を行います。

L3
秘密文字を生成します。 秘密文字の生成はユーザー毎(セッション毎)に生成します。 また、一度検証を行って通過させた秘密文字とトークンの組み合わせは破棄して新規に生成しなおす方が望ましいです(=セッションで固定化すると悪意あるユーザーにバレる危険が増すため)。 生成した秘密文字は必ずセッションに保存するようにし、クライアントに返却は行いません。 ちなみに秘密文字は非同期生成できるので、実装できるなら非同期 ( tokens.secret(callback) または Promise ) で実装した方が良いかと思います。 ただし、この記事では分かりやすさを優先させるため同期で記載します。

L4
先に生成した秘密文字を利用してトークンを生成します。 生成したトークンはフォームに埋め込むかクッキーに埋め込んでクライアントに返しておき、クライアントからサーバーへ処理依頼をするときにあわせてトークンをサーバーへ戻してもらいます。

L5
tokens.verify(secret, token) で 秘密文字 と トークン の検証を行います。

csrf ミドルウェア を使った 実装例

Node.js + Express を使って CSRF対策 を行う実装例を以下に載せます…が、すべてのコードを載せると長くなるので一部抜粋です。。 以下の実装例では 秘密文字をセッション に、トークンをクッキーに 保存するよう実装しています。 以下のコードには出てきませんが、セッションとクッキーを利用するので express-session および cookie-parser を事前に設定しておく必要があります。

var router = require("express").Router();
var Tokens = require("csrf");
var tokens = new Tokens();

// …中略 (extract, validate, commit 処理) …

// GET: /shop/regist/input
router.get("/regist/input", function (request, response) {
  // 新規に 秘密文字 と トークン を生成
  var secret = tokens.secretSync();
  var token = tokens.create(secret);

  // 秘密文字はセッションに保存
  request.session._csrf = secret;

  // トークンはクッキーに保存
  response.cookie("_csrf", token);

  // 入力画面の表示
  response.render("./shop/regist/input.ejs");
});

// POST: /shop/regist/complete
router.post("/regist/complete", function (request, response) {
  // 秘密文字 と トークン を取得
  var secret = request.session._csrf;
  var token = request.cookies._csrf;

  // 秘密文字 と トークン の組み合わせが正しいか検証
  if (tokens.verify(secret, token) === false) {
    throw new Error("Invalid Token");
  }

  // 入力データを取得
  var data = extract(request);

  // 入力データの検証
  if (validate(data) === false) {
    return response.render("./shop/regist/input.ejs", data);
  }

  // 登録処理
  commit(data);

  // 使用済み 秘密文字 と トークン の無効化
  delete request.session._csrf;
  response.clearCookie("_csrf");

  // 完了画面へリダイレクト
  response.redirect("/shop/regist/complete");
});

module.exports = router;

L10, 11
入力画面が最初に表示されたタイミングで新規秘密文字とトークンを生成します。

L14
生成した秘密文字はセッションに保存します。

L17
生成したトークンはクッキーに保存します。 クッキーでなければクライアントのフォームへ返してもよいです。 WebAPI を考えるとクッキーへ返すのが良いきがしますが…。

L26, 27
完了処理では秘密文字をセッションから取り出し、トークンはクッキーから取り出します。

L30-32
取り出した秘密文字とトークンを検証します。 検証して適切な組み合わせでない場合、エラーを発生させて以後の処理を停止します。

L46,47
一度使った秘密文字とトークンの組み合わせは完了時に初期化します。 初期化しておかないと再利用される懸念が出てきてしまいます。

今回のサンプルは参考になったでしょうか? CSRF対策でポイントは以下になります。

  • 対策を行う処理はデータ作成・変更・削除を行う画面
  • 入力画面の表示時に新規秘密文字と新規トークンを生成
  • 作成・変更・削除の実処理が呼び出されたとき秘密文字とトークンの組み合わせを検証
  • 一度使った秘密文字とトークンは初期化