LoginSignup
21
21

More than 5 years have passed since last update.

MongoDB+Express+AngularJS+Node.jsでCSRF対策

Posted at

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で設定します。

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を利用しました。

app.js
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のキーで渡されます。

routes/index.js
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.etcmetaタグに設定します。そして、jQueryを利用してmetaタグにアクセスしてトークンを取得し、CSRF対策を行いたいリクエストのヘッダに設定します。※saveWithCheckの部分

views/index.ect
<!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.js
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も参考にさせて頂いた記事のまま載せておきます。

app.js
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");
  });
});

21
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
21