AnyHook の受信を担っているのは Cloudflare Worker です。サービスが受信する Webhook はすべて、まずここに届きます。そこで署名を検証され、永続化され、送信元には約 1 秒で 200 が返ります。その時間の大半は、キューのあるリージョンとの往復です。本番環境でしばらく動かしてきましたが、厄介だったのは予想していたところではありませんでした。
この記事は、「Vercel で Webhook を取りこぼさずに受信する」と対になる、受信側の記事です。障害の起こり方は本当に違い、そのほとんどは Workers が Node ではないことに由来します。
TL;DR
node:cryptoはデフォルトでは使えません。署名検証は WebCrypto で行い、これは非同期です- どこでも同じですが、ボディは何かがパースする前に、バイト列として一度だけ読みます
- ペイロードのサイズ上限は、ストリーミング中に自分で強制します。大きなボディを読み切ってから大きいと気づくのは、順序が逆です
ctx.waitUntilはリクエストをレスポンスの後まで延ばしますが、Vercel のものとまったく同じく、永続的ではありません- Workers Free プランでは CPU 時間の上限が小さく、経過時間とは別に数えられます。遅い API を await しても消費しませんが、大きなペイロードに対するタイトなループは消費します
- Worker は応答と永続化には最適な場所で、ビジネスロジックには向かない場所です
署名検証は非同期で、制御フローが変わる
Node では同期的な検証関数を書いて、早い段階で呼び出します。WebCrypto のメソッドは Promise を返すので、同じロジックが非同期になり、ルーティングのためにボディをパースした後で await したくなります。そうしないでください。検証は、ペイロードを信頼するどの処理よりも先に行います。
以下は、保存してあるプロバイダーのシークレットを復号してから検証に使うときに、AnyHook の受信 Worker が使っている形です。すべて crypto.subtle です:
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(envKey.padStart(32, "0").slice(0, 32)),
"AES-GCM",
false,
["decrypt"],
);
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv, tagLength: 128 },
keyMaterial,
dataToDecrypt,
);
痛い目を見てから知ると時間を取られる細かい点が 2 つあります。
WebCrypto の AES-GCM は認証タグが暗号文の末尾に付いていることを前提にしますが、Node の createDecipheriv は setAuthTag で別々に受け取ります。Node で暗号化して Worker で復号するなら、自分で連結する必要があります。それを教えてくれるものは何もなく、ただ OperationError が返ってくるだけです。
WebCrypto には標準の timingSafeEqual がありません。HMAC の検証では、ダイジェストを計算して文字列を比較するのではなく、定数時間で比較してくれる crypto.subtle.verify を使ってください。
nodejs_compat を有効にすれば node:crypto のシムが使えるので、単純な HMAC なら手っ取り早い方法です。ただし、シムは同じプリミティブの上にかぶせた互換レイヤーにすぎません。WebCrypto を直接使って書けば、検証コードは Deno、Bun、ブラウザーでもそのまま動きます。
ボディはバイト列で読み、大きすぎたら早めに止める
生のリクエストボディのルールはどこでも同じです:HMAC は届いたバイト列そのものにかかっているので、検証する前に何もパースしないでください。まず await request.text() か await request.arrayBuffer() です。
エッジの受信側に特有なのは、URL を知っているインターネット上の誰に対しても玄関口になることです。いくらでも大きなボディをバッファしてから要らないと判断していたら、他人のトラフィックの料金を払うことになります。AnyHook の受信 Worker は最初に content-length を確認し、ストリーミング中にもう一度上限を強制します。content-length は事実ではなく、クライアントの申告にすぎないからです:
const contentLength = request.headers.get("content-length");
if (contentLength && parseInt(contentLength, 10) > limitBytes) return null;
const reader = request.body?.getReader();
if (!reader) return new ArrayBuffer(0);
const chunks: Uint8Array[] = [];
let totalBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > limitBytes) {
reader.cancel(); // stop pulling bytes we have decided to reject
return null;
}
chunks.push(value);
}
まねする価値があるのは reader.cancel() の部分です。これがないと、拒否すると決めたペイロードを最後までダウンロードしてしまいます。
いちばん安いチェックを最初に
エッジの受信側は公開エンドポイントで、チェックの順序はセキュリティの判断であると同時に、コストの判断でもあります。
AnyHook の順序はこうです:名乗っているプロバイダーが必ず送るヘッダーがないリクエストを拒否し、シークレットが設定されていれば署名を検証し、それからイベントをクォータに数えます。Stripe の Webhook を名乗りながら Stripe-Signature ヘッダーのないリクエストは、暗号処理が何ひとつ動く前に、何かが数えられる前に拒否されます。正しいヘッダーを持たない攻撃者に消費させられるものは、ほぼありません。
この順序が機能するのは、安いチェックが本当に安い場合だけです。ヘッダーの有無はマップの参照です。署名検証は鍵のインポートと HMAC です。クォータは KV の読み取りです。この順に並べれば、高くつく処理は、それに値するトラフィックにしか実行されません。
そのすべての土台に、IP ごと、App(Webhook の送信元ごとに作る受信設定)ごとのレート制限があります。さらに、存在しないスラッグに対する短時間のネガティブキャッシュを置いて、有効なエンドポイントを探すスキャンが請求額に化けないようにしています。
改めて、waitUntil はキューではない
ctx.waitUntil は、バックグラウンドの処理が終わるまで、レスポンスの後も Worker を生かしておきます。本当に便利ですが、永続性ではありません。
isolate が破棄されたり、リクエストが途中でキャンセルされたりすると、その処理は消えます。リトライも、デッドレターも、記録もありません。しかもすでに 200 を返しているので、送信元はイベントを配信済みとみなし、二度と送ってきません。そのイベントは、もうどこにも存在しません。
これは Vercel の waitUntil とまったく同じ落とし穴で、同じルールが当てはまります:応答する前に永続化してください。イベントを永続的な場所に書き込み、200 を返し、それから処理します。
AnyHook が waitUntil を使うのは、カウンターの加算のように、失っても困らない処理だけです。破棄のタイミングで拒否数のカウンターが 3 少なく記録されても、誰も困りません。イベントのペイロードが失われたら、誰かが困ります。
しかも時間が短いのです。Cloudflare はレスポンスの 30 秒後に waitUntil の処理をキャンセルし、その時間は渡したすべての Promise で共有されます。モデルの呼び出しだけでも、それを超えることがあります。各プラットフォームで代わりに何を使うべきかは、「Webhook ハンドラーが LLM を呼ぶ」で扱っています。
失ってはならないものには、キュー、永続的なストア、またはその両方を使います。AnyHook の受信 Worker はマネージドキューにパブリッシュし、それが失敗したらイベントを R2 にスプールし、それも失敗したら Postgres に退避させます。3 つすべてが失敗したら、200 ではなく 503 を返します。そうすれば、送信元自身のリトライが最後の防衛線になります。この判断ははっきり書いておく価値があります:どこにも保存できなかったイベントに 200 を返すのは嘘で、その代償としてイベントを失います。
CPU 時間は経過時間ではない
Workers が課金し、制限するのは CPU 時間で、リクエストが待っている時間ではありません。両者が同じものである Lambda から来た人は、ここで混乱します。
データベースへの書き込みや上流の API を await しても、CPU は消費しません。2 MB の JSON ペイロードのパースは消費します。大きなボディのハッシュ計算も消費します。10,000 要素の配列に対するループも消費します。
Webhook の受信側にとっては、これはおおむね良い知らせです。もともと目指す形は、ほぼすべてが I/O だからです:バイト列を読み、検証し、書き込み、応答する。足をすくわれるのは、深く考えずに足してしまう処理です。保存するだけでよかったペイロードへの JSON.parse や、ボディ全体にかける正規表現などです。
ヘッダーでルーティングしてバイト列を永続化するだけなら、ボディは一切パースしないでください。バイト列のまま保存し、十分な CPU 時間がある下流のコンシューマーにパースを任せます。
エッジに置くべきもの、置くべきでないもの
本番環境で運用してみて、その線引きは最初よりはっきりしました。
Worker に向いているもの:送信元の近くでの TLS 終端、ゴミの拒否、署名検証、ペイロードの永続化、キューへのパブリッシュ、すばやい 200 の応答。どれも I/O バウンドで、安く、呼び出し元の近くにあることが本当に効く、レイテンシー重視の処理です。
Worker に向いていないもの:ビジネスロジック。長時間維持するコネクションプール、大きな依存関係ツリー、重い CPU 処理、Node のエコシステムを必要とするもの全般です。動かせないからではありません。何の得もないのにプラットフォームと戦うことになるからです。受信側をエッジに置く意味は、高くつく部分が別の場所で、それ自身のスケジュールで実行されることにあります。
エッジで応答し、非同期で配信するというこの分担は、後から考えた理屈ではなく、AnyHook がその上に作られているアーキテクチャそのものです。
知っておきたいデプロイの落とし穴
1 つの wrangler.toml で複数の環境を管理している場合、--env production を付けずに wrangler deploy を実行すると、デフォルトの環境にデプロイされます。本番の Worker は以前のコードを返し続け、エラーは何も出ません。デプロイ自体は本当に成功しているからです。意図した場所ではなかっただけです。
AnyHook はこれで午後を丸ごと失いました。シークレットも同じです:wrangler secret put NAME --env production としないと、トラフィックを受けていない場所に設定してしまいます。
まとめ
Worker は、その性質を受け入れる限り、とても良い Webhook の受信先です:Node がなく、waitUntil に永続性がなく、不要なパースには CPU 時間の制限で罰が下る、I/O バウンドの玄関口です。
何かがパースする前にバイト列を読み、ペイロードが上限を超えたらストリームをキャンセルし、安い拒否を高い拒否より先に並べ、永続的に書き込んでいないものには決して 200 を返さないでください。それ以外はすべて、時間のある下流で行います。