Webhookの署名検証を忘れると課金が無料化する話
はじめに
参考にしたStripe公式ドキュメント
Webhookの受信と署名検証: https://docs.stripe.com/webhooks
署名検証エラーの解決: https://docs.stripe.com/webhooks/signature
私は個人開発でユーザーの課金システムを実装した。Stripeの公式ドキュメントを読みながら、Firebase FunctionsでWebhookを受け取って、FirestoreのisPaidフラグを立てる。よくある構成だ。
テストも通った。Stripeのテストカードで決済して、ちゃんと課金状態になる。「動いた、リリースだ」──そう思った数日後、何気なくセキュリティの勉強をしていて、自分のWebhookエンドポイントにcurlを投げてみた。
{ "isPaid": true }
誰でも、課金せずに、全機能を使える状態だった。
この記事は、新米開発者の私がやらかしたWebhook署名検証漏れの話と、そこから学んだ「アプリサービスを運営する上で本当に気をつけるべきこと」のまとめです。同じ構成で個人開発をしている人に届いてほしい。
何を作っていたか
技術スタックはこんな感じ。
- フロントエンド:React
- バックエンド:Firebase Functions(Node.js)
- DB:Firestore
- 決済:Stripe Checkout
ユーザーがReactアプリ上で「課金する」ボタンを押すと、Firebase FunctionsでCheckout Sessionを作ってStripeにリダイレクト。決済完了後、StripeがWebhookでcheckout.session.completedイベントを送ってくる。それを受けてFirestoreのユーザードキュメントにisPaid: trueを書き込む──という、教科書通りのフロー。
公式ドキュメントは読んだ。サンプルコードもコピペした。動いた。だから安全だと思った。
これが間違いだった。
問題のコード
当時のWebhookハンドラはこうなっていた。
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
admin.initializeApp();
export const stripeWebhook = functions.https.onRequest(async (req, res) => {
const event = req.body;
if (event.type === "checkout.session.completed") {
const session = event.data.object;
const userId = session.client_reference_id;
await admin.firestore().collection("users").doc(userId).update({
isPaid: true,
});
}
res.status(200).send("OK");
});
シンプルで、読みやすくて、動く。そして致命的に脆弱。
何が問題か。このコードは「リクエストを送ってきたのが本当にStripeかどうか」を一切確認していない。リクエストボディの形式さえ合っていれば、誰が送ってきても処理してしまう。
つまり、Firebase FunctionsのURL(https://<region>-<project>.cloudfunctions.net/stripeWebhook)さえ知っていれば、世界中の誰でもこんなリクエストを投げて、好きなユーザーを課金状態にできる。
curl -X POST https://us-central1-myapp.cloudfunctions.net/stripeWebhook \
-H "Content-Type: application/json" \
-d '{
"type": "checkout.session.completed",
"data": {
"object": {
"client_reference_id": "any_user_id_here"
}
}
}'
しかもFirebase FunctionsのURLは規則的だから、プロジェクト名が分かれば推測も容易だ。
なぜ署名検証を「うっかり」スキップしてしまうのか
弁解がましいが、これはFirebase Functions特有の罠が関係している。
Stripeの署名検証は、リクエストボディの生のバイト列(raw body)に対して行う必要がある。ところがFirebase Functionsは、デフォルトでJSONボディを自動でパースしてしまう。何も考えずにreq.bodyをStripeの検証関数に渡すと、パースされた後のオブジェクトを再度文字列化したものになり、ハッシュが合わなくなって検証が失敗する。
// これはうまくいかない
stripe.webhooks.constructEvent(
JSON.stringify(req.body), // ❌ パース後のオブジェクトを再文字列化してもダメ
signature,
endpointSecret
);
ここで初心者がやりがちな思考の流れがこれ。
- ドキュメント通りに書いたのに検証が失敗する
- ググっても解決しない
- 「とりあえず動くようにしたい」
- 検証をコメントアウトしてリリース
- 直すのを忘れる
5番、本当に忘れる。動いているコードは触りたくないから。
正しい実装
Firebase Functionsには req.rawBody というプロパティがあって、これがStripeの署名検証用に使える生のボディだ。これを知っていれば一発で解決する。
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import Stripe from "stripe";
admin.initializeApp();
const stripe = new Stripe(functions.config().stripe.secret_key, {
apiVersion: "2024-06-20",
});
const endpointSecret = functions.config().stripe.webhook_secret;
export const stripeWebhook = functions.https.onRequest(async (req, res) => {
const signature = req.headers["stripe-signature"];
let event;
try {
event = stripe.webhooks.constructEvent(
req.rawBody, // ✅ ここがポイント
signature,
endpointSecret
);
} catch (err) {
console.error("Webhook signature verification failed:", err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// ここから先は、Stripeから来たことが保証されたイベント
if (event.type === "checkout.session.completed") {
const session = event.data.object;
const userId = session.client_reference_id;
await admin.firestore().collection("users").doc(userId).update({
isPaid: true,
});
}
res.status(200).send("OK");
});
たった数行の追加。でも、これがあるとないとでサービスの安全性は天と地ほど違う。
ここまでで終わらない。実はもう1つの罠がある
Webhook検証を直して「よし、これで安全だ」と思ったあなた。もう1つ大きな穴がある可能性が高い。
Firestoreのセキュリティルールだ。
ReactアプリからFirebaseを使うとき、クライアントは直接Firestoreを読み書きする。もしルールがこうなっていたら、
match /users/{userId} {
allow read, write: if request.auth.uid == userId;
}
ユーザーは自分のドキュメントを好きに書き換えられる。つまりブラウザのコンソールから、
firebase.firestore().collection("users").doc(currentUserId).update({
isPaid: true
});
これで課金完了。Webhookを叩く必要すらない。
isPaidのような決済に関わるフィールドは、クライアントから書き換え不可にする必要がある。
match /users/{userId} {
allow read: if request.auth.uid == userId;
allow update: if request.auth.uid == userId
&& !request.resource.data.diff(resource.data).affectedKeys()
.hasAny(['isPaid', 'plan', 'subscriptionId']);
allow create: if request.auth.uid == userId;
}
これで、ユーザーは自分のプロフィール情報は編集できるが、課金関連フィールドはサーバー(Functions)からしか変更できない。
新米がアプリサービスを運営する上で気をつけること
今回の経験を経て、自分の中でルール化したことをまとめておく。
1. クライアントから来るデータは全部疑う
これがセキュリティの第一原則だと、身をもって理解した。リクエストヘッダもボディもクエリパラメータも、すべて改ざんされうる。「フロントでバリデーションしたから大丈夫」は通用しない。バックエンドで必ず再検証する。
2. 外部サービスからの通知も疑う
WebhookやコールバックURLは「外部サービスから来る」というだけで安心しがちだが、URLが分かれば誰でも叩ける。署名検証は必須であって、オプションじゃない。
3. 「動いた」と「安全」は別物
テストが通ること、正常系で動くことは、安全であることを意味しない。攻撃者は正常系を使わない。リリース前に最低限、自分のエンドポイントにcurlで雑なリクエストを投げてみる癖をつけた。
4. 権限の境界を意識する
「このデータを書き換えていいのは誰か」を、すべてのテーブル・コレクションで明確にする。Firestoreならrequest.resource.data.diff()、SQLならアプリケーション層での認可チェック。デフォルトで全部許可になっていないかを毎回確認する。
5. 公式ドキュメントを"読む"だけでなく"従う"
Stripeのドキュメントには「Webhookの署名検証は必須」とはっきり書いてある。読んでいた。でも面倒だからスキップした。ドキュメントに「必須」と書かれていることを軽視しない。書いてある通りにやる。
6. ログとアラートを最初から仕込む
不審なリクエストに気づくためには、ログがいる。決済関連のエンドポイントはとくに、失敗ログ・成功ログを残しておく。Cloud Loggingに署名検証失敗のログが急増したら、それは攻撃の兆候かもしれない。
7. リリース前のチェックリストを持つ
人間は忘れる。だからチェックリストにする。自分のは今こんな感じ。
- 認証が必要なAPIは全部認証チェックしているか
- Webhookは署名検証しているか
- DBのセキュリティルール/RLSは適切か
- 環境変数・APIキーをコミットしていないか
- 金額や権限フィールドをクライアントから受け取っていないか
- エラーメッセージに内部情報が漏れていないか
おわりに
正直、この脆弱性に気づいたときは血の気が引いた。実害が出ていなかったのは運が良かっただけで、もし誰かに見つかっていたら、課金は素通しされ、調査と修正と謝罪に追われていたはずだ。
でも同時に、これは自分にとって転機でもあった。今までは「動くものを作る」ことばかり考えていて、「守ること」を意識していなかった。動くコードと守れるコードは別物だと、ようやく実感を持って理解した。
それから本格的にセキュリティを学び始めた。OWASP Top 10を読み、IPAの安全なウェブサイトの作り方を読み、自分の過去のコードを見返して怖くなる、を繰り返している。
サービスを世に出すというのは、ユーザーのデータとお金を預かるということ。新米開発者であっても、その責任からは逃れられない。同じ轍を踏む人が一人でも減ればと思って、この記事を書きました。
何かの参考になれば嬉しいです。