コンテンツへスキップ

React.jsとExpress.js(Node.js)で独自のCSRF対策を実装する

  • 未分類

Webシステムを構築する際にセキュリティ対策として懸念すべき点がいくつかあります。
その一つが、CSRF(クロスサイト・リクエスト・フォージェリ)対策です。

今回は、React.js(フロントエンド)とExpress.js(バックエンド)の構成で独自のCSRF対策を実装する方法をまとめて行きます。

CSRF(クロスサイト・リクエスト・フォージェリ)とは

本題に入る前に今回対策するCSRF攻撃とはどのようなものなのかを簡単に説明します。

よくあるユーザ登録や掲示板の機能などで、【フォームへの入力】→【データベースへの登録処理】→【データベースへの書き込み実行】というフローがある状況で考えていきます。

想定されるプロセスとしては、登録フォームの入力画面に表示されている項目を入力し、その内容をプログラム部分が受け取り、データベースへのアクションを行います。

このプロセスの範囲であれば入力される内容や項目は開発者の想定の範囲内となり、安全性は特に問題がありません。

しかし、時には悪意を持った人が危険なプログラムを開発し、用意プロセスの外からプログラムにデータを送信し、開発者が想定しないデータをデータベースに格納したり、データベースへのアクションを行うSQLを書き換え、データベースの破壊を試みることもあります。

このフォームのプログラムを外部から悪用使用とする攻撃をCSRF攻撃と言います。

CSRF対策の基本

この外部からの攻撃に対して行うべき対策が、CSRFトークンを利用したフォームの実装です。

CSRFトークンは、フロントエンドとバックエンドが同じ値を取れる任意の文字列で、複数のユーザ同士で重複する可能性の低いものであることが重要です。
PHPベースのフレームワークであるLaravelでは、セッションIDが利用されます。

正しいプロセスである登録フォームの入力画面の隠し要素としてCSRFトークンが仕込まれており、ユーザは特にCSRFトークンのことを意識することはありません。

フォームが送信されると、CSRFトークンとして入力フォームから送信された値とバックエンドのプログラムが参照できるCSRFトークンの値を比較検証し、両CSRFトークンの値が合致するときのみ正しいリクエストとしてデータベースへの書き込み処理を行います。

外部からの危険なプログラムが送信するデータはCSRFトークンを持たないので、データベースへのアクションは行われません。

また、危険なプログラムが仮にCSRFトークンを偽装した文字列を含めてきた場合も、きちんとした機能設計を行うことでCSRFトークンは不一致となり、こちらもデータベースが危険に晒されることはありません。

利用したCSRFトークンは、一連の処理が完了した段階で破棄し、次回別のフォーム機能を利用する際はその都度CSRFトークンを生成します。

React.js(フロントエンド)× Express.js(バックエンド)でのCSRF対策を考える

ここまでは特に明言はしませんでしたが、PHPのようなサーバサイド言語主体のフロントエンドとバックエンドが一体となった構成で説明を進めてきました。

最近ではフロントエンドとバックエンドがそれぞれ別々に存在し、フロントエンドからバックエンドへはAPI通信を用いるケースが多くなっています。

この場合、フロントエンドからバックエンドへのリクエストは同じユーザが連続して行っていたとしても、バックエンドは誰がリクエストをしてきているかは意識しません。

そのため、リクエストのたびにバックエンド側で生成されるセッションIDは異なるし、CSRFトークンをバックエンドのセッションに保存したとしても、次のリクエストでその保存したセッションを掴むことはできません。

React.jsのようなJavaScriptベースのフロントエンドフレームワークを利用する場合、フロントエンドからバックエンドへのリクエストに対し、セッションを維持する仕組みが重要になってきます。

これを踏まえて実際のコード実装例の説明へと移ります。

コードを実装してみる

ログイン機能のようなシンプルなフォーム機能で考えます。
細かなエラー処理は省略していますが、このような形でCSRFトークンを利用した実装を考えていきます。

フロントエンド

import react, { useEffect } from 'react';
import axios from 'axios';

function FormBody() {
    function handleSubmit() {
        axios.post('APIサーバURL/login', {
            userId: userId,
            password: password
        }, {
            headers: {
                withCredentials: true
            }
        })
        .then((response) => {
            // 成功時の処理
        })
        .catch((error) => {
            // 失敗時の処理
        });
    };

    return (
        // フォームコンポーネント //  
    );
}

export function Login() {
    useEffect(() => {
        axios.get('APIサーバURL/csrf_token', {
            withCredentials: true
        })
        .then((response) => {
            // 通信成功時の処理
        })
        .catch((error) => {
            // 通信失敗時の処理
        });
    }, []);

    return (
        <FormBody />
    );
}

今回重要なCSRFトークンの通信に関与する部分をピックアップしているので、フォーム自体の処理やコンポーネントの記述は省略しています。

ログインページにアクセスした段階でCSRFトークンを生成したいので、useEffectの中でAPIサーバに用意しているcsrf_tokenへとGETリクエストしています。

このときに重要なのは、withCredentials: trueの記述になります。

この記述を追記することにより、API通信でバックエンドにリクエストを送信する際、クライアントのCookie情報も自動的に送ることができるようになります。

今回、クライアント側のCSRFトークンはCookie内に保管するので、この設定は必須になります。
また、この設定を有効化することでセッションIDを維持することができ、バックエンドのセッション情報も維持して利用することが可能になります。

useEffect内のAPI通信が成功すると、バックエンドの処理により自動的にCookieにCSRFトークンが格納されるようになります。
そのため、FormBodyコンポーネントのhandleSubmitメソッド内で再びAPI通信を行う際も、リクエストヘッダでwithCredentials: true記述し、Cookieを送るようにすることでCSRFトークンの認証を通すことができるようになる想定です。

