Node.js express csurfでCSRF対策

CSRFとは
https://www.ipa.go.jp/security/vuln/websecurity-HTML-1_6.html

Cookieを発行し、ログイン済の状態となっているユーザー(ブラウザ)が悪意のあるサイトを閲覧。
悪意のあるサイトの埋め込みスクリプトが直接自サイトの投稿フォームにPOSTしてきて、意図しない書き込みが行われる。
という感じでしょうか。

csurfパッケージを使用し対策してみます。
https://www.npmjs.com/package/csurf


インストール



こちらで作成した環境に追加しました。
Node.js express セッション情報の保存にRedisを使用する

POSTデータを送る画面も欲しかったので、ejsを追加でインストールしています。


$ npm install csurf ejs



[email protected]
[email protected]
がインストールできました。




ベースとなるアプリケーション



ボタンを押すとカウントアップするサンプルを作成しました。
こちらをベースにCSRF対策を追加してみます。

・index.js


  1. const express = require('express');
  2. const session = require('express-session');
  3. const app = express();
  4. const port = 3000;
  5. // viewの表示はejsを使用
  6. app.set('view engine', 'ejs');
  7. app.use(express.json());
  8. app.use(express.urlencoded({ extended: true }));
  9. app.use(session({
  10.     secret: 'secret_key',
  11.     resave: false,
  12.     saveUninitialized: false
  13. }));
  14. app.get('/', (req, res) => {
  15.     if (!req.session.views) {
  16.         req.session.views = 1;
  17.     }
  18.     res.render('sample.ejs', {views: req.session.views});
  19. });
  20. app.post('/countup', (req, res) => {
  21.     // カウントアップして再描画
  22.     req.session.views++;
  23.     res.render('sample.ejs', {views: req.session.views});
  24. });
  25. app.listen(port, () => {
  26. console.log(`Example app listening at http://localhost:${port}`);
  27. });




・views/sample.ejs


  1. <!DOCTYPE html>
  2. <html lang="ja">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0">
  6. <title>csurfサンプル</title>
  7. <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
  8. </head>
  9. <body>
  10.     <div>
  11.         表示回数:<%= views %>
  12.     </div>
  13.     <form method="POST" action="/countup">
  14.         <input type="submit" value="カウントアップ">
  15.     </form>
  16. </body>
  17. </html>





ボタンを押すと表示回数がカウントアップされます。

a74_01.png

a74_02.png





csurf追加



効果を確かめるため、サンプルを参考にcsurfを追加してみます。

・index.js


  1. const express = require('express');
  2. const session = require('express-session');
  3. // csurf追加
  4. const csrf = require('csurf');
  5. const app = express();
  6. const port = 3000;
  7. // viewの表示はejsを使用
  8. app.set('view engine', 'ejs');
  9. app.use(express.json());
  10. app.use(express.urlencoded({ extended: true }));
  11. const csrfProtection = csrf({ cookie: false });
  12. app.use(session({
  13.     secret: 'secret_key',
  14.     resave: false,
  15.     saveUninitialized: false
  16. }));
  17. app.get('/', csrfProtection, (req, res) => {
  18.     if (!req.session.views) {
  19.         req.session.views = 1;
  20.     }
  21.     res.render('sample.ejs', {views: req.session.views});
  22. });
  23. app.post('/countup', csrfProtection, (req, res) => {
  24.     // カウントアップして再描画
  25.     req.session.views++;
  26.     res.render('sample.ejs', {views: req.session.views});
  27. });
  28. app.listen(port, () => {
  29. console.log(`Example app listening at http://localhost:${port}`);
  30. });




この状態でボタンを押すと、

ForbiddenError: invalid csrf token


というエラーとなりました。

a74_03.png

ちゃんとtokenをチェックしていますね。


POST送信時、トークンも送信するよう修正します。
トークンは、req.csrfToken()で取得できます。

・index.js


  1. const express = require('express');
  2. const session = require('express-session');
  3. // csurf追加
  4. const csrf = require('csurf');
  5. const app = express();
  6. const port = 3000;
  7. // viewの表示はejsを使用
  8. app.set('view engine', 'ejs');
  9. app.use(express.json());
  10. app.use(express.urlencoded({ extended: true }));
  11. const csrfProtection = csrf({ cookie: false });
  12. app.use(session({
  13.     secret: 'secret_key',
  14.     resave: false,
  15.     saveUninitialized: false
  16. }));
  17. app.get('/', csrfProtection, (req, res) => {
  18.     if (!req.session.views) {
  19.         req.session.views = 1;
  20.     }
  21.     // tokenを画面に設定
  22.     res.render('sample.ejs', {views: req.session.views, csrfToken: req.csrfToken()});
  23. });
  24. app.post('/countup', csrfProtection, (req, res) => {
  25.     // カウントアップして再描画
  26.     req.session.views++;
  27.     // tokenを画面に設定
  28.     res.render('sample.ejs', {views: req.session.views, csrfToken: req.csrfToken()});
  29. });
  30. app.listen(port, () => {
  31. console.log(`Example app listening at http://localhost:${port}`);
  32. });




