Node.js WebアプリをCSRFからシンクロナイザートークンで保護する
Olivia Novak
Dev Intern · Leapcell

強力なNode.js Webアプリケーションを構築する CSRF防御のためのシンクロナイザートークンパターン
デジタルランドスケープは絶え間ない戦場であり、開発者は安全で信頼性の高いアプリケーションの構築に昼夜を問わず取り組んでいます。Web開発の世界では、しばしば影に潜む蔓延する脅威の1つが、クロスサイトリクエストフォージェリ(CSRF)です。この陰湿な攻撃は、認証済みのユーザーを、意図しないWebアプリケーション上の望まないアクションを実行するようにだますことができ、データ操作、不正なトランザクション、あるいはアカウントの侵害につながる可能性があります。応答性と効率性が最優先されるNode.js Webアプリケーションにとって、CSRFの脆弱性に対処することは、単なるベストプラクティスではありません。ユーザーの信頼とデータの整合性を維持するための基本的な要件です。この記事では、CSRFに対する強力で広く採用されている防御メカニズム、シンクロナイザートークンパターンを探ります。その原則を分解し、Node.js環境での実装を実証し、この一般的な脅威からWebアプリケーションをどのように強化するかを説明します。
CSRF保護の柱を理解する
シンクロナイザートークンパターンの詳細に入る前に、CSRF攻撃とその防御の基盤となるいくつかのコアコンセプトを簡単に明確にしましょう。
- クロスサイトリクエストフォージェリ(CSRF): Webアプリケーションが信頼しているユーザーから、許可されていないコマンドが送信される悪意のあるエクスプロイトの一種です。攻撃者は、ユーザーのアクティブなセッションとCookieを利用して、ユーザーのブラウザをだまし、脆弱なWebアプリケーションに正規のリクエストを送信させます。
- 同一オリジンポリシー(SOP): Webブラウザによって強制される重要なセキュリティメカニズムです。これは、最初のWebページに含まれるスクリプトが、2番目のWebページにアクセスできるのは、両方のWebページが同じオリジン(プロトコル、ホスト、ポート)を持っている場合のみであると指示します。SOPは多くのクロスサイトインタラクションを効果的にブロックしますが、CSRFは異なるオリジンからリクエストを送信し、ターゲットドメインの正規のCookieをブラウザに付与させることで、その制限を悪用します。
- ステートレス対ステートフル認証: ステートレスシステムでは、リクエスト間でサーバー側にセッション情報は保存されません(例:JWT)。ステートフルシステムでは、セッション情報はサーバーで維持されます(例:従来のセッションCookie)。CSRF攻撃は、Cookieがリクエストとともに自動的に送信されるステートフルシステムを標的とすることがよくあります。
- シンクロナイザートークンパターン: CSRFに対する防御メカニズムです。これには、サーバー側の状態を変更する各HTTPリクエスト(例:隠しフォームフィールドまたはカスタムヘッダーとして)に、ランダムに生成された、暗号学的に安全なトークンを埋め込むことが含まれます。その後、サーバーはリクエストを処理する前に、このトークンの存在と有効性を検証します。
シンクロナイザートークンパターンが機能する仕組み
シンクロナイザートークンパターンは、シンプルでありながら効果的な原則に基づいています。状態を変更するリクエストごとに、一意のサーバー生成トークンがリクエストに付随する必要があります。このトークンはユーザーのセッションに関連付けられ、サーバーによってリクエスト処理前に検証されます。悪意のある攻撃者は、正規のユーザーセッションからこの一意のトークンを予測したり取得したりできないため、偽造されたリクエストには有効なトークンが含まれず、拒否されることになります。
このパターンは通常、以下の手順を伴います。
- トークン生成: ユーザーがフォームまたは状態変更アクションを開始するページをリクエストすると、サーバーは一意で予測不可能なCSRFトークンを生成します。このトークンは通常、暗号学的にランダムであり、総当たり攻撃を防ぐのに十分な長さである必要があります。
- トークン関連付けと埋め込み: 生成されたトークンは、ユーザーのアクティブなセッションに関連付けられます(例:
express-sessionを使用している場合はreq.sessionに保存)。また、AJAXリクエスト用のHTMLフォーム内に隠し入力フィールドとして埋め込まれるか、カスタムHTTPヘッダーで送信されます。 - トークン送信: ユーザーがフォームを送信するか、AJAXリクエストを送信すると、CSRFトークンが他のリクエストパラメータとともにサーバーに送信されます。
- トークン検証: リクエストを受信すると、サーバーはリクエストからトークンを取得し、ユーザーのセッションに保存されているトークンと比較します。トークンが一致する場合、リクエストは正当であると見なされ、処理されます。一致しない場合、またはトークンが欠落している場合、リクエストはCSRF試行と見なされ、拒否されます。
Expressを使用したNode.jsでの実践的な実装
簡単なNode.js Expressアプリケーションでこれを例示しましょう。セッション管理にはexpress-sessionを、CSRFトークンの生成と検証にはカスタムミドルウェアを使用します。
まず、必要なパッケージがインストールされていることを確認してください。
npm install express express-session csurf
csurfはこれを簡略化する人気のあるパッケージですが、基盤となるメカニズムを明確に示すために、ここでは簡略化された手動実装を作成しましょう。
1. サーバーセットアップ (app.js):
const express = require('express'); const session = require('express-session'); const bodyParser = require('body-parser'); const crypto = require('crypto'); const app = express(); const port = 3000; // セッションミドルウェアを設定 app.use(session({ secret: 'a_very_secret_key_for_session_encryption', // 本番環境では強力でランダムなキーを使用してください resave: false, saveUninitialized: true, cookie: { secure: false, // HTTPSを使用している場合は true に設定 httpOnly: true, // クライアントサイドJavaScriptからのアクセスを防ぐ maxAge: 3600000 // 1時間 } })); // URLエンコードされたボディを解析 (フォーム送信用) app.use(bodyParser.urlencoded({ extended: false })); // JSONボディを解析 (APIリクエスト用) app.use(bodyParser.json()); // デモンストレーション用のシンプルなインメモリ「データベース」 const users = [ { id: 1, username: 'testuser', password: 'password123' } // 本番環境では、プレーンテキストパスワードは絶対に保存しないでください! ]; // ログインルート (デモンストレーション用に簡略化) app.post('/login', (req, res) => { const { username, password } = req.body; const user = users.find(u => u.username === username && u.password === password); if (user) { req.session.userId = user.id; console.log(`User ${username} logged in. Session ID: ${req.sessionID}`); return res.redirect('/dashboard'); } res.send('Invalid credentials'); }); // CSRFトークンを生成および検証するミドルウェア const csrfProtection = (req, res, next) => { // セッションにトークンが存在しない場合は生成 if (!req.session.csrfToken) { req.session.csrfToken = crypto.randomBytes(32).toString('hex'); console.log('CSRF Token generated:', req.session.csrfToken); } // POST/PUT/DELETEリクエストの場合、トークンを検証 if (['POST', 'PUT', 'DELETE'].includes(req.method)) { const receivedToken = req.body._csrf || req.headers['x-csrf-token']; console.log('Received CSRF Token:', receivedToken); console.log('Session CSRF Token:', req.session.csrfToken); if (!receivedToken || receivedToken !== req.session.csrfToken) { console.warn('CSRF token validation failed for:', req.method, req.url); return res.status(403).send('CSRF Token validation failed.'); } console.log('CSRF token validated successfully.'); } next(); }; app.use(csrfProtection); // ダッシュボードルート (ログインとアクションのためのCSRF保護が必要) app.get('/dashboard', (req, res) => { if (!req.session.userId) { return res.redirect('/'); } // CSRFトークンを含むフォームを表示 res.send(` <html> <head><title>Dashboard</title></head> <body> <h1>Welcome to your Dashboard!</h1> <p>Your user ID: ${req.session.userId}</p> <form action="/update-profile" method="POST"> <input type="hidden" name="_csrf" value="${req.session.csrfToken}"> <label for="newEmail">New Email:</label> <input type="email" id="newEmail" name="newEmail" value="user@example.com"> <button type="submit">Update Profile</button> </form> <form action="/delete-account" method="POST"> <input type="hidden" name="_csrf" value="${req.session.csrfToken}"> <button type="submit" style="color: red;">Delete Account</button> </form> <p><a href="/logout">Logout</a></p> </body> </html> `); }); // 例: 状態を変更するルート app.post('/update-profile', (req, res) => { if (!req.session.userId) { return res.status(401).send('Unauthorized'); } console.log(`User ${req.session.userId} updated profile with new email: ${req.body.newEmail}`); res.send('Profile updated successfully!'); }); app.post('/delete-account', (req, res) => { if (!req.session.userId) { return res.status(401).send('Unauthorized'); } console.log(`User ${req.session.userId} account deleted.`); delete req.session.userId; // ユーザーをログアウトさせる res.send('Account deleted successfully!'); }); app.get('/logout', (req, res) => { req.session.destroy(err => { if (err) { console.error('Error destroying session:', err); } res.redirect('/'); }); }); // ルートルート (ログインフォーム) app.get('/', (req, res) => { res.send(` <html> <head><title>Login</title></head> <body> <h1>Login</h1> <form action="/login" method="POST"> <label for="username">Username:</label> <input type="text" id="username" name="username" value="testuser"> <br> <label for="password">Password:</label> <input type="password" id="password" name="password" value="password123"> <br> <button type="submit">Login</button> </form> </body> </html> `); }); app.listen(port, () => { console.log(`Server running at http://localhost:${port}`); });
コードの説明:
express-session: ユーザーセッションを管理し、各ユーザーに関連付けられたcsrfTokenを保存および取得できるようにします。csrfProtectionミドルウェア:req.session.csrfTokenをチェックします。現在のセッションにトークンが存在しない場合、crypto.randomBytes(32).toString('hex')を使用して新しいトークンを生成し、セッションに保存します。- 状態を変更する可能性のある
POST、PUT、またはDELETEリクエストに対して、req.body._csrf(フォーム送信用)またはreq.headers['x-csrf-token'](AJAXリクエスト用)からCSRFトークンを取得しようとします。 - その後、受信したトークンを
req.session.csrfTokenに保存されているトークンと比較します。両者が一致しない場合、403 Forbiddenレスポンスを送信し、CSRF攻撃を効果的に防止します。
- ダッシュボードルート (
/dashboard): このルートは、隠し入力フィールド (`<input type=

