3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Webhookの署名検証を忘れると課金が無料化する話

3
Posted at

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
);

ここで初心者がやりがちな思考の流れがこれ。

  1. ドキュメント通りに書いたのに検証が失敗する
  2. ググっても解決しない
  3. 「とりあえず動くようにしたい」
  4. 検証をコメントアウトしてリリース
  5. 直すのを忘れる

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の安全なウェブサイトの作り方を読み、自分の過去のコードを見返して怖くなる、を繰り返している。

サービスを世に出すというのは、ユーザーのデータとお金を預かるということ。新米開発者であっても、その責任からは逃れられない。同じ轍を踏む人が一人でも減ればと思って、この記事を書きました。

何かの参考になれば嬉しいです。

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?