・views/sample.ejs


  1. <!DOCTYPE html>
  2. <html lang="ja">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0">
  6. <title>csurfサンプル</title>
  7. <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
  8. </head>
  9. <body>
  10.     <div>
  11.         表示回数:<%= views %>
  12.     </div>
  13.     <form method="POST" action="/countup">
  14.         <!-- csrfトークンも送信するよう修正 -->
  15.         <input type="hidden" name="_csrf" value="<%= csrfToken %>">
  16.         <input type="submit" value="カウントアップ">
  17.     </form>
  18. </body>
  19. </html>




これで修正前と同様、ボタンを押すとカウントアップできるようになりました。

a74_04.png




ajaxの場合



ajaxでPOSTデータを送信し、データを更新するパターンに修正してみます。

・index.js


  1. const express = require('express');
  2. const session = require('express-session');
  3. // csurf追加
  4. const csrf = require('csurf');
  5. const app = express();
  6. const port = 3000;
  7. // viewの表示はejsを使用
  8. app.set('view engine', 'ejs');
  9. app.use(express.json());
  10. app.use(express.urlencoded({ extended: true }));
  11. const csrfProtection = csrf({ cookie: false });
  12. app.use(session({
  13.     secret: 'secret_key',
  14.     resave: false,
  15.     saveUninitialized: false
  16. }));
  17. app.get('/', csrfProtection, (req, res) => {
  18.     if (!req.session.views) {
  19.         req.session.views = 1;
  20.     }
  21.     // tokenを画面に設定
  22.     res.render('sample.ejs', {views: req.session.views, csrfToken: req.csrfToken()});
  23. });
  24. app.post('/countup', csrfProtection, (req, res) => {
  25.     // カウントアップ
  26.     req.session.views++;
  27.     // 値をリターン
  28.     res.send({views: req.session.views});
  29. });
  30. app.listen(port, () => {
  31. console.log(`Example app listening at http://localhost:${port}`);
  32. });




・views/sample.ejs


  1. <!DOCTYPE html>
  2. <html lang="ja">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0">
  6. <title>csurfサンプル</title>
  7. <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
  8. </head>
  9. <body>
  10.     <div>
  11.         表示回数:<span id="views"><%= views %></span>
  12.     </div>
  13.     <input type="button" value="カウントアップ" id="countup">
  14. <script>
  15. $(function() {
  16.     $('#countup').on('click', function() {
  17.         $.ajax({
  18.             // 送信ヘッダーにtokenを設定する
  19.             headers: {
  20.                 'csrf-token': '<%= csrfToken %>'
  21.             },
  22.             type: 'POST',
  23.             url: '/countup'
  24.         }).done(function(data) {
  25.             $('#views').text(data.views);
  26.         });
  27.     });
  28. });
  29. </script>
  30. </body>
  31. </html>




ポイントはajaxでデータ送信時のヘッダーに「csrf-token」という名前で生成されたtokenを設定する点でしょうか。
キー名は大文字小文字は区別しません。「CSRF-Token」としてもOKです。

指定できる値は複数存在します。
以下、いずれのキー名でも動作します。
・csrf-token
・xsrf-token
・x-csrf-token
・x-xsrf-token



headerへの設定が難しい場合



使用しているライブラリの都合などでヘッダーへのtoken設定が難しい場合、
送信のbodyデータやurlにtokenを含めることで対応できます。

双方、「_csrf」というキーで値を設定します。
bodyにtokenを設定する場合。


  1. <!DOCTYPE html>
  2. <html lang="ja">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0">
  6. <title>csurfサンプル</title>
  7. <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
  8. </head>
  9. <body>
  10.     <div>
  11.         表示回数:<span id="views"><%= views %></span>
  12.     </div>
  13.     <input type="button" value="カウントアップ" id="countup">
  14. <script>
  15. $(function() {
  16.     $('#countup').on('click', function() {
  17.         $.ajax({
  18.             type: 'POST',
  19.             url: '/countup',
  20.             // 送信データにtokenを設定する
  21.             data: {
  22.                 _csrf: '<%= csrfToken %>'
  23.             }
  24.         }).done(function(data) {
  25.             $('#views').text(data.views);
  26.         });
  27.     });
  28. });
  29. </script>
  30. </body>
  31. </html>




URLパラメーターに含める場合


  1. <!DOCTYPE html>
  2. <html lang="ja">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0">
  6. <title>csurfサンプル</title>
  7. <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
  8. </head>
  9. <body>
  10.     <div>
  11.         表示回数:<span id="views"><%= views %></span>
  12.     </div>
  13.     <input type="button" value="カウントアップ" id="countup">
  14. <script>
  15. $(function() {
  16.     $('#countup').on('click', function() {
  17.         $.ajax({
  18.             type: 'POST',
  19.             // urlにtokenを含める場合
  20.             url: '/countup?_csrf=<%= csrfToken %>',
  21.         }).done(function(data) {
  22.             $('#views').text(data.views);
  23.         });
  24.     });
  25. });
  26. </script>
  27. </body>
  28. </html>


関連記事

プロフィール

Author:symfo
blog形式だと探しにくいので、まとめサイト作成中です。
https://symfo.web.fc2.com/

PR

検索フォーム

月別アーカイブ