Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

StripeのList Customers APIをメールだけで照合すると、第三者が既存顧客を上書きできる

公開 読了時間 約7分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

認証を持たない公開フォームがStripeの顧客をメールアドレスだけで照合すると、相手のメールを知っている第三者が本人になりすまして既存顧客の契約metadataを書き換えられる。

結論

認証を要求しない公開フォームが、Stripeの顧客をメールアドレスだけで既存顧客と照合していると、相手のメールアドレスを知っている第三者が本人になりすまして、既存の有料顧客の契約条件をStripeのmetadata経由で書き換えられる。 直し方は、メールアドレス一致で見つけたcustomerIdを即座に確定させないこと。決済セッションIDから引けた場合(=実際に代金を支払った本人)だけ即時の上書きを許し、メール一致だけで見つかった場合は別のmetadataキーに「変更希望」として退避し、人が確認してから反映する。

症状

申込みフォームのAPI(api/order.js)は専用DBを持たず、申込みも決済もStripeの顧客レコード(metadata付き)に集約する設計だった。決済セッションID(stripeSessionId)が送られてきた場合は、Checkout Sessionから実際に課金されたcustomerを引ける。問題は、決済セッションIDが無い場合の分岐だった。

// api/order.js:77-87(修正前)
// セッションから引けない場合はメールで既存顧客を探す(二重登録の防止)
if (!customerId) {
  const listRes = await fetch(
    `https://api.stripe.com/v1/customers?email=${encodeURIComponent(email)}&limit=1`,
    { headers: auth },
  );
  if (listRes.ok) {
    const list = await listRes.json();
    if (list.data && list.data[0]) customerId = list.data[0].id;
  }
}

この分岐はもともと「同じ人が無料お試しと有料申込みで2回フォームを送っても、Stripeの顧客レコードが2つに分裂しないように」という二重登録防止のために書かれていた。しかし、ここで決まったcustomerIdは、このあとの処理でそのまま使われる。

const params = new URLSearchParams({
  email,
  name: company,
  "metadata[source]": "formkaeru-lp",
  "metadata[status]": "new",
  "metadata[plan]": plan,
  "metadata[contact_name]": name,
  "metadata[offer]": offer,
  "metadata[target]": target,
  // ...
});
const url = customerId
  ? `https://api.stripe.com/v1/customers/${customerId}`
  : "https://api.stripe.com/v1/customers";

customerIdが決まっていれば、そのcustomerへPOSTでmetadataを上書きする。このoffer(営業条件)・target(納品対象)はフォームの送信者が自由入力した値で、しかもこのAPIエンドポイント自体にはハニーポット(bot避け)しかなく、本人確認の仕組みが無い。つまり、既存顧客のメールアドレスを知っている第三者が同じフォームにそのメールアドレスを入力して送信するだけで、customerIdがメール一致で引き当てられ、既存顧客のoffer/targetのmetadataが送信者の入力値で上書きされる経路が成立していた。

原因

このコードには、強さの違う2種類の「既存顧客の見つけ方」が同じ変数(customerId)に流れ込む構造になっていた。

  • 決済セッションIDからのcustomer取得:Stripe側のCheckout Sessionが決済完了時に発行するIDで、それを持っていること自体が「実際に代金を支払った本人である」という証明になる
  • メールアドレスでのList Customers検索:メールアドレスは本人以外の第三者も知り得る情報で、「一致した」という事実は本人確認にならない

二重登録防止という目的のためには、後者の検索結果(メールアドレス一致でのcustomerId)だけで十分だった。しかし、見つけたcustomerIdをそのまま前者と同じ変数に代入し、同じ「確定した顧客」として以降のmetadata更新処理に渡してしまったため、本人確認の強度が違う2つの経路が、コードの後段では区別できない1つの値になっていた。この構造が、認証を持たないフォームという条件と組み合わさることで、メールアドレスを知っているだけの第三者が既存契約を書き換えられる経路を開いていた。

直し方

メールアドレス一致で見つけたIDをcustomerIdとは別の変数(existingId)に分け、customerIdが未確定(決済セッションから引けなかった)かつexistingIdがある場合は、即時の上書きをせず「変更希望」として別経路に退避するようにした。

