Astro + Supabaseで「ログアウトしたのにUIが変わらない」問題を解決した話

SaaSやWebアプリでよくある「回数制限付き機能」の実装。「ボタンを押したらチケットを1枚消費してサービスを実行する」というシンプルな要件ですが、実装場所を間違えると簡単に不正利用の温床になります。

本記事では、フロントエンドで実装してしまいがちな「クレジット消費ロジック」の危険性と、トランザクション処理を用いた正しい実装パターンについて解説します。


実装パターンの比較表

実装方式セキュリティリスク実装コスト推奨度OWASP分類
フロントエンド主導(2回API呼び出し)高(APIリクエストのスキップ可能)❌ 非推奨A01:2021 – Broken Access Control
サーバーサイド完結(アトミック処理)低(サーバー側で強制)✅ 推奨-
トークンベース検証(ワンタイムトークン)✅ 推奨(高頻度API向け)-
分散トランザクション(Saga pattern)⚠️ 必要に応じて-

❌ 危険な実装パターン:フロントエンド主導の2段階API呼び出し

脆弱なコード例

フロントエンドエンジニアが直感的に書きがちなのが、以下のような「クレジット消費API → 診断API」の2回呼び出しです。

javascript
// ❌ BAD: クライアントサイドコード(脆弱な実装)

async function handleDiagnose() {
  // 1. まずクレジット消費APIを呼ぶ
  const consumed = await fetch('/api/credits/consume', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${token}` }
  }).then(r => r.json());

  if (consumed.success) {
    // 2. クレジット消費に成功したら、診断APIを叩く
    const result = await fetch('/api/diagnose', {
      method: 'POST',
      body: JSON.stringify({ targetUrl: 'https://example.com' })
    }).then(r => r.json());

    showResult(result);
  } else {
    alert("クレジットが足りません");
  }
}

この実装の3つの脆弱性

脆弱性1: リクエストの選択的ブロック

攻撃者はChrome DevToolsのNetworkタブで以下の操作が可能です:

javascript
// 攻撃者がConsoleで実行できるコード
// 1. クレジット消費APIだけをブロック(ネットワークスロットリング)
// 2. 診断APIを直接叩く
fetch('/api/diagnose', {
  method: 'POST',
  headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') },
  body: JSON.stringify({ targetUrl: 'https://victim.com' })
});
// → クレジット消費なしで診断実行!

脆弱性2: レースコンディション

複数のブラウザタブから同時にリクエストを送信すると、以下のような競合状態が発生します:

plain text
時刻  | タブA                    | タブB                    | サーバー残高
------|-------------------------|-------------------------|-------------
t0    | consume API呼び出し      |                         | 1
t1    | (残高チェック中...)       | consume API呼び出し      | 1
t2    | → 成功(残高=0)         | → 成功(残高=0)         | -1 ← 不正
t3    | diagnose API実行        | diagnose API実行        | (2回実行)

脆弱性3: クライアントコードの改変

攻撃者は以下のようにブラウザ拡張機能やローカルプロキシ(Burp Suite等)でコードを改変できます:

javascript
// 改変されたコード(if文を削除)
async function handleDiagnose() {
  // consumed チェックを削除
  const result = await fetch('/api/diagnose', { /* ... */ });
  showResult(result);
}

✅ 正しい実装パターン:サーバーサイド完結(アトミック処理)

安全なサーバーサイドコード例

不正を防ぐ唯一の方法は、「クレジット消費」と「サービス実行」をサーバー内部でアトミック(不可分)に処理することです。

typescript
// ✅ GOOD: サーバーサイドAPI実装例(Next.js App Router)

import { NextRequest, NextResponse } from 'next/server';
import { getUserFromToken } from '@/lib/auth';
import { prisma } from '@/lib/db';

export async function POST(req: NextRequest) {
  const userId = await getUserFromToken(req);
  if (!userId) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // データベーストランザクション開始
  const result = await prisma.$transaction(async (tx) => {
    // 1. 残高チェック & 減算(行ロック付き)
    const user = await tx.user.findUnique({
      where: { id: userId },
      select: { credits: true }
    });

    if (!user || user.credits < 1) {
      throw new Error('Insufficient credits');
    }

    // 2. クレジット減算(アトミック更新)
    await tx.user.update({
      where: { id: userId },
      data: { credits: { decrement: 1 } }
    });

    // 3. 診断サービス実行
    const diagnosisResult = await runDiagnosisService(req.body);

    // 4. 使用履歴を記録
    await tx.creditHistory.create({
      data: {
        userId,
        action: 'DIAGNOSIS',
        amount: -1,
        metadata: diagnosisResult.summary
      }
    });

    return diagnosisResult;
  });

  return NextResponse.json(result);
}

この実装が安全な理由

理由1: トランザクション保証

Prisma Transaction APIにより、以下が保証されます:

  • Atomicity(原子性): クレジット消費と診断実行が両方成功するか、両方失敗するかのどちらか
  • Consistency(一貫性): 残高がマイナスになることはない
  • Isolation(分離性): 同時リクエストでも正しい残高計算が行われる
  • Durability(永続性): コミット後はサーバークラッシュでもデータが保持される

理由2: クライアントからの干渉不可能

javascript
// ✅ クライアントサイドコード(シンプル化)

async function handleDiagnose() {
  // 単一APIエンドポイントを叩くだけ
  const result = await fetch('/api/diagnose', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${token}` },
    body: JSON.stringify({ targetUrl: 'https://example.com' })
  });

  if (result.ok) {
    const data = await result.json();
    showResult(data);
  } else if (result.status === 403) {
    alert("クレジットが足りません");
  }
}

