Stripe決済実装でやりがちな「なりすましアップグレード」の脆弱性と修正

SaaS開発で欠かせない決済機能。Stripeを使えば簡単にサブスクリプション機能を実装できますが、「誰が」 決済しようとしているのか、正しく検証できていますか?

今回のセキュリティ診断で見つかった、Checkout Session作成APIにおける「なりすまし」脆弱性と、その修正アプローチを共有します。


問題:なりすまし脆弱性の発見

脆弱な実装 vs 安全な実装の比較

以下の表で、脆弱な実装と安全な実装の違いを確認してください。

項目脆弱な実装安全な実装
ユーザーID取得元request.body.userIdJWT検証後のuser.id
認証方法なし(リクエストボディを信頼)Authorizationヘッダー検証
改ざん可能性高(攻撃者が任意のIDを指定可能)不可(サーバー側でトークン検証)
攻撃時の影響他人のアカウントに課金可能認証エラーで拒否

脆弱なコード例

以下のような実装、心当たりはありませんか?

typescript
// ❌ 脆弱な実装例
export const POST = async ({ request }) => {
  const body = await request.json();

  // フロントエンドから送られてきたIDをそのまま信じている!
  const userId = body.userId;
  const userScore = body.score;

  const session = await stripe.checkout.sessions.create({
    // ...
    client_reference_id: userId, // ここが問題
  });

  return new Response(JSON.stringify({ url: session.url }));
};

このコードの致命的な問題は、「フロントエンドから送られてくる userId は改ざん可能である」 という点です。

詳細はStripe Checkout Session API公式リファレンスおよびOWASP A07:2021 Identification and Authentication Failuresを参照してください。

攻撃シナリオ

  1. 攻撃者が自分のアカウントでログインし、アップグレードボタンを押す。
  2. ネットワークリクエストをキャプチャし、userId「被害者のID」 に書き換えて送信する。
  3. サーバーは疑いもせず被害者のIDでCheckout Sessionを作成。
  4. 攻撃者が支払いを完了すると、Webhookが「被害者のID」に対して「支払い完了」イベントを処理する。
  5. 結果:攻撃者が支払ったのに、被害者のアカウントがProプランになる(あるいはその逆)。

より深刻なのは、userId に適当な値を入れたり、管理者IDを入れたりしてシステムを混乱させることです。


解決策:JWTによる認証

「誰が」リクエストしてきたかは、リクエストボディではなく、認証トークン(JWT) から判断すべきです。

サーバーサイド実装

Supabase Authを使っている場合の修正例を見てみましょう。

typescript
import { createClient } from '@supabase/supabase-js';

export const POST = async ({ request }) => {
  // 1. Authorizationヘッダーのチェック
  const authHeader = request.headers.get('Authorization');
  if (!authHeader?.startsWith('Bearer ')) {
    console.error('Missing or invalid Authorization header');
    return new Response('Unauthorized', { status: 401 });
  }

  // 2. サーバーサイドでトークンを検証してユーザーを取得
  const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
    global: { headers: { Authorization: authHeader } },
  });

  const { data: { user }, error } = await supabase.auth.getUser();

  if (error || !user) {
    console.error('Token validation failed:', error);
    // 不正アクセス試行をログに記録(本番環境ではモニタリングツールに送信を推奨)
    // await logSecurityEvent({ type: 'invalid_token', ip: request.headers.get('x-forwarded-for') });
    return new Response('Invalid Token', { status: 401 });
  }

  // 3. 検証済みのユーザーIDを使用する
  // body.userId は一切見ない!
  const userId = user.id;
  const email = user.email;

  const session = await stripe.checkout.sessions.create({
    // ...
    client_reference_id: userId, // 安全!
    customer_email: email,
  });

  // ...
};

詳細はSupabase Auth getUser() ドキュメントを参照してください。

プロダクション環境での追加考慮事項

  • エラーログの記録: 不正なトークンでのアクセス試行を記録し、異常検知に活用
  • レート制限: 同一IPからの連続した認証失敗を制限(例:1分間に5回まで)
  • アラート通知: 大量の認証失敗が発生した場合、管理者に通知

フロントエンド修正

これに合わせて、フロントエンド側も「自分のID」を送るのではなく、「アクセストークン」を送るように修正が必要です。

typescript
// フロントエンド修正例
const { data: { session } } = await supabase.auth.getSession();

await fetch("/api/checkout", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    // トークンをヘッダーに乗せる
    "Authorization": `Bearer ${session.access_token}`
  },
  body: JSON.stringify({
    // userIdはもう送らない
    language: "ja"
  }),
});

まとめ

  • 「ユーザー入力(リクエストボディ)はすべて疑え」 はセキュリティの基本。
  • ID情報は改ざんやなりすましが容易なため、必ず信頼できる認証基盤(JWT検証など)からサーバーサイドで取得する。
  • StripeやSupabaseを使っていても、つなぎ込み部分の実装ミスで脆弱性は生まれる。
  • エラーハンドリング、ログ記録、レート制限などの防御層を組み合わせることで、より堅牢なシステムを構築できる。

決済機能は特にお金が絡む部分なので、慎重すぎるくらいの実装を心がけたいですね。