バックエンド

require('dotenv').config();

const express = require('express');
const session = require('express-session');
const cookieParser = require('cookie-parser');
const cors = require('cors');
const routes = require('./routes');

// アプリケーション生成
const app = express();

// CORS設定
app.use(cors({
    origin: process.env.FRONTEND_URL || 'http://localhost:3000',
    credentials: true
}));

// JSONパーサ
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// SESSION設定
app.use(session({
    secret: 'your_secret_key',
    resave: false,
    saveUninitialized: true,
    cookie: { httpOnly: true }
}));

// COOKIEパーサ
app.use(cookieParser());

// ルーティング
app.use('/', routes);

// サーバ起動
app.listen(port, () => {
    console.log(`Server running at http://localhost:${port}`);
});

まず最初にindex.jsです。

ここで重要なのは、CORS設定の記述です。
フロントエンドからのリクエストを受けれるようにoriginにフロントエンドのURLを設定してあげます。
また、credentials: trueにすることでクライアントCookieを取得できるようになります。

次にSESSION設定の記述です。
この記述があることで、クライアントCookieに自動的にセッションIDを格納してくれるので、バックエンドのセッションを利用できるようになります。
注意点としてましては、cookie: { httpOnly: true }にしないと、セットしたCookieのセッションIDがJavaScriptから改ざんを受ける可能性が残ってしまうため、必ず設定しましょう。

最後にCOOKIEパーサです。
COOKIEパーサの利用は必須ではありませんが、このミドルウェアを有効にしておくことで後々の処理でクライアントCookieの値を扱いやすくなります。

// CSRFトークンの生成
function generateCsrfToken(req, res, next) {
    if(!req.session.csrfToken) {
        // CSRFトークンを生成
        const csrfToken = 'ランダムな文字列を生成したと仮定';
        
        // 生成したトークンをセッションに格納
        req.session.csrfToken = csrfToken;

        // クライアントCookieにトークンをセット
        res.cookie(
            'csrf-token',
            csrfToken,
            {
                httpOnly: true,
            }
        );
    }

    req.csrfToken = function() {
        return req.session.csrfToken;
    }

    next();
}

// CSRFトークンの検証
function checkCsrfToken(req, res, next) {
    console.log(req.session);

    // リクエストヘッダのCSRFトークン
    const csrfToken = req.cookies['csrf-token'];

    // セッションに保管されているCSRFトークン
    const sessionCsrfToken = req.session.csrfToken;

    // CSRFトークン検証実行
    if(
        !csrfToken ||
        csrfToken !== sessionCsrfToken
    ) {
        // 認証失敗
        res.status(403).json({ status: 'Failed', message: 'Invalid Token.' });
        return;
    }

    next();
}

// CSRFトークンの破棄
function deleteCsrfToken(req, res, next) {
    // セッション内CSRFトークンの破棄
    delete req.session.csrfToken;

    // クライアントCookie内CSRFトークンの破棄
    res.clearCookie('csrf-token');

    next();
}

module.exports = { generateCsrfToken, checkCsrfToken, deleteCsrfToken };

これはCSRFトークンを管理するためのミドルウェアです。
このミドルウェアはCSRFトークンを生成するメソッドCSRFトークンの正当性を検査するメソッド利用し終わったCSRFトークンを破棄するメソッドの3つで構成されています。

const express = require('express');
const router = express.Router();

// Middlewares
const csrfTokenMiddleware = require('./middlewares/csrfTokenMiddleware');

// CSRF Token //
router.get('/csrf_token', csrfTokenMiddleware.generateCsrfToken, (req, res) => {
    res.status(200).json({ status: 'Success' });
});

// Login //
router.post('/login', csrfTokenMiddleware.checkCsrfToken, csrfTokenMiddleware.deleteCsrfToken, (req, res) => {
    // CSRFトークン認証成功後の処理を記述
});

先ほどのフロントエンドからのリクエストと照らし合わせて見てみます。

ログインページ読み込み時に、csrf_tokenへリクエストを行います。
バックエンドがcsrf_tokenへのGETリクエストを受け取るとcsrfTokenMiddleware.generateCsrfTokenを経由し、問題なければレスポンスステータス200を返します。

csrfTokenMiddleware.generateCsrfTokenが実行されると、バックエンドのセッションにCSRFトークンを格納するのと、レスポンスでCookieにCSRFトークンを格納する命令を出してくれます。

ログインページ表示後、デベロッパーツールでCookieの値を確認してもらえればCSRFトークンが格納されていることが確認できると思います。

そして、次にログイン画面のフォームをSubmitしたときに発動されるhandleSubmitでバックエンドのloginに対してPOSTでリクエストを行っています。

loginにPOSTリクエストが来た場合、csrfTokenMiddleware.checkCsrfTokenが実行されCSRFトークンの正当性がチェックされます。

CSRFトークンが正しければ次の処理に進むことができます。
もし、CSRFトークンの正当性が認められなければ403ステータスを返し、処理は中断されます。

上記の例では、csrfTokenMiddleware.checkCsrfTokenのあとにcsrfTokenMiddleware.deleteCsrfTokenを実行し、今回作成されたCSRFトークンを破棄して、ログインを完了させています。

ただ、CSRFトークンを破棄するタイミングとしては、一連の処理が完了する段階になります。
あと何回か処理のやり取りが発生する場合は、CSRFトークンを破棄せずに継続し、最後の処理で破棄を実行するようにしましょう。