これは、テストが通り、きちんとして見えるのに、ほとんど何も証明していない Webhook の署名テストです:
const ts = Math.floor(Date.now() / 1000).toString();
const sig = await hmacHex(`${ts}.${body}`, secret, "SHA-256");
const headers = new Headers({ "stripe-signature": `t=${ts},v1=${sig}` });
expect(await verifySignature("stripe", headers, body, secret)).toBe(true);
フィクスチャは、検証コードがチェックするのと同じ式で作られています。検証コードを書いたときに Stripe のドキュメントを読み違えていたら(署名する文字列を間違えた、区切り文字を間違えた、base64 にすべきところを hex にした)、テストにも同じ読み違いが組み込まれ、両方ともグリーンのままです。テストしたのは「Stripe の署名を検証できる」ではなく、「自分自身と矛盾しない」です。
AnyHook には、20 のプロバイダーにわたって、まさにこういうテストが 70 個ありました。すべて通っていました。そこで、唯一重要な問いを立てました:プロバイダー自身のコードが作った署名は、AnyHook の検証コードを通るのか?
プロバイダーの SDK と突き合わせる
公式 SDK に署名用の関数を含めているプロバイダーがあり、たいていはまさにこの目的のためです。Stripe なら generateTestHeaderString、Svix なら Webhook クラスの .sign() です。これらがすでに依存関係にあるなら、突き合わせにコストはかかりません:
import Stripe from "stripe";
import { Webhook } from "svix";
import { verifySignature } from "./index";
it("accepts a header generated by stripe-node", async () => {
const stripe = new Stripe("sk_test_" + "0".repeat(24));
const payload = JSON.stringify({ id: "evt_1", type: "charge.succeeded" });
const secret = "whsec_" + "s".repeat(32);
const header = stripe.webhooks.generateTestHeaderString({ payload, secret });
expect(
await verifySignature("stripe", new Headers({ "stripe-signature": header }), payload, secret)
).toBe(true);
});
it("accepts a signature generated by the svix SDK", async () => {
const secret = "whsec_" + Buffer.from("k".repeat(24)).toString("base64");
const payload = JSON.stringify({ type: "email.received" });
const msgId = "msg_crosscheck";
const ts = new Date();
const signature = new Webhook(secret).sign(msgId, ts, payload);
const headers = new Headers({
"svix-id": msgId,
"svix-timestamp": Math.floor(ts.getTime() / 1000).toString(),
"svix-signature": signature,
});
expect(await verifySignature("svix", headers, payload, secret)).toBe(true);
});
これで、アサーションが確かめるのは自分自身との一致ではなく、Stripe との一致になります。ネガティブケース(ペイロードに署名し、別のボディを検証する)も追加して、テストが実際に失敗しうることを確認してください:
const header = stripe.webhooks.generateTestHeaderString({ payload, secret });
const tampered = JSON.stringify({ id: "evt_1", amount: 999999 });
expect(
await verifySignature("stripe", new Headers({ "stripe-signature": header }), tampered, secret)
).toBe(false);
Clerk と Resend はどちらも Svix の基盤で配信しているので、Svix の突き合わせ 1 つで 3 つのプロバイダーをカバーできます。AnyHook の 20 の検証コードのうち 4 つが、いまでは SDK による裏付けを持っています。午後のうちに終わったこの作業は、どんなスタックにも応用できます。
公開された署名関数がないプロバイダーは?
Shopify、Discord、PayPal:ほとんどのプロバイダーは署名用の関数を提供していません。その場合、唯一の正直な証拠は、実際の配信をキャプチャしたものです。自分が管理するエンドポイントに Webhook を向け、署名付きのリクエストを 1 件記録し、(headers, body, secret) の 3 つ組をフィクスチャとして固定します。記録した本物の署名こそが、自作のフィクスチャには決してなれない正解データです。
それが済むまでは、テストが何を示しているのかを、自分に対して正確に把握してください。AnyHook では、テストファイルの末尾のコメントに台帳を残しています:どのプロバイダーが SDK で検証済みか、どれが自己整合的なだけか、どれにはポジティブなテストがまったくないか。行動につなげられる正直なリストは、信用できないグリーンのテストスイートに勝ります。
この考え方で同じ日に見つかったバグ
同じ疑いは、ユニットテストの外にも当てはまります。Resend の経路を本番環境でエンドツーエンドに検証していたとき(ユニットテストではなく、実際にデプロイされたもので)、署名シークレットを設定したエンドポイントに 3 件のリクエストを送りました:
| リクエスト | 期待した結果 | 実際の結果 |
|---|---|---|
| 正しい署名 | 200 | 200 |
| 間違った署名 | 401 | 401 |
| 署名なし | 401 | 200 |
署名のないリクエストが、保護されているように見えたエンドポイントをすんなり通り抜けました。原因は暗号のバグではなく、ポリシーの穴でした。送信元が generic の App(Webhook の送信元ごとに作る受信設定)は、設計上、検証を省きます(「URL が欲しいだけ」という用途のため)。そして、シークレットを設定しても App は generic のままでした。シークレットは何も検証しないまま置かれ、API はその保存に成功を返していました。
自己整合的なユニットテストをいくら重ねても、これは見つかりません。どのユニットも正しかったからです。修正として、誤解を招く設定そのものを拒否するようにしました:generic の App に署名シークレットを設定すると、いまは 400 が返り、代わりにどのフィールドを設定すべきかを示します。
チェックリスト
- SDK に署名関数があるプロバイダーはすべて、両方向で突き合わせます(正しいものは通り、改ざんしたものは失敗する)
- それ以外は、署名付きの実際の配信を 1 件キャプチャし、フィクスチャとして固定します
- どのプロバイダーにどのレベルの裏付けがあるかを、見える形で台帳に残します
- デプロイ済みのシステムを、間違った署名だけでなく、署名がない状態でもテストします。抜け穴は数学ではなく、ポリシーにあります