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
- const express = require('express');
- const session = require('express-session');
- const app = express();
- const port = 3000;
- // viewの表示はejsを使用
- app.set('view engine', 'ejs');
- app.use(express.json());
- app.use(express.urlencoded({ extended: true }));
- app.use(session({
- secret: 'secret_key',
- resave: false,
- saveUninitialized: false
- }));
- app.get('/', (req, res) => {
- if (!req.session.views) {
- req.session.views = 1;
- }
- res.render('sample.ejs', {views: req.session.views});
- });
- app.post('/countup', (req, res) => {
- // カウントアップして再描画
- req.session.views++;
- res.render('sample.ejs', {views: req.session.views});
- });
- app.listen(port, () => {
- console.log(`Example app listening at http://localhost:${port}`);
- });
・views/sample.ejs
- <!DOCTYPE html>
- <html lang="ja">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0">
- <title>csurfサンプル</title>
- <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
- </head>
- <body>
- <div>
- 表示回数:<%= views %>
- </div>
- <form method="POST" action="/countup">
- <input type="submit" value="カウントアップ">
- </form>
- </body>
- </html>
ボタンを押すと表示回数がカウントアップされます。
csurf追加
効果を確かめるため、サンプルを参考にcsurfを追加してみます。
・index.js
- const express = require('express');
- const session = require('express-session');
- // csurf追加
- const csrf = require('csurf');
- const app = express();
- const port = 3000;
- // viewの表示はejsを使用
- app.set('view engine', 'ejs');
- app.use(express.json());
- app.use(express.urlencoded({ extended: true }));
- const csrfProtection = csrf({ cookie: false });
- app.use(session({
- secret: 'secret_key',
- resave: false,
- saveUninitialized: false
- }));
- app.get('/', csrfProtection, (req, res) => {
- if (!req.session.views) {
- req.session.views = 1;
- }
- res.render('sample.ejs', {views: req.session.views});
- });
- app.post('/countup', csrfProtection, (req, res) => {
- // カウントアップして再描画
- req.session.views++;
- res.render('sample.ejs', {views: req.session.views});
- });
- app.listen(port, () => {
- console.log(`Example app listening at http://localhost:${port}`);
- });
この状態でボタンを押すと、
ForbiddenError: invalid csrf token
というエラーとなりました。
ちゃんとtokenをチェックしていますね。
POST送信時、トークンも送信するよう修正します。
トークンは、req.csrfToken()で取得できます。
・index.js
- const express = require('express');
- const session = require('express-session');
- // csurf追加
- const csrf = require('csurf');
- const app = express();
- const port = 3000;
- // viewの表示はejsを使用
- app.set('view engine', 'ejs');
- app.use(express.json());
- app.use(express.urlencoded({ extended: true }));
- const csrfProtection = csrf({ cookie: false });
- app.use(session({
- secret: 'secret_key',
- resave: false,
- saveUninitialized: false
- }));
- app.get('/', csrfProtection, (req, res) => {
- if (!req.session.views) {
- req.session.views = 1;
- }
- // tokenを画面に設定
- res.render('sample.ejs', {views: req.session.views, csrfToken: req.csrfToken()});
- });
- app.post('/countup', csrfProtection, (req, res) => {
- // カウントアップして再描画
- req.session.views++;
- // tokenを画面に設定
- res.render('sample.ejs', {views: req.session.views, csrfToken: req.csrfToken()});
- });
- app.listen(port, () => {
- console.log(`Example app listening at http://localhost:${port}`);
- });
・views/sample.ejs
- <!DOCTYPE html>
- <html lang="ja">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0">
- <title>csurfサンプル</title>
- <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
- </head>
- <body>
- <div>
- 表示回数:<%= views %>
- </div>
- <form method="POST" action="/countup">
- <!-- csrfトークンも送信するよう修正 -->
- <input type="hidden" name="_csrf" value="<%= csrfToken %>">
- <input type="submit" value="カウントアップ">
- </form>
- </body>
- </html>
これで修正前と同様、ボタンを押すとカウントアップできるようになりました。
ajaxの場合
ajaxでPOSTデータを送信し、データを更新するパターンに修正してみます。
・index.js
- const express = require('express');
- const session = require('express-session');
- // csurf追加
- const csrf = require('csurf');
- const app = express();
- const port = 3000;
- // viewの表示はejsを使用
- app.set('view engine', 'ejs');
- app.use(express.json());
- app.use(express.urlencoded({ extended: true }));
- const csrfProtection = csrf({ cookie: false });
- app.use(session({
- secret: 'secret_key',
- resave: false,
- saveUninitialized: false
- }));
- app.get('/', csrfProtection, (req, res) => {
- if (!req.session.views) {
- req.session.views = 1;
- }
- // tokenを画面に設定
- res.render('sample.ejs', {views: req.session.views, csrfToken: req.csrfToken()});
- });
- app.post('/countup', csrfProtection, (req, res) => {
- // カウントアップ
- req.session.views++;
- // 値をリターン
- res.send({views: req.session.views});
- });
- app.listen(port, () => {
- console.log(`Example app listening at http://localhost:${port}`);
- });
・views/sample.ejs
- <!DOCTYPE html>
- <html lang="ja">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0">
- <title>csurfサンプル</title>
- <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
- </head>
- <body>
- <div>
- 表示回数:<span id="views"><%= views %></span>
- </div>
- <input type="button" value="カウントアップ" id="countup">
- <script>
- $(function() {
- $('#countup').on('click', function() {
- $.ajax({
- // 送信ヘッダーにtokenを設定する
- headers: {
- 'csrf-token': '<%= csrfToken %>'
- },
- type: 'POST',
- url: '/countup'
- }).done(function(data) {
- $('#views').text(data.views);
- });
- });
- });
- </script>
- </body>
- </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を設定する場合。
- <!DOCTYPE html>
- <html lang="ja">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0">
- <title>csurfサンプル</title>
- <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
- </head>
- <body>
- <div>
- 表示回数:<span id="views"><%= views %></span>
- </div>
- <input type="button" value="カウントアップ" id="countup">
- <script>
- $(function() {
- $('#countup').on('click', function() {
- $.ajax({
- type: 'POST',
- url: '/countup',
- // 送信データにtokenを設定する
- data: {
- _csrf: '<%= csrfToken %>'
- }
- }).done(function(data) {
- $('#views').text(data.views);
- });
- });
- });
- </script>
- </body>
- </html>
URLパラメーターに含める場合
- <!DOCTYPE html>
- <html lang="ja">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0">
- <title>csurfサンプル</title>
- <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
- </head>
- <body>
- <div>
- 表示回数:<span id="views"><%= views %></span>
- </div>
- <input type="button" value="カウントアップ" id="countup">
- <script>
- $(function() {
- $('#countup').on('click', function() {
- $.ajax({
- type: 'POST',
- // urlにtokenを含める場合
- url: '/countup?_csrf=<%= csrfToken %>',
- }).done(function(data) {
- $('#views').text(data.views);
- });
- });
- });
- </script>
- </body>
- </html>