CSRF対策
リクエスト強要(CSRF:Cross-site Request Forgery)とは、別のサイトに用意したコンテンツ上の罠のリンクを踏ませること等をきっかけとして、インターネットショッピングの最終決済や退会等Webアプリケーションの重要な処理を呼び出すようユーザを誘導する攻撃です。
(see https://www.ipa.go.jp/security/awareness/vendor/programmingv2/contents/301.html)
この対策として、画面遷移毎にトークンを発行し、ブラウザからのリクエストトークンとサーバで保持しているトークンを比較して、不正な画面操作が行われないようにする方法があります。
MEAN
一連の仕組みを実現するためにクライアントサイドもサーバサイドもJavaScriptで書くことができるMEAN Stack(MongoDB+Express+AngularJS+Node.js)を試してみました。
仕組みを理解すれば、クライアントサイドとサーバサイドが何であれ適用できるかと思います。
シンプルなCRUDの準備にあたって、以下の記事を参考にさせて頂きました。
MongoDB+Express+AngularJS+Node.jsでシンプルなCRUDアプリ作成
また、サーバーサイドのトークンは以下の記事を参考にセッションを利用することにしました。
Node.js+Express+MongoDBでSessionを利用する、をちょっと整理して理解を試みた
トークンの発行
上記で紹介した記事にもありますように、MongoDBとExpressとNode.jsでセッションを利用するにはcookie-parseとexpress-session、connect-mongoを用いてapp.jsで設定します。
var cookieParser = require('cookie-parser');
var session = require('express-session');
var MongoStore = require('connect-mongo')(session);
app.use(cookieParser());
app.use(session({
secret: 'secret',
store: new MongoStore({
db: 'session',
host: 'localhost',
clear_interval: 60 * 60
}),
cookie: {
httpOnly: false,
maxAge: 60 * 60 * 1000
}
}));
この設定でセッションIDが発行されます。req.sessionをみるとセッションの情報を確認することができると思います。
トークンをクライアントに渡す
サーバサイドでトークン(セッション)を利用することはできるようになりましたが、今度はこれを画面遷移ごとに再発行し、クライアントサイドに渡す必要があります。
クライアントに渡す手段としては、node.jsのテンプレートエンジンectを利用しました。
var ECT = require('ect');
var ectRenderer = ECT({ watch: true, root: __dirname + '/views', ext : '.ect' });
var routes = require('./routes/index');
app.engine('ect', ectRenderer.render);
app.set('view engine', 'ect');
app.use('/', routes);
トークンの再発行はroutes/index.js
で/
にアクセスする際に行うようにしました。
クライアントサイドにはsessionID
のキーで渡されます。
var express = require('express');
var router = express.Router();
router.get('/', function(req, res) {
req.session.regenerate(function(err) {
req.session.save(function(err) {
res.render('index', {sessionID: req.sessionID});
});
});
});
module.exports = router;
クライアントサイドではサーバサイドから渡されたトークンをviews/index.etc
のmeta
タグに設定します。そして、jQueryを利用してmetaタグにアクセスしてトークンを取得し、CSRF対策を行いたいリクエストのヘッダに設定します。※saveWithCheck
の部分
<!doctype html>
<html lang="ja" ng-app="app">
<head>
<meta charset="utf-8">
<meta name="sessionID" content="<%= @sessionID %>">
<title>ユーザー管理</title>
<script src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.2.16/angular.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.2.16/angular-resource.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.2.16/angular-route.min.js"></script>
<script src="//code.jquery.com/jquery-2.1.1.min.js"></script>
<script>
var app = angular.module('app', ['ngResource', 'ngRoute']);
app.config(function($routeProvider, $httpProvider) {
$routeProvider.when('/users', {
templateUrl: 'list.html', controller: 'ListCtrl'
}).when('/users/:_id', {
templateUrl: 'edit.html', controller: 'EditCtrl'
}).otherwise({
redirectTo: '/users'
});
$httpProvider.interceptors.push('DoubleSubmitInterceptor');
});
app.factory('DoubleSubmitInterceptor', function($injector, $rootScope, $q) {
return {
request : function(config) {
var $http = $injector.get('$http');
var requestToken = config.headers['X-Request-Token'];
function checkIfDuplicated(config) {
var duplicated = $http.pendingRequests.filter(
function(pendingConfig) {
return pendingConfig.headers['X-Request-Token']
&& pendingConfig.headers['X-Request-Token'] === requestToken;
}
);
return duplicated.length > 0;
}
if (requestToken && checkIfDuplicated(config)) {
return $q.defer().promise;
}
return config || $q.when(config);
},
response : function(response) {
return response;
}
}
});
app.factory('User', function($resource) {
return $resource('/api/users/:_id', {_id: '@_id'}, {
saveWithCheck: {method:'POST', headers:{'X-Request-Token':$("meta[name=sessionID]").attr("content")}}
});
});
app.controller('ListCtrl', function($scope, $route, User) {
$scope.users = User.query();
$scope.delete = function(_id) {
User.delete({_id: _id}, function() {
$route.reload();
});
};
});
app.controller('EditCtrl', function($scope, $routeParams, $location, User) {
if ($routeParams._id != 'new') $scope.user = User.get({_id: $routeParams._id});
$scope.edit = function() {
User.saveWithCheck($scope.user, function() {
$location.url('/');
},
function(error) {
// 非同期リクエストエラー時の処理
});
};
});
</script>
</head>
<body>
<div ng-view></div>
</body>
</html>
対象のリクエストはEditCtrl
コントローラの$scope.edit
です。ここではさらに二重サブミットを防止しています。詳しくは以下を参照してください。
pendingRequestsで二重のサブミットを防止する
サーバサイドでトークンチェックを行う
最後にブラウザからのリクエストトークンとサーバで保持しているトークンを比較します。
これはnode.jsのInterceptorを利用して行いました。
app.use(function(req, res, next) {
if (req.headers["x-request-token"] &&
req.session.id != req.headers["x-request-token"]) {
// エラー処理
}
next();
});
CSRF対策は以上です。
最後にapp.jsの全体を載せます。
※本稿では使用していないfunctionも参考にさせて頂いた記事のまま載せておきます。
var express = require('express');
var bodyParser = require('body-parser');
var mongodb = require('mongodb');
var cookieParser = require('cookie-parser');
var session = require('express-session');
var MongoStore = require('connect-mongo')(session);
var app = express();
var BSON = mongodb.BSONPure;
var db, users;
var ECT = require('ect');
var ectRenderer = ECT({ watch: true, root: __dirname + '/views', ext : '.ect' });
var routes = require('./routes/index');
app.engine('ect', ectRenderer.render);
app.set('view engine', 'ect');
app.use(express.static('views/template'));
app.use(bodyParser.json());
app.use(cookieParser());
app.use(session({
secret: 'secret',
store: new MongoStore({
db: 'session',
host: 'localhost',
clear_interval: 60 * 60
}),
cookie: {
httpOnly: false,
maxAge: 60 * 60 * 1000
}
}));
app.use(function(req, res, next) {
if (req.headers["x-request-token"] &&
req.session.id != req.headers["x-request-token"]) {
// エラー処理
}
next();
});
app.use('/', routes);
mongodb.MongoClient.connect("mongodb://localhost:27017/test", function(err, database) {
db = database;
users = db.collection("users");
app.listen(3000);
});
// 一覧取得
app.get("/api/users", function(req, res) {
users.find().toArray(function(err, items) {
res.send(items);
});
});
// 個人取得
app.get("/api/users/:_id", function(req, res) {
users.findOne({_id: new BSON.ObjectID(req.params._id)}, function(err, item) {
res.send(item);
});
});
// 追加
app.post("/api/users", function(req, res) {
var user = req.body;
users.insert(user, function() {
res.send("insert");
});
});
// 更新
app.post("/api/users/:_id", function(req, res) {
var user = req.body;
delete user._id;
users.update({_id: new BSON.ObjectID(req.params._id)}, user, function() {
res.send("update");
});
});
// 削除
app.delete("/api/users/:_id", function(req, res) {
users.remove({_id: new BSON.ObjectID(req.params._id)}, function() {
res.send("delete");
});
});