// api/order.js:77-118(修正後)
// セッションから引けない場合はメールで既存顧客を探す(二重登録の防止)
let existingId = null;
if (!customerId) {
  const listRes = await fetch(
    `https://api.stripe.com/v1/customers?email=${encodeURIComponent(email)}&limit=1`,
    { headers: auth },
  );
  if (listRes.ok) {
    const list = await listRes.json();
    if (list.data && list.data[0]) existingId = list.data[0].id;
  }
}

// ⚠ このフォームは誰でも叩ける(認証はハニーポットだけ)。
//   既存顧客の営業条件(offer/target)をメールアドレスだけで上書きできてしまうと、
//   相手のメールを知っている第三者が、その顧客の翌月の納品内容を差し替えられる。
//   決済セッションIDを持っている=実際に決済した本人、のときだけ上書きを許す。
//   それ以外は「変更希望」として別のキーに置き、人が確認してから反映する。
if (!customerId && existingId) {
  const pending = new URLSearchParams({
    "metadata[status]": "change_requested",
    "metadata[change_requested_at]": new Date().toISOString(),
    "metadata[pending_contact_name]": name,
    "metadata[pending_offer]": offer,
    "metadata[pending_target]": target,
    "metadata[pending_points]": points,
    "metadata[pending_website]": website,
  });
  const pr = await fetch(`https://api.stripe.com/v1/customers/${existingId}`, {
    method: "POST",
    headers: form,
    body: pending.toString(),
  });
  if (!pr.ok) throw new Error("stripe pending update failed");
  res.statusCode = 200;
  return res.end(
    JSON.stringify({
      ok: true,
      note: "ご登録済みのメールアドレスのため、変更希望として承りました。担当者から確認のご連絡をします",
    }),
  );
}

修正後は、メールアドレス一致だけで見つかった顧客に対しては、本来のoffer/targetのmetadataキーを直接書き換えず、pending_offer/pending_targetという別のキーに「変更希望」として退避する。レスポンスも「承りました」として即時反映ではないことを送信者に示し、実際の反映は人がStripeのmetadataを見てから行う。決済セッションIDからcustomerIdが確定した経路(=実際に決済した本人からの送信)はこの分岐を通らず、従来どおり即時に反映される。

再発防止

このバグは、本人確認の強度が異なる複数の方法で「同じ顧客ID」を決めようとしたときに、その強度の違いを変数の型やフィールド名では区別せず、同じ1つの値に合流させてしまったことで起きた。認証を持たないエンドポイントで外部から来た識別子(ここではメールアドレス)をキーに既存レコードを引き当てる処理を書くときは、その識別子が「本人であることの証明になるか」を先に判定し、証明にならない経路で見つかったレコードは、確定済みのレコードとは別の変数・別の扱い(即時反映ではなく人の確認を挟む)にしておく必要がある。

よくある質問

Q1なぜメールアドレスだけの照合が危ないのですか?

フォーム自体に認証機構がなく、誰でも送信できるため、Stripeに登録済みのメールアドレスを知っているだけで本人確認なしにそのcustomerIdを引き当てられる。引き当てた後にcustomerのmetadataをそのまま更新する処理だと、送信者が本人かどうかを一切確かめずに既存契約の内容を上書きしてしまう。

Q2決済セッションIDがあれば安全なのはなぜですか?

StripeのCheckout Sessionは決済を完了した本人にしか渡らないIDで、session.customerから引けるcustomerIdは実際に代金を支払った本人のものであることが保証される。メールアドレスは本人以外でも知り得る情報なので、同じ強さの根拠にはならない。

Q3メールで見つかった既存顧客はどう扱うようにしたのですか?

customerIdを確定させず、別のmetadataキー(status=change_requestedなど)に変更希望の内容を退避し、フォームには担当者が確認のうえ連絡すると返すだけにした。実際の反映はStripe側のmetadataを人が見てから行う。

確認した環境

  • Vercel Serverless Functions(Node.js、package.jsonなし・Stripe SDKを使わずfetchでREST APIを直接呼ぶ構成)
  • 2026-09-30 のコミットで修正を確認(実際の修正はそれより前のデプロイ)

この記事の根拠

  • JavaScriptファイル 77〜87行目コミット e5e536d
  • JavaScriptファイル 77〜118行目コミット 9cd8ba2

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。