技術 約6分で読めます

Stripe Checkoutの価格改ざん脆弱性

いけさん目次

TL;DR

影響 Stripe Checkout の作成APIで amountpriceId をクライアントから受け取り、そのまま unit_amountline_items.price に渡している実装。

対応 ブラウザからは plan のような商品キーのみ。Stripe Price ID と金額はサーバー側の許可リスト参照

確認 価格ページのCheckout作成リクエストを curl でコピーし、amountpriceIdquantity を改変して送信。改変価格でCheckout URLが返ればアウト


DEV Community に Never trust the client with your Stripe price という記事が出ていた。
言っていることはシンプルで、Stripe Checkout の作成エンドポイントで amount: req.body.amount みたいなコードを書くな、という話。

これ、Stripe固有の穴というより「決済の権威をどこに置くか」の問題だった。
このブログでも以前、Google Play課金で購入トークンをサーバー側で検証する話を書いたが、Stripe Checkoutでも同じ線引きが必要になる。

動くCheckoutがそのまま危ない

危ない形はこういうコード。

export async function POST(req: Request) {
  const { amount, plan } = await req.json();

  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [
      {
        price_data: {
          currency: "eur",
          product_data: { name: plan },
          unit_amount: amount,
        },
        quantity: 1,
      },
    ],
    success_url: `${origin}/success`,
    cancel_url: `${origin}/cancel`,
  });

  return Response.json({ url: session.url });
}

このコードは普通に動く。
テストカードで支払いも通る。
Stripeのダッシュボードにも成功した支払いが残る。

ただし、ブラウザのDevToolsや curl でリクエスト本文を書き換えれば、amount は好きな値にできる。
Premiumプランのボタンから送られるリクエストが { "amount": 2999, "plan": "Premium" } なら、攻撃者は { "amount": 100, "plan": "Premium" } に変えるだけでいい。

Stripeは「そのサービスのPremiumが本当に29.99ユーロか」を知らない。
Stripeはサーバーから渡された金額を処理するだけだ。
支払い後に自アプリのWebhookや成功ページがPremium権限を付けるなら、アプリ側は安い支払いを正規のPremium購入として扱ってしまう。

金額ではなく商品キーだけ受け取る

ブラウザから送ってよいのは、せいぜい「ユーザーがどの商品を選んだか」まで。
金額、Stripe Price ID、商品名、付与する権限はサーバー側で決める。

const PLANS = {
  hobby: {
    priceId: process.env.STRIPE_PRICE_HOBBY,
    entitlement: "hobby",
  },
  premium: {
    priceId: process.env.STRIPE_PRICE_PREMIUM,
    entitlement: "premium",
  },
  enterprise: {
    priceId: process.env.STRIPE_PRICE_ENTERPRISE,
    entitlement: "enterprise",
  },
} as const;

type PlanKey = keyof typeof PLANS;

function isPlanKey(value: unknown): value is PlanKey {
  return typeof value === "string" && Object.hasOwn(PLANS, value);
}

export async function POST(req: Request) {
  const body = await req.json();

  if (!isPlanKey(body.plan)) {
    return new Response("Invalid plan", { status: 400 });
  }

  const plan = PLANS[body.plan];

  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    line_items: [{ price: plan.priceId, quantity: 1 }],
    success_url: `${origin}/success`,
    cancel_url: `${origin}/cancel`,
    metadata: {
      plan: body.plan,
      entitlement: plan.entitlement,
    },
  });

  return Response.json({ url: session.url });
}

この形なら、攻撃者が amount を混ぜてもサーバー側では読まない。
priceId を送っても読まない。
plan: "free_lifetime" のような値は許可リストで落ちる。

Stripe公式ドキュメントでも、PaymentIntentはサーバーで作成し、クライアントには client_secret を渡す流れになっている。
Checkout Sessionsでも line_items.priceline_items.price_data の両方を使えるが、SaaSの固定プランならStripe側に作ったPriceオブジェクトをサーバーから参照する形が扱いやすい。

priceIdを受け取るだけでもまだ弱い

amount をやめて priceId にすれば安全、とは限らない。

const { priceId } = await req.json();

const session = await stripe.checkout.sessions.create({
  mode: "subscription",
  line_items: [{ price: priceId, quantity: 1 }],
  success_url: `${origin}/success`,
  cancel_url: `${origin}/cancel`,
});

この形だと、Hobbyプランの price_xxx_5eur とEnterpriseプランの price_xxx_500eur を差し替えられる。
StripeのPrice IDは秘密情報ではない。
価格ページのHTML、JavaScript bundle、Networkタブ、過去のCheckout URL周辺から見つかることがある。

SupabaseのAnon Keyが見えてもRLSで守る設計と同じで、フロントに値が見えること自体と、その値に権限を持たせることは別問題だ。
Stripeの priceId も、表示や選択のために見える場面はある。
でも、どの priceId を使ってCheckoutを作るかはサーバーの認可判断にする。

動的価格はサーバーで計算する

寄付、従量課金、見積もり、マーケットプレイスのように、固定Priceだけでは足りない場面はある。
その場合でも unit_amount をリクエスト本文から丸ごと信用しない。

ユーザーが入力してよいのは、金額そのものではなく、金額を計算するための材料だ。
たとえば寄付なら、最低額と最高額をサーバー側で見る。

export async function POST(req: Request) {
  const { donationCents } = await req.json();

  if (
    typeof donationCents !== "number" ||
    !Number.isInteger(donationCents) ||
    donationCents < 100 ||
    donationCents > 100000
  ) {
    return new Response("Invalid amount", { status: 400 });
  }

  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [
      {
        price_data: {
          currency: "eur",
          product_data: { name: "Donation" },
          unit_amount: donationCents,
        },
        quantity: 1,
      },
    ],
    success_url: `${origin}/success`,
    cancel_url: `${origin}/cancel`,
  });

  return Response.json({ url: session.url });
}

ECなら、商品IDと数量を受け取って、商品価格はDBから引く。
クーポンなら、コードが有効か、そのユーザーに使えるか、その商品に使えるかをサーバーで見る。
数量も quantity: -1 や極端な値を落とす。

「ブラウザから来た値をそのままStripeへ渡す」が危ない。
Stripeへ渡す値は、サーバー側のDB、設定、認証済みセッション、検証済み入力から作る。

Webhookで付与条件をもう一度見る

Checkout Sessionを作る時点で価格を守っても、権限付与側が雑だと別の穴になる。

支払い完了後のWebhookでは、最低限このへんを見たい。

見る値理由
event.typecheckout.session.completed など想定イベントだけ処理する
session.payment_status未払いのSessionで権限を付けない
session.customerログインユーザーに紐づくStripe Customerか確認する
session.metadata.planサーバーが入れたプラン情報だけを使う
line_items のPrice ID付与する権限と実際のPrice IDが一致するか確認する

特に customerId をクライアントから受け取る実装は危ない。
ログイン中のユーザーからサーバー側でStripe Customerを引き、Checkout Sessionに渡す。
「誰の支払いか」も「いくらの支払いか」と同じで、リクエスト本文に権威を持たせない。

手元のコードをcurlで殴る

自分の実装が危ないかどうかは、価格ページのNetworkタブからCheckout作成リクエストをコピーすればすぐ見える。

curl -X POST https://example.com/api/checkout \
  -H "Content-Type: application/json" \
  -H "Cookie: session=..." \
  -d '{
    "plan": "premium",
    "priceId": "price_FAKE",
    "amount": 1,
    "quantity": -1
  }'

これでStripe Checkout URLが返ってきて、Checkout画面の価格が改変値に寄っていたらアウト。
URLが返ってきても、表示価格が本来のPremium価格のままなら一段守れている。

さらにWebhookまで見るなら、Stripe CLIでテストイベントを流すだけでなく、実際にテスト決済を通して、自アプリ側のユーザー権限テーブルがどう更新されるかまで追う。
Stripe側の決済成功と、自サービス側の権限付与は別システムだ。
ここを同一視すると、ダッシュボード上は成功だらけなのに、アプリ側では安売りPremiumが量産される。

参考リンク