はじめに
前回は機能だけ満足するよう実装しましたが、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/) にアクセスして、問題なく動作しているか確認しましょう。うまく動きましたか?