背景と必要性
AWSの社内コンソールや請求ポータルにログインしているエンジニアを思い浮かべてみてください。別のタブで、Slackチャンネルやフィッシングメールに記載された未検証のリンクをクリックしたとします。わずか200ミリ秒以内に、アカウントのメールアドレスが変更されたり、攻撃者の管理下にあるSSHキーが追加されたり、資金が外部に送金されたりしてしまいます。その間、ユーザーには一切の確認ダイアログが表示されません。
クロスサイトリクエストフォージェリ(CSRF)は、Webブラウザの「暗黙の権限(ambient authority)」を突く攻撃です。ブラウザは、どのサイトがリクエストを発行したかに関係なく、送信先へのHTTPリクエストにセッションCookieやBasic認証ヘッダーなどの保存済みクレデンシャルを自動的に付与します。
Chrome 80は2020年2月にSameSite=Laxをデフォルトとしましたが、ブラウザのデフォルト挙動だけに依存するのは危険な隙を残すことになります。トップレベルのGETナビゲーションでは依然としてLax Cookieが送信されます。また、モバイルのWebViewや古い組み込みブラウザ、SafariのITP(Intelligent Tracking Prevention)の特異な挙動によって、これらのフラグが一貫して処理されない場合もあります。攻撃者はこうした盲点を積極的に突いてきます。例えば、Cisco ASAのCVE-2020-3580では、エンタープライズの境界ネットワーク内であってもCSRFによってリモートコード実行が引き起こされる可能性が実証されました。
アカウントのメールアドレスを更新する、保護対策が施されていないエンドポイントを考えてみましょう。
POST /api/user/email HTTP/1.1
Host: myapp.internal
Content-Type: application/x-www-form-urlencoded
Cookie: session_id=9f82a1b7c4e5d6...
[email protected]
攻撃者はsession_id Cookieを盗んだり読み取ったりする必要はありません。代わりに、被害者のブラウザを操作して、本人に代わってリクエストを実行させます。https://evil-site.com上の悪意あるWebページは、ページ読み込み時に自動送信される5行の非表示フォームを使ってこれを実行します。
<form id="csrfForm" action="https://myapp.internal/api/user/email" method="POST">
<input type="hidden" name="email" value="[email protected]" />
</form>
<script>
document.getElementById('csrfForm').submit();
</script>
サーバーはクロスオリジンリクエストとともに有効なセッションCookieを受け取ります。追加の検証を行わない限り、正規のユーザー操作と偽造されたリクエストを区別することはできません。この攻撃を防ぐには、決定論的なCookie境界ルールと暗号論的なトークン検証という、2つの異なる制御策が必要です。
環境構築
非推奨となったcsurfライブラリの最新HMACベースの後継であるcsrf-csrfと、cookie-parserを使い、Node.jsとExpressによる防御レイヤーを構築します。
専用の作業ディレクトリを作成し、必要な依存パッケージをインストールします。
mkdir csrf-defense-lab && cd csrf-defense-lab
npm init -y
npm install express cookie-parser csrf-csrf dotenv
npm install --save-dev nodemon
このスタックは、暗号化HMAC検証を備えたDouble Submit Cookieパターンを実装します。Redisやリレーショナルデータベースに状態を保存することなく、リクエストの正当性を検証できます。
設定と実装
堅牢な防御策は、2つの独立したレイヤーを組み合わせることで実現します。
- 厳格なCookie分離: クロスサイトリクエスト時にブラウザがセッションCookieを送信するタイミングを制限します。
- 暗号論的リクエストトークン: カスタムヘッダーに一意で予測不可能なHMACトークンを要求します。同一生成元ポリシー(SOP)により、サードパーティオリジンがこの値を読み取ったり偽造したりすることはできません。
1. 暗号シークレットの設定
エントロピーの高い256ビットキーを生成し、ローカルの.envファイルに保存します。
PORT=3000
NODE_ENV=development
COOKIE_SECRET=d83f12a98e54c7b20f456123489abced98471234908123490812349081234908
CSRF_SECRET=4f7b9c1d2e3a5b6c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c
2. ミドルウェアとエンドポイント保護の実装
server.jsにアプリケーションのエントリポイントを記述します。
require('dotenv').config();
const express = require('express');
const cookieParser = require('cookie-parser');
const { doubleCsrf } = require('csrf-csrf');
const app = express();
const isProduction = process.env.NODE_ENV === 'production';
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser(process.env.COOKIE_SECRET));
const {
invalidCsrfTokenError,
generateToken,
doubleCsrfProtection,
} = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET,
cookieName: isProduction ? '__Host-app.csrf' : 'app.csrf',
cookieOptions: {
httpOnly: true,
sameSite: 'lax', // 高リスクなトランザクションツールには 'strict' を使用
secure: isProduction, // 本番環境のHTTPSで __Host- プレフィックスを使用する場合に必須
path: '/',
},
size: 64,
ignoredMethods: ['GET', 'HEAD', 'OPTIONS'],
getTokenFromRequest: (req) => req.headers['x-csrf-token'] || req.body._csrf,
});
// ハンドシェイクエンドポイント:SPAがブートストラップ時に呼び出す
app.get('/api/csrf-token', (req, res) => {
const csrfToken = generateToken(req, res);
res.json({ csrfToken });
});
// 認証済みセッションCookieを発行する模擬ログイン
app.post('/api/login', (req, res) => {
res.cookie('auth_session', 'usr_sess_9a8b7c6d5e4f3a2b1c', {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
path: '/',
maxAge: 3600000, // 1時間
});
res.json({ status: 'authenticated', userId: 42 });
});
// CSRF保護された状態変更(mutation)ルート
app.post('/api/user/email', doubleCsrfProtection, (req, res) => {
const { email } = req.body;
console.log(`[監査] メールアドレスが正常に更新されました: ${email}`);
res.json({ status: 'success', email });
});
// 拒否されたトークン用の集中エラーハンドラー
app.use((err, req, res, next) => {
if (err === invalidCsrfTokenError) {
console.warn(`[セキュリティ警告] 偽造リクエストを拒否しました: ${req.method} ${req.originalUrl}`);
return res.status(403).json({
error: 'Forbidden',
message: 'CSRFトークンが無効であるか、存在しません。',
});
}
next(err);
});
app.listen(process.env.PORT, () => {
console.log(`サーバーがポート ${process.env.PORT} で起動しました`);
});
3. 防御メカニズムの連携の仕組み
__Host-プレフィックス: RFC 6265bisに基づき、ブラウザはこのCookieにSecure属性が付与され、Domain属性がなく、Path=/に設定されている場合のみ受け入れます。サブドメインから上書きされたりハイジャックされたりすることはありません。- SameSite=Lax: クロスサイトのフォーム送信やバックグラウンドでのAJAXリクエスト時にCookieの送信をブロックします。通常のトップレベルリンクのクリック時にはCookieが保持されるため、検索エンジンからのアクセスにおけるユーザー体験(UX)を損ないません。
- カスタム
x-csrf-tokenヘッダー: SPA(React、Vue、Angularなど)は起動時にトークンを取得し、API呼び出し時に付与します。ブラウザの同一生成元ポリシー(SOP)によってオリジンを跨いだレスポンスの読み取りが禁止されているため、クロスオリジンの攻撃者はこのトークンを傍受できません。
検証とモニタリング
curlを使用して、正規クライアントのフローと悪意ある偽造リクエストの双方をテストします。
1. 正規ユーザーのワークフローのテスト
認証を行い、セッションCookieを保存し、トークンを取得して、認可された更新処理を実行します。
# 1. ログインしてセッションCookieをローカルファイルに保存
curl -s -c cookies.txt -X POST http://localhost:3000/api/login
# 2. Cookieファイルを渡してCSRFトークンを取得
CSRF_TOKEN=$(curl -s -b cookies.txt -c cookies.txt http://localhost:3000/api/csrf-token | jq -r .csrfToken)
echo "取得したトークン: $CSRF_TOKEN"
# 3. 認可されたデータ更新を実行
curl -s -b cookies.txt \
-H "Content-Type: application/json" \
-H "x-csrf-token: $CSRF_TOKEN" \
-d '{"email":"[email protected]"}' \
http://localhost:3000/api/user/email
サーバーはHTTP 200 OKとともに {"status":"success","email":"[email protected]"} を返します。
2. CSRF攻撃のシミュレーション
被害者のセッションCookieを含めつつ、カスタムヘッダーを省いてリクエストを送信します。
curl -i -b cookies.txt \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]"}' \
http://localhost:3000/api/user/email
ミドルウェアがリクエストを即座に拒否します。
HTTP/1.1 403 Forbidden
Content-Type: application/json; charset=utf-8
{
"error": "Forbidden",
"message": "CSRFトークンが無効であるか、存在しません。"
}
3. 本番環境でのモニタリングとアラート
更新用エンドポイントで403エラーが多発している場合、活発な総当たり攻撃(ブルートフォース)またはクライアントビルドの不具合を示唆しています。これらはセキュリティインシデントとして扱ってください。
構造化JSONエラーログをVector、Fluentbit、Datadogなどのコレクターにパイプします。
if (err === invalidCsrfTokenError) {
const securityLog = {
timestamp: new Date().toISOString(),
event: 'CSRF_VALIDATION_FAILURE',
client_ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress,
origin: req.headers['origin'] || null,
referer: req.headers['referer'] || null,
path: req.originalUrl,
method: req.method,
user_agent: req.headers['user-agent'],
};
console.error(JSON.stringify(securityLog));
}
単一のIPから60秒以内に10回を超える失敗イベントが発生した場合や、Originヘッダーが許可済みドメイン一覧と一致しないリクエストが発生した場合に、ログ監視ツールでアラートを発報するしきい値を設定します。こうした予兆を早期に検知することで、攻撃が成功する前にユーザーアカウントを安全に保護できます。

