カテゴリー
プログラミング

Node.js (Server API) と React + Flux (Client) で CSRF 対策

はじめに

前回は機能だけ満足するよう実装しましたが、POST、PUT、DELETE といったサーバー側のデータを変更するようなリクエストを受け付けるには CSRF 対策が欠かせません。今回は、前回のカウントアップアプリに CSRF 対策を施します。

API 側の CSRF 対策

./index.js 修正

Node.js に CSRF 対策機能を実装するために、csurf モジュールを使用します。また、セッション管理も必要になりますので、セッション情報を MongoDB に格納することにします。これには express-session モジュールと connect-mongo モジュールを使用することにします。さらに、POST や DELETE で CSRF トークンをリクエストボディから取り出すために、body-parser パッケージを使用します。

./index.js

var express = require('express')
  , mongoose = require('mongoose')
  , connectMongo = require('connect-mongo')
  , expressSession = require('express-session')
  , csrf = require('csurf')
  , bodyParser = require('body-parser');

var MongoStore = connectMongo(expressSession);

var app = express();

app.set('port', (process.env.PORT || 5000));
mongoose.connect(process.env.MONGOLAB_URI);

app.use(express.static(__dirname + '/public'));
app.use(bodyParser.json());

// for CSRF Protection
app.use(expressSession({
  secret: process.env.APP_SECRET,
  store: new MongoStore({
    mongooseConnection: mongoose.connection
  }),
  resave: false,
  saveUninitialized: true
}));
app.use(csrf());

var counter = require('./routes/counter');
app.get('/counter', counter.get);
app.post('/counter', counter.post);
app.delete('/counter', counter.delete);

app.listen(app.get('port'), function() {
  console.log('Node app is running on port', app.get('port'));
});

セッション用のシークレットとして環境変数 APP_SECRET を参照するように実装しました。環境変数はローカル環境では .env に記述します。

./.env 修正

./.env

MONGOLAB_URI="mongodb://localhost:27017/devel-heroku-nodereactflux"
APP_SECRET="hCCBtRDRKU2a4lJ0K5hwWQEBn7HnoIKfftPz6JvG"

シークレット向けのランダム文字列を手軽に生成できるように Python 1 liner コードを紹介します。
 末尾の引数「40」は生成される文字列の桁数です。必要に応じて適当な値を指定してください。

$ python -c 'import random, sys; print("".join([random.choice("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") for i in range(0, int(sys.argv[1]))]))' 40

./routes/counter.js 修正

CSRF トークンをクライアント側アプリに送る必要があります。レスポンスに CSRF トークンを含めるようにします。

./routes/counter.js

// This route handles singleton resource counter.

var Counter = require('../models/counter');

// create document if not exists.
Counter.findOne({}, function (error, result) {
  if ( result === null ) {
    var counter = new Counter({
      count: 0
    });
    counter.save(function (error, result) {});
  }
});

// main proccesses.
module.exports = {
  get: function (request, response) {
    Counter.findOne({}, function (error, result) {
      response.json({
        id: result['_id'],
        count: result['count'],
        _csrf: request.csrfToken()
      });
    });
  },
  post: function (request, response) {
    Counter.findOne({}, function (error, result) {
      var counter = result;
      counter.count += 1;
      counter.save(function (error, result) {
        response.json({
          id: result['_id'],
          count: result['count'],
          _csrf: request.csrfToken()
        });
      });
    });
  },
  delete: function (request, response) {
    Counter.findOne({}, function (error, result) {
      var counter = result;
      counter.count = 0;
      counter.save(function (error, result) {
        response.json({
          id: result['_id'],
          count: result['count'],
          _csrf: request.csrfToken()
        });
      });
    });
  }
};

request.csrfToken() は csurf モジュールを use して使えるようになったメソッドで、新しく CSRF トークンを生成します。

./package.json 修正とパッケージインストール

Node.js 向けのモジュールを追加で利用しましたので、依存パッケージとして ./package.json に追加しておきます。

./package.json

{
  "name": "node-js-getting-started",
  "version": "0.1.5",
  "description": "A sample Node.js app using Express 4",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "ejs": "2.3.3",
    "express": "4.13.3",
    "mongoose": "~4.1.10",
    "kerberos": "0.0.9",
    "express-session": "~1.11.3",
    "connect-mongo": "~0.8.2",
    "csurf": "~1.8.3",
    "body-parser": "~1.14.1"
  },
  "engines": {
    "node": "0.12.7"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/heroku/node-js-getting-started"
  },
  "keywords": [
    "node",
    "heroku",
    "express"
  ],
  "license": "MIT"
}
$ npm install

動作確認

API 側の実装は以上です。ここで簡単にテストしましょう。まず、GET リクエストはこれまで通り現在のカウントを取得できます。POST リクエストと DELETE リクエストは CSRF トークンなしのままだと認証エラー (403 エラー) となることを確認します。

