AIにダウンロードURLを送らせる前に、偽の決済Webhookを署名で止める見本

合成したcheckout.session.completedの本文を1文字だけ変えると、同じ署名ヘッダーでも検査はREJECT reason=signatureになりました。購入完了の通知らしいJSONを受け取っただけで、AIにダウンロードURLを送らせるのは危険です。
デジタル商品では、決済通知をきっかけに、ファイルの案内や購入者向けメールの下書きをAIへ任せたくなります。ただし、その前提になる「この通知は決済サービスが送ったものか」は、文面の自然さでは判断できません。署名を確認せずに処理すると、第三者が送った偽の購入通知でも、商品を渡す流れへ入り得ます。
今回は実在の決済、顧客、Stripeアカウント、秘密鍵を使いません。固定した合成本文とwhsec_test_onlyというテスト専用文字列で、署名、本文、時刻の3点を検査します。Stripeへの通信も、メール送信も、AIへの依頼もしていません。
先に止める場所を決めます
AIは、購入者への案内文を整える場所には使えます。しかし、決済が本物かを判定する場所をAIの推測へ渡してはいけません。検査の出口を先に固定します。
[Webhookの未加工本文] + [Stripe-Signature] + [endpoint secret]
│
▼
[署名を照合する]
│
┌──────────────┼──────────────┐
▼ ▼ ▼
本文改ざん 時刻超過 ヘッダー不正
│ │ │
└───────> [REJECTして処理しない]
署名と時刻が通る
│
▼
[決済種別や注文状態をアプリ側で確認] → [AIは案内の下書きだけを作る]
この順番にした理由は、AIが丁寧な案内を作れても、通知の送信元を証明できないためです。署名検証に通ったことも、商品を必ず渡してよいという判断そのものではありません。返金、在庫、重複注文、提供停止といった事業上の条件は、別のルールで確認します。
Stripeは、Webhookを処理する際に未加工のリクエスト本文、Stripe-Signatureヘッダー、エンドポイント固有の秘密値で検証するよう案内しています。本文をJSONとして読み直したり、空白を整形したりしてから署名を照合すると、受信時のバイト列が変わり、正しい通知も検証できません。
用語をそろえます
| 用語 | この見本での意味 |
|---|---|
| Webhook | 外部サービスが出来事をHTTPリクエストで知らせる仕組みです。 |
| 未加工本文 | 受信直後の本文です。JSONとして整形する前の文字列を指します。 |
Stripe-Signature | 時刻と署名値を含むHTTPヘッダーです。 |
| endpoint secret | 1つのWebhook受信口に対応する秘密値です。実際の値をコードやログへ出しません。 |
| HMAC | 秘密値を使い、本文が途中で変わっていないか照合する計算です。 |
| リプレイ攻撃 | 以前の正しい通知を、後からもう一度送り直す攻撃です。 |
合成通知で検査器を作ります
空の一時ディレクトリで試します。実在の秘密値を環境変数、ソースコード、シェル履歴へ入れるための手順ではありません。ここで使うwhsec_test_onlyは、合成入力専用の文字列です。
work="${TMPDIR:-/tmp}/webhook-signature-lab"
mkdir -p "$work"
cd "$work"
python3 --version
実行時のPythonは次でした。
Python 3.13.5
次のverify_delivery.pyは、t=の時刻とv1=の署名を読み、時刻.本文をHMAC-SHA256で照合します。理解とテスト用の最小実装です。本番ではStripeが案内する公式ライブラリで検証してください。
verify_delivery.pyの全文
from __future__ import annotations
import argparse
import hashlib
import hmac
import sys
def parse_header(value: str) -> tuple[int, list[str]]:
fields: dict[str, list[str]] = {}
for part in value.split(","):
key, sep, item = part.strip().partition("=")
if not sep or not key or not item:
raise ValueError("malformed")
fields.setdefault(key, []).append(item)
if len(fields.get("t", [])) != 1 or not fields.get("v1"):
raise ValueError("missing t or v1")
try:
return int(fields["t"][0]), fields["v1"]
except ValueError as exc:
raise ValueError("invalid timestamp") from exc
def expected(secret: str, timestamp: int, body: str) -> str:
signed = f"{timestamp}.{body}".encode("utf-8")
return hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--secret", required=True)
parser.add_argument("--body", required=True)
parser.add_argument("--header")
parser.add_argument("--now", type=int)
parser.add_argument("--tolerance", type=int, default=300)
parser.add_argument("--sign", type=int, metavar="TIMESTAMP")
args = parser.parse_args()
if args.sign is not None:
print(f"t={args.sign},v1={expected(args.secret, args.sign, args.body)}")
return 0
if args.header is None or args.now is None:
parser.error("--header and --now are required unless --sign is used")
try:
timestamp, supplied = parse_header(args.header)
except ValueError as exc:
print(f"REJECT reason=header detail={exc}")
return 2
actual = expected(args.secret, timestamp, args.body)
if not any(hmac.compare_digest(actual, value) for value in supplied):
print("REJECT reason=signature")
return 1
age = abs(args.now - timestamp)
if age > args.tolerance:
print(f"REJECT reason=timestamp age={age}s tolerance={args.tolerance}s")
return 1
print(f"ACCEPT verified=1 age={age}s")
return 0
raise SystemExit(main())
スクリプトを保存したら、固定した本文と時刻からテスト用ヘッダーを作ります。--signは検査器そのものを確かめるためだけの補助です。実運用で受信ヘッダーを自作しません。
body='{"id":"evt_demo","type":"checkout.session.completed"}'
secret='whsec_test_only'
header="$(python3 verify_delivery.py --secret "$secret" --body "$body" --sign 1730000000)"
printf 'HEADER=%s\n' "$header"
実行結果です。
HEADER=t=1730000000,v1=de050011c260393cb3b4cad0b60ff734a349782e51f7fef057b6e98d18723795
まず正しい組を通します
本文、ヘッダー、秘密値を同じ組にしたまま、10秒後として検証します。
python3 verify_delivery.py \
--secret "$secret" \
--body "$body" \
--header "$header" \
--now 1730000010
printf 'exit=%s\n' "$?"
実行結果です。
ACCEPT verified=1 age=10s
exit=0
ここで通ったのは、合成した本文が変わらず、署名が一致し、時刻差が300秒以内だったことです。決済サービスへ接続できたこと、注文が存在すること、AIが購入者へ送信してよいことまでは示しません。
文字が1つ変わった本文は渡しません
次に、ヘッダーはそのままにして、イベント種別だけをcheckout.session.expiredへ差し替えます。これは、途中で本文が変わった場合を表す最小の悪い入力です。
set +e
python3 verify_delivery.py \
--secret "$secret" \
--body '{"id":"evt_demo","type":"checkout.session.expired"}' \
--header "$header" \
--now 1730000010
printf 'exit=%s\n' "$?"
set -e
実行結果です。終了コードは1でした。
REJECT reason=signature
exit=1
署名が対象にするのは意味が同じJSONではなく、受信した未加工本文です。キーの並びや空白まで変われば別の入力になります。検証を通してから、必要な範囲だけJSONを読む順番を崩しません。
古い通知と壊れたヘッダーも止めます
Stripeはリプレイ攻撃への対策として、署名ヘッダー内の時刻を利用します。この見本では許容時間を300秒にしました。301秒後として実行すると、署名が正しくても拒否します。
set +e
python3 verify_delivery.py \
--secret "$secret" \
--body "$body" \
--header "$header" \
--now 1730000301
printf 'exit=%s\n' "$?"
set -e
実行結果です。終了コードは1でした。
REJECT reason=timestamp age=301s tolerance=300s
exit=1
v1だけで tがないヘッダーも入力として成立しません。ヘッダーを部分的に読むのではなく、必要な項目がそろわない時点で止めます。
set +e
python3 verify_delivery.py \
--secret "$secret" \
--body "$body" \
--header 'v1=abc' \
--now 1730000010
printf 'exit=%s\n' "$?"
set -e
実行結果です。終了コードは2でした。
REJECT reason=header detail=missing t or v1
exit=2
AIに渡す情報を署名検証の後ろへ置きます
購入者へ渡すURL、メールアドレス、注文の内容は、本文が本物と確認できてから必要な分だけ扱います。通知本文をそのままAIへ渡し、AIの返答をトリガーに配布する設計にはしません。
この見本では、処理を次の3つに分けます。
- 受信口で未加工本文、ヘッダー、時刻を検証します。
- アプリ側でイベント種別、注文状態、重複処理の有無を確認します。
- その結果から、AIには購入者へ見せてよい商品名と案内方針だけを渡し、返信案を作らせます。
AIに渡す情報を減らしても、署名検証の代わりにはなりません。逆に署名が通っても、重複配布や返金済み注文の判断まではできません。送信元の確認、事業ルール、案内文を別々の責任として残すと、誤りが出た場所を追えます。
この見本で起きた拒否と対処
| 症状 | この見本での原因 | 対処 |
|---|---|---|
REJECT reason=signature | 署名を作った後で本文を変えました。 | 本番では未加工本文をそのまま公式ライブラリへ渡します。 |
REJECT reason=timestamp | 時刻差が301秒で、許容した300秒を超えました。 | サーバー時刻を同期し、許容時間を事業の要件に合わせて決めます。 |
REJECT reason=header | t=がない壊れたヘッダーでした。 | 必須項目を補完せず、400系の応答として処理を止めます。 |
この検査器はStripe公式SDKの置き換えではありません。実際のエンドポイントでは、公式ライブラリの署名検証を使い、endpoint secretを秘密管理へ置きます。受信後は速やかに成功応答を返し、長いAI処理や配布処理は別の安全なジョブへ分ける設計も必要です。
小さな販売導線でも、「通知が来た」から「AIが商品を渡す」までを1つの黒箱にしない設計ができます。守る順序を固定します。
- 偽の通知は署名検証で止めます。
- 注文状態はアプリ側のルールで確認します。
- AIには案内文の下書きだけを任せます。
この分け方なら、便利さを残したまま、渡してはいけない相手への配布を減らせます。


