SameSite=NoneのCookieとCSRF

SameSite=NoneのCookie

説明

機密Cookieに SameSite=None を指定すると、ブラウザーのほかのCookieポリシーが許可するクロスサイトリクエストにも送信される可能性があります。別のCSRF対策がなければ、攻撃者が悪意あるページへユーザーを誘導し、認証Cookieを伴うリクエストを送らせるおそれがあります。その結果、パスワード変更や送金などがユーザーの権限で実行される可能性があります。

想定される影響

  • クロスサイトリクエストに認証Cookieが含まれ、意図しない操作が可能になることがあります。
  • パスワード変更、決済、送金、プロフィール更新が同意なく行われるおそれがあります。
  • アカウントが悪用され、スパム送信、設定変更、データの作成・変更・削除につながる可能性があります。

対処方法

  • 機密Cookieには SameSite=Strict を優先し、処理上必要な場合でも少なくとも Lax を使ってください。
  • 外部サイトへの埋め込みやSSOなどでクロスサイトCookieが必要な場合は、CSRF対策を追加してください。
    • セッションに結び付けてサーバーで検証するCSRFトークンか、署名付きの二重送信Cookieを使ってください。
    • 状態変更リクエストのOrigin/Refererを、信頼する正確なオリジンと比較してください。
    • 状態変更にはGETではなくPOST、PUT、DELETEなどを使い、サーバーでトークンやオリジンを検証してください。
  • Expressでは res.cookie(..., { sameSite: 'strict' | 'lax', secure: true, httpOnly: true }) を設定してください。
  • SameSite=None が必要な場合はSecureを併用し、上記のCSRF対策も行ってください。

例

変更前

javascript
const crypto = require('crypto');
const express = require('express');
const app = express();

// 変更前: 認証CookieをSameSite=Noneにし、CSRF対策はありません。
app.post('/login', (req, res) => {
  const sessionId = crypto.randomBytes(32).toString('hex');
  res.cookie('auth', sessionId, {
    httpOnly: true,
    secure: true,
    sameSite: 'none' // クロスサイトリクエストでもCookieを送信できます。
  });
  res.send('logged in');
});

app.listen(3000);

変更後

javascript
const express = require('express');
const cookieParser = require('cookie-parser');
const crypto = require('crypto');

const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());

// 補助トークンを発行し、下記の厳密なOrigin/Referer検証と併用します。
app.get('/form', (req, res) => {
  const token = crypto.randomBytes(16).toString('hex');
  const sessionId = crypto.randomBytes(32).toString('hex');
  res.cookie('csrfToken', token, { httpOnly: true, secure: true, sameSite: 'strict' });
  res.cookie('session', sessionId, { httpOnly: true, secure: true, sameSite: 'strict' });
  res.send(`<form method="POST" action="/transfer">
              <input type="hidden" name="csrf" value="${token}">
              <button type="submit">send</button>
            </form>`);
});

function verifyOrigin(req) {
  const value = req.headers.origin || req.headers.referer;
  if (typeof value !== 'string') return false;

  try {
    return new URL(value).origin === 'https://example.com';
  } catch {
    return false;
  }
}

// 変更後: SameSite=Strict、厳密なオリジン検証、トークン比較を行います。
app.post('/transfer', (req, res) => {
  if (!verifyOrigin(req)) return res.status(403).send('forbidden');
  const cookieToken = req.cookies.csrfToken;
  const formToken = req.body?.csrf;
  if (typeof cookieToken !== 'string' || !/^[0-9a-f]{32}$/.test(cookieToken) ||
      typeof formToken !== 'string' || formToken !== cookieToken) {
    return res.status(403).send('bad token');
  }
  res.send('ok');
});

app.listen(3000);

説明:

  • 変更前: 認証Cookieがクロスサイトでも使えます。トークンやオリジンの検証がないと、悪意あるページからのリクエストで状態を不正に変更されるおそれがあります。
  • 変更後: 予測困難なセッション値と SameSite=Strict を使います。Origin/RefererをURLとして解析して正確なオリジンを比較し、空でない所定の形式のトークンが一致することも確認します。https://example.com.evil のような接頭辞は許可しません。この例の単純なCookie比較だけを単独の防御にしないでください。実際のログインと権限の確認は省略しています。

参考資料