クライアントは「診断して!」というリクエストを1回送るだけ。リクエストをブロックしても何も起きず、直接APIを叩いてもサーバー側で必ず残高チェックが走ります。


🔒 高度な実装パターン:ワンタイムトークン方式

高頻度APIや決済系機能では、さらに厳格な制御が必要な場合があります。

ワンタイムトークンフロー

mermaid
sequenceDiagram
    participant Client
    participant Server
    participant DB

    Client->>Server: GET /api/credits/token (トークン要求)
    Server->>DB: SELECT credits WHERE user_id = X
    DB-->>Server: credits = 5
    Server->>DB: INSERT token (expires_in: 60s)
    Server-->>Client: { token: "otp_abc123", expires_at: "..." }

    Client->>Server: POST /api/diagnose (token + payload)
    Server->>DB: SELECT token WHERE id = "otp_abc123" FOR UPDATE
    DB-->>Server: token found (valid)
    Server->>DB: DELETE token (使用済み化)
    Server->>DB: UPDATE credits SET amount = amount - 1
    Server->>DB: INSERT credit_history
    Server-->>Client: { result: "..." }

実装例

typescript
// トークン発行エンドポイント
export async function GET(req: NextRequest) {
  const userId = await getUserFromToken(req);

  const user = await prisma.user.findUnique({
    where: { id: userId },
    select: { credits: true }
  });

  if (!user || user.credits < 1) {
    return NextResponse.json({ error: 'Insufficient credits' }, { status: 403 });
  }

  // ワンタイムトークン生成(60秒有効)
  const token = await prisma.oneTimeToken.create({
    data: {
      userId,
      expiresAt: new Date(Date.now() + 60000),
      purpose: 'DIAGNOSIS'
    }
  });

  return NextResponse.json({
    token: token.id,
    expiresAt: token.expiresAt
  });
}

// 診断エンドポイント(トークン検証付き)
export async function POST(req: NextRequest) {
  const { token: otpToken, targetUrl } = await req.json();

  const result = await prisma.$transaction(async (tx) => {
    // トークン検証 & 削除(再利用防止)
    const token = await tx.oneTimeToken.delete({
      where: {
        id: otpToken,
        expiresAt: { gt: new Date() }
      }
    });

    // クレジット消費
    await tx.user.update({
      where: { id: token.userId },
      data: { credits: { decrement: 1 } }
    });

    // サービス実行
    return await runDiagnosisService(targetUrl);
  });

  return NextResponse.json(result);
}

この方式により、以下が実現できます:

  • リプレイ攻撃の防止: トークンは1回のみ使用可能
  • タイムアウト制御: 60秒以内に使用しないと無効化
  • 監査ログの完全性: トークン発行〜使用までの追跡が可能

実装チェックリスト

以下のチェックリストを使用して、実装の安全性を確認してください。

必須項目

推奨項目

テスト項目


まとめ:Trust Boundaryを守る

3つの原則

  1. クライアントサイドのロジックは改変可能
  2. 消費と実行は不可分な操作
  3. APIはビジネスルールを強制する場所

Trust Boundary(信頼境界)

plain text
┌─────────────────────────────────────────┐
│ クライアント(信頼できない領域)          │
│ - UI表示の制御のみ                      │
│ - サーバーへのリクエスト送信             │
└─────────────────────────────────────────┘
              ↓ HTTPS
┌─────────────────────────────────────────┐
│ サーバー(信頼境界)                     │
│ ✅ 認証・認可の実施                      │
│ ✅ ビジネスロジックの実行                 │
│ ✅ データ整合性の保証                    │
└─────────────────────────────────────────┘

フロントエンドフレームワーク(React、Vue、Next.js等)が便利になり、クライアントサイドでロジックをリッチに書ける時代になりましたが、セキュリティの境界線(Trust Boundary)を見失わないようにしましょう。


参考資料