Claude CodeがStripeコードで踏む地雷と、CLAUDE.mdで防ぐ方法

はじめに

Claude CodeにStripeのWebhookハンドラを書かせると、署名検証が省略されたコードが出てくることがある。コンパイルは通り一見動くように見えるが、攻撃者が偽のWebhookを送れば未払いの注文を完了させられる。冪等性キーの欠落も同様で、ネットワーク障害によるリトライで二重課金が発生する。本記事はClaude Codeが省略しやすい安全側の実装をCLAUDE.mdで強制する方法に絞る。


Webhook署名検証と冪等性を正しく実装する

Webhook署名検証の省略

Claude Codeはリクエストボディをreq.json()でパースするコードを生成しやすいが、これではStripeの署名検証が機能しない。生ボディ(テキスト)を取得してから検証する。


// ❌ req.json()使用(署名検証できない)
const body = await req.json();
const event = body as Stripe.Event; // 任意のリクエストを処理してしまう

// ✅ req.text()で生ボディを取得してから署名検証
export async function POST(req: NextRequest) {
  const body = await req.text();
  const sig = (await headers()).get('stripe-signature');
  if (!sig) return NextResponse.json({ error: 'No signature' }, { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err) {
    return NextResponse.json({ error: 'Webhook Error' }, { status: 400 });
  }
  // 署名検証後にイベント処理
}

署名検証に失敗したときは必ず400を返す。Stripeは200以外をリトライするが、400は最終失敗として記録する仕様だ。

冪等性キーの欠落

stripe.paymentIntents.create()を冪等性キーなしで呼ぶと、ネットワーク障害後のリトライで新しいPaymentIntentが作られて二重課金が発生する。


// ❌ idempotencyKeyなし(リトライで二重課金)
const paymentIntent = await stripe.paymentIntents.create({ amount, currency });

// ✅ 注文IDを冪等性キーに使う
const paymentIntent = await stripe.paymentIntents.create(
  { amount, currency },
  { idempotencyKey: `payment-intent-${orderId}` }
);

冪等性キーは注文IDから導出するか、リクエストスコープのUUID v4を使う。機密情報(メールアドレス等)をキーに含めてはならない。


StripeエラーをClaude Codeに正しく処理させる

Claude Codeはcatch (error)で全エラーを一律に処理するコードを生成しがちだ。カード拒否の理由がユーザーに伝わらず、リトライすべきエラーとすべきでないエラーを区別できなくなる。


// ❌ 全エラーを一律処理
} catch (error) { return { error: '決済に失敗しました' }; }

// ✅ Stripeエラー型を判別して処理
} catch (error) {
  if (error instanceof Stripe.errors.StripeCardError) {
    const code = error.decline_code;
    if (code === 'insufficient_funds') return { error: '残高不足です', retryable: false };
    if (code === 'expired_card') return { error: 'カードの有効期限切れです', retryable: false };
    // fraudulent・lost_card は具体理由を表示しない(攻撃者への情報提供を防ぐ)
    return { error: '決済が承認されませんでした', retryable: false };
  }
  if (error instanceof Stripe.errors.StripeRateLimitError ||
      error instanceof Stripe.errors.StripeConnectionError) {
    return { error: 'しばらく後に再試行してください', retryable: true };
  }
  throw error;
}

リトライ可能なのはStripeRateLimitErrorStripeConnectionErrorStripeAPIErrorの3種。StripeCardErrorStripeInvalidRequestErrorStripeAuthenticationErrorStripeIdempotencyErrorは再試行しても解決しない。


CLAUDE.mdテンプレート+Stripe CLI

CLAUDE.mdテンプレート


# Stripe統合ルール

## キー管理
- STRIPE_SECRET_KEY等は環境変数から参照(ハードコード禁止・sk_live_はCI/本番のみ)
- .env.localは.gitignoreに含める

## Webhook(必須)
- req.text()で生ボディを取得(req.json()禁止)
- stripe.webhooks.constructEvent()を最初に実行
- 署名検証失敗時はHTTP 400を返す
- 処理済みイベントIDをDBに記録して重複処理を防ぐ

## 冪等性
- stripe.paymentIntents.create()等には必ずidempotencyKeyを渡す
- キーは注文IDから導出(`payment-intent-${orderId}`)またはUUID v4

## エラーハンドリング
- instanceof Stripe.errors.StripeCardErrorでエラー型を判別
- fraudulent・lost_cardはユーザーに具体理由を表示しない
- StripeRateLimitError・StripeConnectionErrorはリトライ可能として処理

## PCI DSS禁止事項
- カード番号・CVV・有効期限をサーバーで受け取るコード禁止
- Payment Element / Checkout Sessionを使いカード情報はStripeに直接送信

Stripe CLI基本コマンド


# ローカルに転送(STRIPE_WEBHOOK_SECRETが出力される)
stripe listen --forward-to localhost:3000/api/webhook

# テストイベントをトリガー
stripe trigger checkout.session.completed
stripe trigger payment_intent.payment_failed
stripe trigger customer.subscription.deleted

EM視点──PCI DSS準拠とチームのStripe標準化

Stripeを使うとPCI DSS準拠スコープが大幅に縮小するが、Webhook署名検証・APIキー管理・ログへのカード情報出力禁止は義務として残る。CLAUDE.mdにPCI DSS禁止事項を書いておけば、Claude Codeが誤ってカード番号を受け取るエンドポイントを生成するリスクを防げる。チームへの展開は既存コードをgrepして違反箇所を洗い出すところから始める。


まとめ

Claude CodeがStripeコードで省略しやすいのはWebhook署名検証・冪等性キー・エラー型判別だ。CLAUDE.mdに本記事のテンプレートを追記すれば、これらを前提にしたコードが生成される。stripe listen --forward-toでローカルWebhookテストを実施して動作を確認してほしい。

コメント

タイトルとURLをコピーしました