$ curl -L http://localhost:10080/counter/ -X 'GET'
{"id":"5625a5426bf5c2836a887793","count":0,"_csrf":"jGa8uPUP-P0ydrFiZgwfxhKJTdicK-Hq7b-o"} ← count と共に CSRF トークンも取得できる。

$ curl -L http://localhost:10080/counter/ -X 'POST'
ForbiddenError: invalid csrf token
    at verifytoken ... ← ForbiddenError が発生する。

$ curl -L http://localhost:10080/counter/ -X 'GET'
{"id":"5625a5426bf5c2836a887793","count":0,"_csrf":"jGa8uPUP-P0ydrFiZgwfxhKJTdicK-Hq7b-o"} ← count の値は変わっていない。

$ curl -L http://localhost:10080/counter/ -X 'DELETE'
ForbiddenError: invalid csrf token
    at verifytoken ... ← ForbiddenError が発生する。

React 側の CSRF 対策

クライアント側はサーバー API と直接やり取りする CountupAPIUtils のみ修正します。

CSRF トークンはクライアント側の窓口である CountupAPIUtils だけが管理すれば十分です(※)。クライアント側での CSRF トークンの保持には LocalStorage を使用してみました。

./public/src/apiutils/CountupAPIUtils.js

var CountupAPIActions = require('../actions/CountupAPIActions');

function setCSRFToken (token) {
    localStorage.setItem('CountupAPIUtilCSRFToken', token);
}
function getCSRFToken () {
    return localStorage.getItem('CountupAPIUtilCSRFToken');
}

module.exports = {
    reload: function () {
        $.ajax({
            url: '/counter/',
            type: 'GET'
        }).done( function (result) {
            setCSRFToken(result._csrf);
            CountupAPIActions.apisynced(result.count);
        }).fail( function (result) {
            console.log(result);
        });
    },
    countup: function () {
        $.ajax({
            url: '/counter/',
            type: 'POST',
            contentType: 'application/json; charset=utf-8',
            dataType: 'json',
            data: JSON.stringify({
                _csrf: getCSRFToken()
            })
        }).done( function (result) {
            setCSRFToken(result._csrf);
            CountupAPIActions.apisynced(result.count);
        }).fail( function (result) {
            console.log(result);
            this.reload();
        }.bind(this));
    },
    reset: function () {
        $.ajax({
            url: '/counter/',
            type: 'DELETE',
            contentType: 'application/json; charset=utf-8',
            dataType: 'json',
            data: JSON.stringify({
                _csrf: getCSRFToken()
            })
        }).done( function (result) {
            setCSRFToken(result._csrf);
            CountupAPIActions.apisynced(result.count);
        }).fail( function (result) {
            console.log(result);
            this.reload();
        }.bind(this));
    }
};

「CSRF トークンはクライアント側の窓口である CountupAPIUtils だけが管理すれば十分です。」について
 もしこの CSRF トークンをストア CountupStore で管理することを考えた場合、関係する React の コンポーネントやアクションにまで CSRF トークンのやりとりをする必要が出てきてしまいます。CSRF トークンはあくまでサーバー API との通信で必要なセキュリティ対策に使用するものなので、ストアでは管理せず、APIUtils に納めておく方がよいと私は考えます。

countup や reset が失敗すると、このときに使った CSRF トークンはサーバー側で失効します。処理が失敗したときは localStorage から CSRF トークンを削除すれば同期が取れるのですが、次に countup や reset を実行するとき、新しい CSRF トークンを取得するためにページを再読み込みする必要があり使い勝手が悪くなります。これを改善するため、countup や reset が失敗しても、新しい CSRF トークンを取得するよう、reload() を実行するようにしました。.fail() 内の function で CountupAPIUtils.reload() を呼び出したいので、function の末尾に .bind(this) をつけています。

動作確認

クライアント側の実装も完了しました。ここでクライアント側をビルドして動作確認しましょう。ボタン「Countup」とボタン「Reset」をクリックしてみてエラーにならず動作すること、LocalStorage にある CSRF トークンを削除してからボタン「Countup」やボタン「Reset」をクリックするとエラーになること、を確認しましょう。

うまく動きましたか?

本番環境 Heroku へのデプロイ

MongoLAB コレクション追加

セッション管理用のコレクション「sessions」を追加しておきます。

環境変数 APP_SECRET 設定

今回、セッション向けのシークレットを独自の環境変数「APP_SECRET」に格納しました。本番環境にはまだ設定していませんので、ここで設定しておきます。

(heroku toolbelt ログイン)
$ heroku login

(環境変数 APP_SECRET 設定)
$ heroku config:set APP_SECRET="XZ3ja9g5Ft0NIxn08STOJAziiCVJwYMCzaWEvwLG"

(環境変数 設定確認)
$ heroku config

本番デプロイ

準備が整いましたので、アプリをデプロイしましょう。

$ git commit -am "v0.0.2"
$ git push heroku master

動作確認

Heroku アプリサイト (https://<heroku-app-name>.herokuapp.com/) にアクセスして、問題なく動作しているか確認しましょう。うまく動きましたか?

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください