Stripe Checkoutの価格改ざん脆弱性
目次
TL;DR
影響 Stripe Checkout の作成APIで amount や priceId をクライアントから受け取り、そのまま unit_amount や line_items.price に渡している実装。
対応 ブラウザからは plan のような商品キーのみ。Stripe Price ID と金額はサーバー側の許可リスト参照
確認 価格ページのCheckout作成リクエストを curl でコピーし、amount・priceId・quantity を改変して送信。改変価格で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.price と line_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.type | checkout.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が量産される。