CyberFix Note
セキュアコーディング

Webhook受信エンドポイントのセキュリティ設計。署名検証とリプレイ対策から送信側のSSRFまで

対象の目安: Web APIを開発運用するエンジニア / 実務

ソウ攻撃・脆弱性リサーチ担当
・ 約62分で読めます
Webhook受信エンドポイントのセキュリティ設計。署名検証とリプレイ対策から送信側のSSRFまで

決済が成立した、リポジトリにpushがあった、動画の変換が終わった。こうした知らせを外部サービスから受け取る口がWebhookの受信エンドポイントです。作りは単純で、公開されたURLでPOSTを受け、JSONを読み、自分のデータベースを更新します。

この単純さの裏に一つの前提が置かれています。届いたPOSTが本当にそのサービスから来たものだ、という前提です。ログイン状態もセッションCookieもありません。HTTP認証やmTLS、送信元IPの制限を併用できるサービスもありますが、連携先をまたいで使える共通の方式はなく、多くはサービス固有の署名ヘッダが唯一の手掛かりになります。URLさえ知っていれば誰でもリクエストを投げられます。Stripeの公式ドキュメントは、この状態を放置した場合に何が起きるかを直接的に書いています。認証を行わないと、攻撃者が偽のwebhookイベントをエンドポイントへ送信し、注文の処理、アカウントアクセスの許可、レコードの変更などを不正に実行される可能性がある、というものです。

この記事はWeb APIを開発運用するエンジニアに向けて、Webhookに固有の論点だけを扱います。受信側では署名の検証方法とリプレイ対策、署名の先にある認可の穴、再送と順序の扱い、鍵のローテーション。送信側では登録URLの検証とSSRF対策、リトライの設計。Stripe、GitHub、Slack、Shopify、Twilioの公式ドキュメント、Standard Webhooksの仕様と参照実装、RFC 9421とRFC 9530、OWASPの資料、AWSのセキュリティブログ、Node.jsとPythonの標準ライブラリ文書を一次情報として使い、確認できた範囲だけを書きます。記述は執筆時点(2026年9月5日)に参照できた版に基づきます。

Webhookエンドポイントが通常のAPIと違うところ

自社のAPIであれば、リクエストにはアクセストークンかセッションが付き、それを検証すれば呼び出し元が誰かは決まります。Webhookの受信口にはそれがありません。差分を並べると設計上の勘所が見えてきます。

観点通常のAPIエンドポイントWebhook受信エンドポイント
呼び出し元の識別アクセストークンやセッション主にヘッダの署名(HTTP認証やmTLSを併用できる場合もある)
呼び出し元の主体自社のクライアント外部サービスのサーバ
リクエストの発生源利用者の操作外部で起きた事象
到達性認証で絞られるURLを知れば誰でも到達
再送クライアント任せ送信側の方針しだい(自動再送なしの例もある)
順序利用者の操作順に近い保証されない
CSRF対策トークンで防ぐ除外設定が必要になる

最後の行は見落とされがちな点です。RailsやDjangoのようにPOSTへ自動でCSRFトークンを要求するフレームワークでは、Webhookのルートを保護の対象から外さないとリクエストが弾かれます。Stripeの公式ドキュメントもこの除外設定を案内しています。ここで外すのはCSRFトークンの検証であって、署名の検証はその代わりに必ず入れます。両方を外すと、URLを知っている誰でも状態を変更できる口が残ります。CSRFトークンはブラウザの利用者になりすました送信を防ぐ仕掛けであり、サーバ間の通信で本人性を確かめる役には立たないため、代替として署名が要ります。

もう一つの違いは副作用の大きさです。Webhookが起点になる処理は、入金の確定、サブスクリプションの有効化、メール送信、外部への出金など、後戻りが難しいものが並びます。読み取り専用のAPIと違い、偽のリクエストが1本通っただけで実害が出ます。入口の検証を通ったかどうかで処理を止める設計にしておく価値は、この副作用の重さに見合います。

共有シークレットによるHMAC署名が土台になる

現行の主要サービスが採る方式は、送信側と受信側で共有した秘密を鍵にしたHMACです。ハッシュ関数はSHA-256が標準的で、送信側はリクエストのたびに署名を計算してヘッダへ入れ、受信側は同じ手順で計算した値と突き合わせます。

Standard Webhooksの仕様は、対称鍵方式の署名についてHMAC-SHA256を指定し、鍵の長さを24バイト(192ビット)から64バイト(512ビット)の乱数と定めています。シリアライズはbase64で、利用者へ提示する際にwhsec_という接頭辞を付けて識別できるようにする、という決めもあります。接頭辞は見た目の話に見えますが、リポジトリやログに流出した文字列を検出する仕組みを作るときに効いてきます。

Standard Webhooksの仕様書は、対称署名の方式をHMAC-SHA256、署名鍵を24バイト(192ビット)から64バイト(512ビット)の乱数、シリアライズをbase64エンコードしてwhsec_を接頭辞に付ける形と定めています。非対称署名の方式はed25519で、秘密鍵にwhsk_、公開鍵にwhpk_の接頭辞を付けます。署名識別子は対称がv1、非対称がv1aです。追加の考慮事項として、対称署名では署名鍵をエンドポイントごとに固有にすること、非対称署名でもエンドポイント単位ないし顧客単位で固有にすることを求め、顧客をまたいで鍵を再利用するとセキュリティ上の問題につながると明記しています。また、対称署名より非対称署名を優先することを推奨し、送信側と受信側の双方の安全性を自分で管理できない場合は非対称署名の利用を勧めています。

鍵をエンドポイントごとに分けるという要求は、後述するテナント取り違えの問題に直結します。全顧客で同じ署名鍵を使うと、署名の検証を通ったという事実からは「このプラットフォームが送った」ことしか分からず、「どの顧客向けのイベントか」は分かりません。

非対称方式の位置づけも押さえておく価値があります。HMACは検証にも同じ秘密が必要なので、受信側が鍵を漏らせば偽のイベントを作られます。ed25519のような署名では受信側が持つのは公開鍵だけなので、受信側の漏えいで偽造されることはありません。Standard Webhooksが非対称を優先するよう書いているのはこの理由です。ただし現実の主要サービスはHMACが大半で、受信側が選べる話ではありません。自社がWebhookを送る立場を設計するときの選択肢として意識しておく形になります。

署名は暗号化ではありません。ボディの中身は平文で流れます。Standard Webhooksの仕様も、署名方式はペイロードの真正性と正当性をカバーするが暗号化はしないため、盗聴によって中身を見られる可能性があるとしてHTTPSの強制を挙げています。個人情報や決済情報をペイロードへ載せる設計なら、TLSは前提として、そもそも識別子だけを送ってAPIで本体を取りに行く形も検討に値します。

生のリクエストボディで検証しなければならない理由

署名の検証で最初に落ちる罠がここにあります。ボディをJSONとしてパースし、オブジェクトを再びJSON文字列へ戻して署名対象にする実装です。

// 危険な実装: パース済みオブジェクトを再シリアライズして検証している
app.use(express.json());

app.post("/webhooks/example", (req, res) => {
  const body = JSON.stringify(req.body); // 送信されたバイト列とは別物になりうる
  const expected = crypto
    .createHmac("sha256", secret)
    .update(body)
    .digest("hex");
  if (expected === req.get("x-signature")) {
    // 比較もタイミングセーフではない
  }
});

このコードは動くこともあります。送信側とキーの並び順、空白の入れ方、数値やUnicodeのエスケープ方式がたまたま一致していれば通ります。しかし一致は保証されません。Standard Webhooksの仕様は、送信するペイロードと署名したペイロードが同一であることを確認するよう求め、暗号署名は最小の変化にも反応するため空白1つで署名が無効になるとしています。そのうえで、受信側がボディをJSONとしてパースし再度シリアライズしてしまうことが、よくある失敗の形だと指摘しています。JSONのシリアライズは実装ごとに、場合によっては同じ実装の呼び出しごとに同じ結果になるとは限らないためです。

Stripeの公式ドキュメントも同じ点を強調します。署名の検証には未加工のリクエスト本文が必要であり、フレームワークを使っている場合は元の本文に手が加えられないようにしなければならない、未加工のリクエスト本文に何らかの変更が行われた場合は検証に失敗する、という記述です。Shopifyの検証手順も、HMACの検証には生のリクエストボディが必要で、express.json()のようなボディパーサのミドルウェアを使うと検証コードが動く前にボディがパースされてしまうと注意しています。

GitHubの検証手順には文字コードの注意があります。言語やサーバの実装が文字エンコーディングを指定する場合は、ペイロードをUTF-8として扱うこと。Webhookのペイロードにはユニコード文字が含まれうるためです。

GitHubの「Validating webhook deliveries」は、シークレットトークンから作ったハッシュ署名をX-Hub-Signature-256ヘッダの値として各配送に付けると説明しています。ハッシュはHMACの16進ダイジェストで計算され、署名は必ずsha256=で始まります。検証手順としては、自分でハッシュを計算し、GitHubが送ってきたハッシュと一致するか確認するよう求めています。比較については「素の等値演算子を決して使わないでください」として、Rubyのsecure_compareやNode.jsのcrypto.timingSafeEqualのようなメソッドの利用を挙げています。ペイロードはUTF-8として扱うことも明記されています。SHA-1で計算されるX-Hub-Signatureヘッダについては、後方互換のためだけに存在するとしています。

実装としては、Webhookのルートだけボディパーサを外し、バイト列のまま受け取る形にします。Expressならexpress.raw()を該当ルートにだけ適用し、検証を通してからJSON.parseします。Next.jsのApp Routerならawait request.text()で生の文字列を取り、Flaskならrequest.get_data()を使います。

経路上でボディが書き換わる可能性にも目を向けておきます。リバースプロキシやWAF、APIゲートウェイが文字コードを変換したり、圧縮を展開したり、ボディを整形したりすると署名は合わなくなります。署名検証が本番だけ落ちる現象の多くはここが原因です。検証はできるだけ入口に近い層で行い、途中の中継がボディへ触らないことを構成として確認しておきます。

タイムスタンプを署名の対象に含めてリプレイを防ぐ

署名がボディだけを対象にしている場合、通信を傍受できる立場の攻撃者、あるいは何らかの理由で過去のリクエストを入手した攻撃者は、同じボディと同じ署名をそのまま再送できます。署名は正しいので検証は通ります。これがリプレイです。

対策は、時刻を署名の対象に入れて、受信側で鮮度を確かめることです。StripeはStripe-Signatureヘッダにt=で始まるタイムスタンプとv1=で始まる署名を並べて送ります。署名対象はタイムスタンプの文字列、ピリオド1個、実際のJSONペイロードを連結した文字列で、これをエンドポイントの署名シークレットを鍵にHMAC-SHA256にかけたものです。タイムスタンプが署名の一部になっているため、攻撃者は署名を壊さずにタイムスタンプだけを差し替えることができません。

signed_payload = timestamp + "." + raw_request_body
expected_signature = HMAC_SHA256(key = endpoint_secret, message = signed_payload)

SlackはX-Slack-SignatureX-Slack-Request-Timestampの2本のヘッダを使い、バージョン文字列とタイムスタンプとボディをコロンで連結した文字列を署名対象にします。ハッシュはSHA-256で、結果の16進表現にv0=を付けた形になります。

basestring = "v0:" + timestamp + ":" + raw_request_body
expected_signature = "v0=" + hex(HMAC_SHA256(key = signing_secret, message = basestring))

許容範囲の決め方はサービスによって違います。Stripeのライブラリはタイムスタンプと現在時刻の間に5分の既定の許容範囲を持ち、検証時に追加のパラメータを渡して変更できます。公式ドキュメントは許容値0を使わないよう明示しています。0にすると最新性のチェックが完全に無効になるためです。Slackの検証手順は、リクエストのタイムスタンプがローカル時刻から5分以上離れている場合をリプレイ攻撃の可能性ありとして無視する例を示しています。Standard Webhooksはwebhook-timestampが現在時刻から許容できる範囲内にあることを確認するよう求めるだけで、具体的な秒数は決めていません。

Stripeの「Receive Stripe events in your webhook endpoint」は、Stripe-Signatureヘッダにタイムスタンプ(接頭辞t)と1つ以上の署名(接頭辞はスキーム、現在有効な本番のスキームはv1のみ)が含まれると説明しています。手動検証の手順は、ヘッダをカンマで分割して要素を取り出し、タイムスタンプ、ピリオド、実際のJSONペイロードを連結したsigned_payloadを作り、エンドポイントの署名シークレットを鍵としてSHA-256のHMACを計算し、タイミング攻撃から保護するために一定時間の文字列比較でヘッダ内の署名と突き合わせる、というものです。ダウングレード攻撃を防ぐためv1以外のスキームはすべて無視するよう求めています。リプレイ対策の項では、ライブラリの既定の許容範囲がタイムスタンプと現在時刻の間で5分であること、許容値0は最新性チェックを完全に無効にするため使わないこと、NTPでサーバのクロックを正確に保つことを記載しています。Stripeがイベントを再試行する際には、新しい配信試行に対して新しい署名とタイムスタンプを生成するとも述べています。

許容範囲を決めるときに効いてくるのがクロックのずれです。受信側のサーバ時刻が5分以上ずれていれば、正規のリクエストが全部落ちます。StripeがNTPでの時刻同期を案内しているのはこのためです。コンテナ環境ではホスト側の時刻同期が効いているかを確認しておきます。

すべての方式がタイムスタンプを持つわけではありません。GitHubのX-Hub-Signature-256はリクエストボディのHMACだけで、署名の対象に時刻は入りません。この場合、受信側で鮮度を判定する材料がないため、リプレイ対策は次に述べる冪等性の側で受け止めることになります。GitHubは配送ごとに一意なGUIDをX-GitHub-Deliveryヘッダで送っており、これを処理済み判定に使う形になります。

署名の比較にタイミングセーフな関数を使う

計算した期待値と受け取った署名の比較を、素の等値演算子で書くと問題が起きます。多くの言語の文字列比較は先頭から1バイトずつ照合し、違いが見つかった時点で処理を打ち切ります。すると一致した先頭バイト数に応じて処理時間がわずかに変わります。攻撃者が同じボディで署名を少しずつ変えながら大量に送ると、この時間差から正しい署名を1バイトずつ絞り込める余地が生まれます。

GitHubの公式ドキュメントは、素の等値演算子を決して使わず、Rubyのsecure_compareやNode.jsのcrypto.timingSafeEqualのようなメソッドを使うよう書いています。同ドキュメントの言語別サンプルでは、Pythonにhmac.compare_digestが使われています。Standard Webhooksの仕様は、対称署名の検証で一定時間の比較関数を使わないと受信側がタイミング攻撃にさらされ、署名オラクルに変わってしまうと述べています。

Node.jsの公式ドキュメントはcrypto.timingSafeEqual(a, b)について、与えられたインスタンスが表す内部のバイト列を一定時間のアルゴリズムで比較する関数であり、攻撃者が値の一方を推測できるようなタイミング情報を漏らさないと説明しています。HMACダイジェストや認証Cookieのような秘密値の比較に適する、という位置づけです。同時に、abは同じバイト長でなければならず、バイト長が異なる場合はエラーが投げられると明記されています。署名ヘッダは攻撃者が自由に作れるので、長さの検査を先に入れて例外を握りつぶさない実装にします。

Pythonのhmac.compare_digest(a, b)は、内容に依存した短絡評価を避けることでタイミング解析を防ぐ設計だと文書化されています。注意書きも付いています。abの長さが異なる場合やエラーが発生した場合、タイミング攻撃が理論上abの型と長さについての情報を明かす可能性はあるが、値そのものは明かさない、というものです。

Node.jsの公式ドキュメントはcrypto.timingSafeEqual(a, b)について「この関数は、与えられたArrayBuffer、TypedArray、DataViewのインスタンスが表す内部のバイト列を、一定時間のアルゴリズムを使って比較します」「この関数は、攻撃者が値の一方を推測できるようなタイミング情報を漏らしません。これはHMACダイジェストや、認証Cookieやcapability URLのような秘密値の比較に適しています」と記載しています。加えて「abはどちらもBuffer、TypedArray、DataViewでなければならず、同じバイト長でなければなりません。abのバイト長が異なる場合はエラーが投げられます」と明記されています。

比較そのものを一定時間にしても、その前後の処理から情報が漏れる余地は残ります。署名が合わないときだけ重い処理をする、失敗の種類ごとに違うエラーメッセージを返す、といった実装は避けます。検証に失敗したリクエストへの応答は、理由を区別しない短いエラーで統一するのが素直です。

主要サービスの署名方式を並べて比べる

受信側の実装を複数サービス分抱えると、方式の違いが混乱のもとになります。公式ドキュメントで確認できた範囲を表にします。

サービス署名ヘッダアルゴリズム署名の対象エンコード時刻の扱い
StripeStripe-SignatureHMAC-SHA256タイムスタンプ + ピリオド + 生ボディ16進ヘッダ内t=。ライブラリ既定の許容範囲は5分
GitHubX-Hub-Signature-256HMAC-SHA256生ボディ16進(sha256=接頭辞)署名対象に含まない
SlackX-Slack-SignatureHMAC-SHA256v0 + タイムスタンプ + 生ボディをコロン連結16進(v0=接頭辞)X-Slack-Request-Timestampヘッダ
ShopifyX-Shopify-Hmac-SHA256HMAC-SHA256生ボディbase64署名対象に含まない
TwilioX-Twilio-SignatureHMAC-SHA1完全なURL + パラメータbase64署名対象に含まない
Standard Webhookswebhook-signatureHMAC-SHA256(対称)またはed25519(非対称)ID + タイムスタンプ + 生ボディをピリオド連結base64(v1,またはv1a,接頭辞)webhook-timestampヘッダ

Twilioだけ性質が違う点に注意が必要です。Twilioの公式ドキュメントによれば、署名はアカウントのauth tokenを鍵にしたHMAC-SHA1で、対象はWebhookのURLとリクエストのパラメータです。フォームエンコードのリクエストではパラメータをアルファベット順に並べて連結し、JSONボディの場合はTwilioがbodySHA256というクエリパラメータに生のJSONボディのSHA-256ハッシュを付けます。URLが署名対象に入るため、ロードバランサやリバースプロキシの背後でホスト名やスキームを再構成している環境では、Twilioが実際に叩いたURLと受信側が組み立てたURLが食い違って検証に失敗します。同ドキュメントはTwilioから来た正確なURLをURLエンコードされた文字も含めてそのまま使うよう求め、デコードや再エンコードをすると検証に失敗すると述べています。

Stripeの表の行にはもう一つ補足があります。Stripe-Signatureにはテスト支援のためにv0という別スキームの署名が入ることがあり、公式ドキュメントはダウングレード攻撃を防ぐためv1以外のスキームをすべて無視するよう求めています。ヘッダをパースするコードを自作する場合、スキーム名の照合を省いて「入っている署名のどれかと一致すればよい」と書くと、この指示に反する実装になります。

Standard Webhooksの署名対象には、ボディの前にメッセージIDとタイムスタンプがピリオド区切りで入ります。仕様書は、メッセージIDとタイムスタンプが利用者の制御下に置かれないこと、少なくともピリオドを含めないようにすることを求めています。区切り文字を値の側に入れられると、連結後の文字列が別の解釈を許してしまうためです。自前の署名方式を設計するときにも同じ配慮が必要になります。

Node.jsとPythonで書く検証処理

Standard Webhooksの形式を例に、受信側の検証を書き下します。順序は、時刻の鮮度、署名の一致、そのあとにJSONのパース、という並びにします。パースを先にすると、検証を通っていないデータでパーサを動かすことになります。

書き始める前に、鍵の作り方で一つ決めておくことがあります。Standard Webhooksで利用者へ提示されるシークレットは、乱数バイト列をbase64エンコードしてwhsec_を接頭辞に付けた表現です。HMACの鍵になるのは接頭辞を外してbase64デコードしたバイト列であり、提示された文字列そのものではありません。仕様の参照実装も、接頭辞の長さぶんを切り落としてからbase64デコードした結果を鍵として保持しています。ここを取り違えて環境変数の文字列をそのまま鍵にすると、送信側の署名と一致せず全イベントが検証に落ちます。サービスによって扱いが違う点にも注意が要ります。Stripeの公式ライブラリは、エンドポイントの署名シークレットをwhsec_を含む文字列のままUTF-8バイト列にしてHMACの鍵に使います。連携先ごとに、提示された文字列をそのまま使うのか、デコードした結果を使うのかを公式ライブラリの実装で確かめてから書きます。

import crypto from "node:crypto";
import express from "express";

const app = express();

// ローテーション中は複数本の秘密を並行して受け付ける
// Standard Webhooks のシークレットは whsec_ 付きの base64 表現で提示される。
// HMAC の鍵になるのは接頭辞を外して base64 デコードしたバイト列
const SECRETS: Buffer[] = (process.env.WEBHOOK_SECRETS ?? "")
  .split(",")
  .filter(Boolean)
  .map((s) => Buffer.from(s.replace(/^whsec_/, ""), "base64"));

const TOLERANCE_SECONDS = 300;

app.post(
  "/webhooks/example",
  // 検証は生バイト列に対して行うため、JSONパーサを通さない
  express.raw({ type: "application/json", limit: "64kb" }),
  async (req, res) => {
    // Content-Type が一致しないと express.raw はボディを読まず、req.body が
    // Buffer にならない。以降の処理が例外で 500 を返さないよう先に弾く
    if (!Buffer.isBuffer(req.body)) {
      return res.status(400).send("invalid request");
    }
    const rawBody = req.body;
    const id = req.get("webhook-id") ?? "";
    const timestamp = req.get("webhook-timestamp") ?? "";
    const header = req.get("webhook-signature") ?? "";

    // 0. 3つのヘッダの存在と形式を先に確認する。とくに ID は冪等性キーに使うため、
    //    空のまま通すと別々のイベントが同じキーとして扱われる
    if (!id || id.length > 128 || !timestamp || !header) {
      return res.status(400).send("invalid request");
    }

    // 1. 時刻の鮮度を先に確認する
    const ts = Number.parseInt(timestamp, 10);
    const now = Math.floor(Date.now() / 1000);
    if (!Number.isInteger(ts) || Math.abs(now - ts) > TOLERANCE_SECONDS) {
      return res.status(400).send("invalid request");
    }

    // 2. 署名対象を組み立てる。ID とタイムスタンプもボディと一緒に署名されている
    const signedPayload = Buffer.concat([
      Buffer.from(id + "." + timestamp + ".", "utf8"),
      rawBody,
    ]);

    // 3. 有効な秘密ごとに期待値を計算する
    const expected = SECRETS.map((secret) =>
      crypto.createHmac("sha256", secret).update(signedPayload).digest(),
    );

    // 4. ヘッダ内の署名は空白区切り。v1 のものだけを対象にする
    const received = header
      .split(" ")
      .filter((part) => part.startsWith("v1,"))
      .map((part) => Buffer.from(part.slice(3), "base64"));

    // timingSafeEqual は長さが違うと例外を投げるので先に長さを見る
    const ok = received.some((r) =>
      expected.some(
        (e) => r.length === e.length && crypto.timingSafeEqual(r, e),
      ),
    );
    if (!ok) {
      return res.status(400).send("invalid request");
    }

    // 5. 検証を通ってから初めてパースする
    let event: unknown;
    try {
      event = JSON.parse(rawBody.toString("utf8"));
    } catch {
      return res.status(400).send("invalid request");
    }

    // 6. 冪等性キーで二重処理を防ぎ、重い処理はキューへ回す
    //    キューへの投入が確定してから200を返す(投入前に落ちると送信側は成功と見なす)
    try {
      await enqueueOnce(id, event);
    } catch {
      return res.status(500).send("enqueue failed");
    }
    return res.status(200).send("ok");
  },
);

Pythonでも構造は変わりません。

import base64
import hashlib
import hmac
import json
import os
import time

from flask import Flask, request

app = Flask(__name__)


def load_secret(value: str) -> bytes:
    # whsec_ 接頭辞を外して base64 デコードしたバイト列が HMAC の鍵になる。
    # パディングが省かれている場合に備えて "==" を足す(余分な分は無視される)
    return base64.b64decode(value.removeprefix("whsec_") + "==")


SECRETS = [
    load_secret(s) for s in os.environ["WEBHOOK_SECRETS"].split(",") if s
]
TOLERANCE_SECONDS = 300


@app.post("/webhooks/example")
def receive():
    raw_body = request.get_data()  # パース前の生バイト列
    msg_id = request.headers.get("webhook-id", "")
    timestamp = request.headers.get("webhook-timestamp", "")
    header = request.headers.get("webhook-signature", "")

    try:
        ts = int(timestamp)
    except ValueError:
        return "invalid request", 400
    if abs(int(time.time()) - ts) > TOLERANCE_SECONDS:
        return "invalid request", 400

    signed = msg_id.encode() + b"." + timestamp.encode() + b"." + raw_body
    expected = [
        base64.b64encode(hmac.new(secret, signed, hashlib.sha256).digest())
        for secret in SECRETS
    ]
    received = [
        part.split(",", 1)[1].encode()
        for part in header.split(" ")
        if part.startswith("v1,")
    ]

    # compare_digest は内容依存の短絡評価を避ける
    if not any(
        hmac.compare_digest(r, e) for r in received for e in expected
    ):
        return "invalid request", 400

    event = json.loads(raw_body.decode("utf-8"))
    enqueue_once(msg_id, event)
    return "ok", 200

Pythonの公式ドキュメントはhmac.compare_digest(a, b)について「a == bを返します。この関数は内容に基づく短絡評価の挙動を避けることでタイミング解析を防ぐ手法を用いており、暗号用途に適しています」と説明しています。注記として「abの長さが異なる場合やエラーが発生した場合、タイミング攻撃は理論的にはabの型と長さに関する情報を明かしうるものの、それらの値については明かしません」と記載されています。digest()hexdigest()の項では、検証処理で外部から与えられたダイジェストと比較する際、タイミング攻撃に対する脆弱性を減らすため==演算子ではなくcompare_digest()を使うことが推奨されています。

エラー応答をすべて同じ文言にしている点は意図的です。「タイムスタンプが古い」「署名が違う」「IDが空」と区別して返すと、攻撃者に検証ロジックの内部を教えることになります。運用に必要な情報はレスポンスではなくサーバ側のログへ残します。

署名が正しいだけでは認可が済んでいない

ここからが、署名の話だけを読んで実装した受信エンドポイントに残りやすい穴です。

マルチテナントのSaaSを考えます。自社サービスは各テナントの決済事業者アカウントと連携し、決済完了のWebhookを1本のエンドポイントで受けています。署名鍵はプラットフォーム単位で1本、検証は正しく実装されています。ここで攻撃者がテナントAとして正規に契約し、自分のアカウントで少額の決済を1件行います。決済事業者は署名付きのイベントを自社エンドポイントへ送ります。

このイベントには、支払いの金額、通貨、そして自社が発行した注文IDのような参照情報が入っています。攻撃者が決済時のメタデータへ、テナントBの注文IDを入れていたらどうなるか。署名の検証は通ります。イベントは本物です。それでも、そのイベントが動かしてよいリソースはテナントAのものだけです。受信側がイベント中の注文IDだけを見て注文を「支払い済み」に更新すると、テナントBの注文が他人の少額決済で確定します。

これは署名の問題ではなく認可の問題です。OWASP API Security Top 10のAPI1:2023はオブジェクトレベルの認可の不備を扱っており、オブジェクトのIDを受け取って何らかの操作を行うすべてのAPIエンドポイントで、ログイン中の利用者がそのオブジェクトへの操作権限を持つかを検証する認可チェックを実装すべきだとしています。Webhookの受信エンドポイントには「ログイン中の利用者」がいませんが、署名によって確定する主体、つまりイベントの発生元アカウントが同じ役割を担います。

対策は二段構えになります。

一つは、署名鍵をエンドポイントごと、あるいは顧客ごとに分けることです。Standard Webhooksが対称鍵をエンドポイントごとに固有にするよう求め、顧客をまたいだ鍵の再利用はセキュリティ上の問題につながると警告しているのは、この分離を確保するためです。鍵が分かれていれば、どの鍵で検証が通ったかが発生元の識別になります。

もう一つは、イベントの中で「発生元を示すフィールド」と「操作対象を示すフィールド」を分けて扱うことです。発生元は署名の検証結果か、ペイロード中の連携アカウント識別子から決めます。操作対象は自社のデータベースで引き、その所有者が発生元と一致することを確認してから更新します。Stripeの公式ドキュメントも、組織配下の複数アカウントを扱う場合にイベントのcontextプロパティからアカウントを特定し、そのアカウントに対応するAPIキーで処理する例を示しています。

// 署名検証の後に必要な確認
const order = await db.orders.findById(event.data.object.metadata.order_id);
if (!order) return ack(); // 知らないIDは静かに受理して終わる

// イベントの発生元アカウントと、注文の所有テナントが一致するか
if (order.tenantId !== resolveTenantFromEvent(event)) {
  logger.warn("tenant mismatch on webhook", { orderId: order.id });
  return ack(); // 処理はしない
}

金額や通貨のような値も、イベントの値をそのまま採用せず自社の期待値と突き合わせます。決済完了イベントが示す金額が注文金額と違えば、それは正常系ではありません。

識別子を差し替えられる形の認可不備は、Webhookに限らずAPI全般に出ます。基本の整理は次の記事にあります。

あわせて読みたい

IDORとアクセス制御の不備、識別子を差し替えられても守れる認可の作り方

少なくとも一度は届く前提での冪等性設計

Webhookの配送は、届いたら1回とは限りません。送信側は受信側から2xxが返らなければ再送しますし、受信側が処理を終えた直後にレスポンスを返せず落ちれば、送信側から見れば失敗なので再送されます。結果として同じイベントが複数回届きます。

Stripeの公式ドキュメントは、Webhookエンドポイントが同じイベントを複数回受信する可能性があるとし、処理したイベントIDをログに記録して、すでに記録済みのイベントを処理しないようにすることで対処できると案内しています。加えて、2つのEventオブジェクトが個別に生成されて送られる場合があり、その重複はdata.objectのオブジェクトIDとevent.typeの組み合わせで識別するとしています。

Standard Webhooksはwebhook-idヘッダを冪等性キーとして使い、同じWebhookを複数回処理してしまうことを防ぐよう求めています。仕様書はIDをRedisに5分間保存する例を挙げていますが、この保持期間はそのまま採用できません。Stripeは本番環境で指数バックオフを使い最長3日間、送信先へのイベント配信を試行します。ダッシュボードからの手動再送はイベント作成後15日間、CLIからの再送は30日間有効です。5分のキャッシュでは、3日後に届いた再送を新規イベントとして処理してしまいます。

実務としては、揮発性のキャッシュではなく永続層に記録します。処理済みイベントのテーブルにイベントIDをユニーク制約付きで持ち、処理本体と同じトランザクションで挿入します。

CREATE TABLE processed_webhook_events (
  provider     TEXT        NOT NULL,
  event_id     TEXT        NOT NULL,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (provider, event_id)
);
await db.transaction(async (tx) => {
  const inserted = await tx.insertProcessedEvent(provider, eventId);
  if (!inserted) return; // 一意制約に当たった = 処理済み
  await applyBusinessLogic(tx, event);
});

挿入と業務処理を同じトランザクションに入れる点が要点です。別々にすると、挿入だけ成功して業務処理が失敗した場合にイベントが永久に失われます。逆に業務処理が成功して挿入が失敗すれば二重処理になります。

外部への副作用がある処理では、トランザクションの外に出さざるを得ない部分が残ります。メール送信や外部APIの呼び出しは、送信先が冪等性キーを受け付けるならそれを使い、受け付けないなら送信記録を先に永続化してから実行し、再実行時は記録を見て飛ばします。

冪等性キーの保持期間は、送信側の自動リトライ期間と手動再送が可能な期間のうち、長いほうを上回る長さに取ります。Stripeであれば30日を超える期間、Standard Webhooksの推奨リトライスケジュールに従う実装なら数日分が最低ラインです。あわせて、古い行を削除するバッチも用意しておきます。

順序が保証されない前提での状態遷移

もう一つの前提が順序です。Stripeの公式ドキュメントは、イベントが生成された順序で配信されることを保証しないと明記しています。サブスクリプションの作成でcustomer.subscription.createdinvoice.createdinvoice.paidcharge.createdが生成される例を挙げ、イベントの宛先が特定の順序での受信に依存しないよう求めています。さらに、スナップショットイベントのcreatedは秒単位なので異なるイベントが同じタイムスタンプを持つことがあり、順序の判断や処理済みかどうかの判断にcreatedを使わないよう明記しています。

順序が保証されない世界での実装方針は3つに整理できます。

1つ目は、イベントを「通知」として扱い、真の状態はAPIで取りに行くことです。Stripeの同じ節も、APIを使って不足しているオブジェクトを取得できるとし、invoice.paidを最初に受け取った場合はそこから請求書や支払いやサブスクリプションのオブジェクトを取得できると述べています。イベントのペイロードをそのまま自社の状態にコピーするのではなく、イベントは「何かが変わった」という合図として受け、現在値はAPIから取得して上書きする形です。この設計なら、古いイベントが後から届いても取得結果は最新なので矛盾しません。

2つ目は、更新にバージョンを持たせることです。オブジェクトにリビジョン番号や更新時刻があるなら、自社が保持している値より古い更新は捨てます。

// 自社に記録済みのバージョンより古い更新は無視する
const current = await db.subscriptions.find(subscriptionId);
if (current && current.remoteUpdatedAt >= incoming.updatedAt) {
  return ack();
}

3つ目は、状態機械として後退を拒むことです。「解約済み」になったサブスクリプションが、後から届いた「有効化」イベントで復活してしまう事故は、遷移表を持たない実装で起きます。許可する遷移を明示し、それ以外は記録だけ残して処理しません。

これらは全部を同時にやる必要はありません。金額や権限に関わる処理はAPIで取り直す、表示用のキャッシュはバージョン比較で足りる、といった使い分けで十分に成立します。

無停止でシークレットをローテーションする

署名鍵は秘密です。漏えいすれば、攻撃者は好きなイベントを偽造して送れます。CI環境の環境変数、エラートラッカーに送られたリクエストヘッダ、リポジトリの設定ファイル。漏れる経路はAPIキーと変わりません。

問題は交換の難しさです。送信側と受信側が同時に切り替わらないと、切り替えの瞬間にイベントが落ちます。そこで主要な方式は、一定期間だけ複数の鍵を並行して有効にします。

Standard Webhooksのwebhook-signatureヘッダが署名のリストになっているのは、無停止での鍵ローテーションを支えるためです。仕様書は、現在の鍵と、一定期間だけ残す古い鍵の両方でWebhookに署名し、両方の署名を空白区切りでヘッダに入れると説明しています。受信側はどれかが一致するまで各署名を検証します。

Stripeも同じ考え方です。エンドポイントのシークレットを更新する際、現在のシークレットを直ちに失効させることも、最大24時間まで有効期限を延ばして受信側の検証コードを更新する時間を確保することもできます。その間はエンドポイントに対して複数のシークレットキーが有効になり、Stripeはシークレットキーごとに1つの署名を生成します。

受信側の実装は、先に示したコードのように秘密を配列で持ち、全要素に対して検証を試す形にします。ローテーションの手順としては次の順序になります。

  1. 1

    受信側を複数鍵に対応させる

    秘密を1本の文字列ではなく配列として読み込み、いずれかで検証が通れば受理する実装に変えます。この時点では配列の要素は1本のままで、挙動は変わりません。設定はカンマ区切りの環境変数やシークレット管理サービスの複数バージョンで持ちます。
  2. 2

    送信元サービス側で新しい秘密を発行する

    管理画面やAPIで新しい署名シークレットを作ります。旧シークレットの失効までの猶予を選べる場合は、受信側の反映に必要な時間を見て設定します。Stripeであれば最大24時間まで延ばせます。
  3. 3

    受信側の配列へ新しい秘密を追加する

    旧秘密を残したまま新しい秘密を追加してデプロイします。この状態では、どちらの秘密で署名されたイベントも受理できます。デプロイ後、実際に新しい秘密で署名されたイベントが検証を通っていることをログで確認します。
  4. 4

    旧秘密での検証成功が止まったことを確認する

    どの秘密で検証が通ったかをメトリクスとして記録しておき、旧秘密での成功件数がゼロになるまで待ちます。件数が残っている間に旧秘密を消すと、その分のイベントが落ちます。
  5. 5

    旧秘密を配列から外して破棄する

    受信側の設定から旧秘密を削除してデプロイし、送信元サービス側でも旧シークレットを失効させます。削除した日時と担当者を記録に残します。
  6. 6

    定期ローテーションの予定に組み込む

    漏えい時の緊急交換だけでなく、期間を決めた定期交換の予定を作ります。手順が動くことを平時に確認しておくと、緊急時に迷いません。

秘密そのものの置き場所と配布は、APIキーの管理と同じ課題です。整理は次の記事にあります。

あわせて読みたい

シークレット管理の実務。APIキー・認証情報をハードコードせず、Vaultやマネージドサービスで守りローテーションする

漏えいの経路としてログにも注意が必要です。デバッグのために署名ヘッダやリクエストボディを丸ごとログへ書く実装は珍しくありませんが、署名そのものが漏れると過去のリクエストを再送される余地が生まれますし、ペイロードには個人情報や決済情報が入っていることがあります。OWASPのLogging Cheat Sheetは、ログに直接記録すべきでないデータとして、認証パスワード、セッション識別値、アクセストークン、暗号鍵とその他の主要な秘密、機微な個人データなどを挙げ、こうしたデータは削除、マスク、サニタイズ、ハッシュ化、暗号化のいずれかを行うべきだとしています。Webhookのログに残すのは、イベントID、イベント種別、検証の成否、処理の結果といった運用に必要な最小限にとどめます。

RFC 9421が示すHTTPメッセージ署名の標準

ここまで見てきた方式はどれもサービスごとの独自仕様です。ヘッダ名も署名対象も違い、受信側は連携先の数だけ検証コードを書くことになります。この状況に対する標準化の動きが2つあります。

1つはStandard Webhooksです。ヘッダ名と署名の作り方を揃える仕様として公開され、既存のヘッダを残したまま追加できる移行方針も示されています。

もう1つがIETFのRFC 9421です。2024年2月に発行されたStandards Trackの文書で、正式名称はHTTP Message Signaturesです。Webhookのために作られた仕様ではなく、HTTPメッセージの構成要素に対してデジタル署名またはメッセージ認証コードを作成、符号化、検証する汎用の仕組みを定めています。要旨には、署名者が完全なHTTPメッセージを知らない場合や、検証者に届く前に仲介者によってメッセージが変換される場合に対応する、と書かれています。

仕組みの中核は署名ベースという考え方です。HTTPメッセージの構成要素を正規化した文字列を並べたものを署名の入力にします。どの要素を署名したかはSignature-Inputフィールドに明示され、署名値はSignatureフィールドに入ります。要素にはヘッダフィールドのほか、@method(HTTPメソッド)、@target-uri(スキームとクエリを含む完全なリクエストURL)、@authority(正規化されたホスト名とポート)といった派生要素を指定できます。

署名パラメータとして定義されているのはcreated(作成時刻のUNIXタイムスタンプ、付与が推奨)、expires(有効期限のUNIXタイムスタンプ)、nonce(この署名のために生成されたランダムで一意な値)、alg(署名アルゴリズム)、keyid(鍵素材の識別子)、tag(アプリケーション固有のタグ)です。Webhookの独自方式が持っているタイムスタンプやメッセージIDは、この枠組みではcreatednonceに対応します。

ボディの扱いには一段の間接があります。RFC 9421はヘッダとリクエストラインの要素を署名するもので、ボディそのものは直接の対象になりません。ボディの完全性を守る場合は、RFC 9530のDigest Fieldsが定めるContent-Digestフィールドをメッセージに付け、そのフィールドを署名の対象に含めます。RFC 9530も2024年2月発行のStandards Trackで、RFC 3230を廃止しています。

RFC 9421「HTTP Message Signatures」(2024年2月、Standards Track)は、HTTPメッセージの構成要素に対する署名またはメッセージ認証コードを作成、符号化、検証する仕組みを定めています。第2.3節は署名パラメータとして、作成時刻のcreated(Integer型のUNIXタイムスタンプ、秒未満の精度は非対応、付与がRECOMMENDED)、有効期限のexpires、この署名のために生成されたランダムで一意な値であるnonce、アルゴリズムのalg、鍵素材の識別子keyid、アプリケーション固有のtagを挙げています。第7.2.2節「Signature Replay」は、HTTPメッセージ署名がメッセージの一部だけを署名できるため、2つの異なるメッセージが同じ署名で検証を通ることがあり、極端な例として構成要素を1つも署名しない署名は傍受されれば任意のHTTPメッセージに付けて自由に再生できると述べています。対策として、署名者が他のメッセージと区別できるだけの構成要素をカバーすること、nonceパラメータでメッセージごとに一意な値を持たせて値の繰り返しから署名の再生を検出できるようにすること、createdexpiresで捕獲された署名の有効性を制限することを挙げています。

第7.2.2節の指摘は、独自方式を設計する側にも当てはまります。署名の対象が狭いほど、その署名は別のメッセージにも付け替えられます。ボディだけを署名する方式が、宛先URLを署名しない以上、同じ署名を別のエンドポイントへそのまま転送できるのはこのためです。複数の顧客が同じプラットフォームからWebhookを受けている状況で鍵が共有されていれば、ある顧客宛のイベントを別の顧客のエンドポイントへ横流しできてしまいます。鍵をエンドポイントごとに分ける要求は、この筋道でも正当化されます。

現時点でRFC 9421をWebhookの署名に採用している主要サービスは限られており、当面は独自方式とStandard Webhooksへの対応が実務の中心になります。それでも、署名の対象を明示的に列挙するという設計思想は、自前の方式を作るときの指針として使えます。

Webhookを送る側が抱えるSSRFの問題

ここまでは受け取る側の話でした。自社がWebhookを送る側になると、まったく別の問題が出てきます。利用者が登録した任意のURLへ、自社のサーバから自社のネットワーク内部を起点にHTTPリクエストを出すという構図そのものが、SSRFの定義に一致します。

OWASP API Security Top 10のAPI7:2023は、SSRFの欠陥がAPIが利用者から与えられたURLを検証せずにリモートリソースを取得するときに発生すると定義し、SSRFを誘発しやすい現代的な開発の慣習としてwebhook、URLからのファイル取得、独自のSSO、URLプレビューを挙げています。攻撃シナリオの2つ目は、まさにWebhookの登録機能です。SIEM製品向けにURLを受け付けるWebhook作成処理を攻撃者が悪用し、APIにクラウドのメタデータサービス(例として169.254.169.254/latest/meta-data/)へアクセスさせて、サーバのレスポンスに認証情報を露出させるという流れが記述されています。

OWASP API Security Top 10のAPI7:2023「Server Side Request Forgery」は、SSRFの欠陥を「APIが利用者から与えられたURLを検証せずにリモートリソースを取得するときに発生する」と定義しています。SSRFを誘発しやすい現代的な開発の概念として、webhook、URLからのファイル取得、独自のSSO、URLプレビューを挙げています。予防策としては、信頼できるリモートの発信元、URLスキーム、ポートの許可リストを整備すること、クライアントから与えられたすべての入力を検証しサニタイズすること、HTTPのリダイレクトを無効にすること、パースの不整合を防ぐためによく保守されたURLパーサを使うこと、リソース取得の仕組みを内部のネットワークから隔離すること、生のレスポンスをクライアントへ返さないことが列挙されています。

Webhookに固有の事情として、宛先URLを許可リストで絞れないという制約があります。相手は自社の顧客であり、そのエンドポイントのホスト名を事前に知ることはできません。OWASPのSSRF Prevention Cheat Sheetは、この状況を「アプリケーションが任意の外部IPアドレスやドメイン名へリクエストを送れる場合」として別のケースに分類し、IPやドメインの一覧が事前に分からず動的に変化するため許可リストは使えないとしています。代わりに求められるのは、与えられたIPが公開アドレスであることの検証、そしてドメイン名の場合は内部DNSリゾルバで解決されないことの確認と、AとAAAAのレコードをすべて取得して結果のIPアドレスが公開アドレスであり私的なレンジに入らないことの確認です。

ここでTOCTOUの問題が出てきます。登録時に名前解決して公開IPだと確認しても、実際にリクエストを出す瞬間に同じ名前が別のIPへ解決されるように仕込まれていれば検証は迂回されます。DNSリバインディングと呼ばれる手口です。同Cheat Sheetもこの点を挙げ、ドメイン名とその解決先IPアドレスを同時に検証する必要があるとしています。実装としては、名前解決の結果を自分で受け取り、そのIPアドレスへ直接接続する形にします。名前解決とTCP接続の間に別の解決が挟まらない経路を作るという方針です。

リダイレクトの追跡も止めます。登録URLが公開IPを指していても、302で内部アドレスへ飛ばされればそこへ接続してしまいます。OWASPのAPI7:2023もCheat Sheetもリダイレクトの無効化を挙げています。Standard Webhooksの仕様も、配送の成功を2xxのみとし、3xxは失敗として扱い、リダイレクトの追跡は送信側と受信側の双方に不要な負荷をかけるためURLの更新で対応することを勧めています。Shopifyの公式ドキュメントも、200番台以外の応答は3XXを含めてエラーとして扱うと述べています。

構成面での分離が効くのはこの領域です。Standard Webhooksの仕様は、SSRFへの主な対策としてWebhookが内部のネットワークやサービスへ到達しないようにすることを挙げ、2つの手段を示しています。1つはすべてのWebhookリクエストを内部IPアドレスを弾く専用プロキシ経由にすること、もう1つはWebhookのワーカー(またはプロキシ)を内部サービスへアクセスできない専用のプライベートサブネットへ置くことです。実例として挙げられているsmokescreenは、StripeがOSSとして公開しているHTTP CONNECTプロキシで、READMEには要求された各ドメイン名を解決し、それが公開ルーティング可能なIPアドレスであって内部のIPアドレスではないことを保証する、と書かれています。同READMEは拒否リストの限界にも触れ、可能な限り拒否リストではなく許可リストを使い、IPアドレスは設定オプションで遮断することを勧めています。

クラウドのメタデータサービスは、多層防御の観点で個別に固めます。AWSの場合、IMDSv2はセッション指向のリクエストを使い、まずPUTリクエストでトークンを取得し、以降のメタデータ要求ではX-aws-ec2-metadata-tokenヘッダでそのトークンを渡す方式です。AWSのセキュリティブログは、PUTリクエストでセッションを開始し、その後のリクエストで秘密のセッショントークンを要求するというIMDSv2の組み合わせが、静的なヘッダだけを要求するより常に厳密に効果的だと説明しています。あわせて、レスポンスのTTL値を1に設定するとトークンを含む応答がインスタンスの外へ出る前にTTLがゼロになりパケットが破棄されること、X-Forwarded-Forヘッダを持つ呼び出し元にはセッショントークンを発行しないことも述べられています。

SSRFの原理と一般的な対策は次の記事で扱っています。

あわせて読みたい

SSRFとは何か。サーバーを踏み台にする脆弱性の仕組みと許可リスト方式の防御を解説

送信側で決めておく項目を表にまとめます。

項目決めること
スキームhttpsのみ許可し、file、gopher、ftpなどを拒否する
ポート443(必要なら80)に限定する
宛先IP名前解決の結果がすべて公開アドレスであることを確認し、そのIPへ直接接続する
リダイレクト追跡しない。3xxは配送失敗として扱う
認証情報付きURLユーザー名とパスワードを含むURLを拒否する
経路専用の送信プロキシまたは専用サブネットを通し、内部サービスへ到達できないようにする
タイムアウト接続と全体の双方に上限を設ける
レスポンス本文を登録者へ返さない。記録するのはステータスコードと所要時間程度にとどめる
ボディサイズ受信するレスポンスのサイズに上限を設ける

最後の2行は見落とされやすい項目です。配送結果の画面に受信側のレスポンス本文をそのまま表示する実装は、SSRFを盲目的なものから内容が見えるものへ格上げします。内部サービスの応答が攻撃者の画面に出るようになるためです。

リトライの設計も送信側の責務です。Standard Webhooksの仕様は、複数日にまたがるリトライスケジュールを指数バックオフで組み、リトライ自体が生む負荷の再来を防ぐためにランダムなジッタを加えることを勧めています。仕様が例示するスケジュールは、即時、5秒後、5分後、30分後、2時間後、5時間後、10時間後、14時間後、20時間後、24時間後という並びです。ステータスコードごとの扱いも決められており、410 Goneは受信側がもう受け取る意思がない合図なのでエンドポイントを無効にして送信を止める、429 Too Many Requestsはレート制限なので追加のリクエストを絞る、502と504は受信側の負荷を示すので同じく絞る、503などに付くRetry-Afterヘッダは次回のスケジュールに反映する、といった内容です。長期にわたって配送が失敗し続ける場合は、メールなど別の経路で受信側に知らせたうえで、以降の配送を無効化することも勧められています。

リクエストのタイムアウトは15秒から30秒の間が推奨値として示されています。ペイロードのサイズは、技術的な制限はないものの20キロバイト未満にとどめることが勧められています。受信側が興味を持っていないデータまで送りつけて負荷をかけないため、という理由です。

受信エンドポイントの運用で決めておくこと

実装が正しくても、運用の設定で守りが崩れることがあります。

応答を速く返す設計が最初に来ます。Stripeの公式ドキュメントは、タイムアウトを引き起こしうる複雑なロジックを実行する前に成功のステータスコードを素早く返すよう求めています。GitHubのベストプラクティスは、配送を受け取ってから10秒以内に2XXで応答するよう記載しています。Shopifyの接続タイムアウトは1秒、リクエスト全体のタイムアウトは5秒です。したがって受信側の処理は、検証と冪等性キーの記録とキューへの投入までを同期で行い、業務処理は非同期のワーカーへ回す形になります。Stripeも非同期キューでの処理を勧めており、月初のように配送が急増する場面でエンドポイントが処理しきれなくなる事態を避けられると説明しています。

レート制限とサイズ制限も要ります。署名の検証は安価ですが、無制限に受け付ければ検証だけでCPUを使い切れます。ボディサイズの上限を先に効かせて、巨大なペイロードでメモリを消費させる経路を塞ぎます。上限は連携先が送る最大のペイロードに余裕を持たせた値にします。

WAFやIP許可リストは補助的な層として使えます。Stripeは特定のIPアドレスからWebhookイベントを送るとして一覧のページを公開しており、サーバやファイアウォールをそれらのアドレスからのリクエストだけ受け付ける設定にすることを、署名の検証と両方使うべき保護として案内しています。GitHubのベストプラクティスも、GET /metaエンドポイントで取得できるアドレスを使ったIP許可リストの設定を挙げています。ただしIPの一覧は変わりますし、同じIPからは他の利用者向けのリクエストも出ます。IP許可リストは署名検証の代わりにはなりません。

TLSの要件も確認しておきます。Stripeのwebhookが対応するTLSはバージョン1.2と1.3のみです。Twilioは自己署名証明書のHTTPS URLへは接続しません。中間証明書の配信漏れがあると、ブラウザでは開けるのにWebhookだけ届かないという状態になります。証明書チェーンの完全性は配送成功率に直接効きます。

監視項目としては、検証失敗の件数と割合、冪等性キーの衝突件数、受信から処理完了までの遅延、送信元サービス側のダッシュボードに出る失敗件数を見ます。検証失敗が突然増えた場合、鍵のローテーション漏れ、経路上のボディ書き換え、クロックのずれ、あるいは実際の攻撃のいずれかです。区別できるよう、失敗の理由はサーバ側のログに残しておきます。

APIエンドポイント全般の防御の組み立て方は、次の記事で整理しています。

あわせて読みたい

APIセキュリティの基本。認証認可・レート制限・入力検証とOWASP API Top 10で守る

既存の受信エンドポイントを安全にする手順

すでに動いているエンドポイントを対象に、順序を決めて進めます。

  1. 1

    受信エンドポイントを棚卸しする

    自社が公開しているWebhookの受信URLをすべて洗い出します。連携先サービス、URL、署名の有無、署名方式、使っているシークレット、そのイベントが起こす業務処理を一覧にします。過去に検証用として作ったまま残っているエンドポイントが見つかることがあります。使っていないものは閉じます。
  2. 2

    署名検証が入っていないエンドポイントを特定する

    署名の検証をしていない、あるいは検証結果を無視して処理を続けている実装を探します。ミドルウェアで検証しているつもりでも、そのルートに適用されていないケースがあります。意図的に不正な署名を付けたリクエストを投げて、実際に拒否されることを確認します。
  3. 3

    生ボディでの検証に直す

    パース済みオブジェクトの再シリアライズで検証している箇所を、生のバイト列を使う形へ書き換えます。ボディパーサの適用範囲をルート単位で見直し、Webhookのルートだけ生のボディを受け取るようにします。検証を通ってからパースする順序に整えます。
  4. 4

    比較をタイミングセーフな関数へ置き換える

    等値演算子での比較を、Node.jsならcrypto.timingSafeEqual、Pythonならhmac.compare_digestへ置き換えます。長さが異なる場合に例外が出る実装では、事前に長さを確認して例外を握りつぶさないようにします。
  5. 5

    リプレイ対策を入れる

    署名対象にタイムスタンプが含まれる方式では、許容範囲を決めて範囲外を拒否します。許容値をゼロにはしません。タイムスタンプを持たない方式では、配送IDを冪等性キーにして二重処理を防ぐ側で受け止めます。サーバのクロックがNTPで同期していることも確認します。
  6. 6

    冪等性キーの記録を永続層へ移す

    処理済みイベントIDを、ユニーク制約付きのテーブルへ業務処理と同じトランザクションで記録します。保持期間は送信側の最大リトライ期間と手動再送の期限より長く取ります。古い行を消すバッチも用意します。
  7. 7

    テナントと金額の突き合わせを追加する

    イベントが指すリソースを自社のデータベースで引き、その所有者がイベントの発生元と一致することを確認してから更新する処理を入れます。金額や通貨などの値も自社の期待値と突き合わせます。不一致は処理せず記録だけ残します。
  8. 8

    同期処理を検証と受理までに絞る

    業務処理を非同期のワーカーへ移し、同期部分は検証と記録とキュー投入までにします。連携先のタイムアウト値(10秒や5秒)の中に収まることを実測で確認します。
  9. 9

    鍵の複数本対応とローテーション手順を用意する

    秘密を配列で扱えるようにし、ローテーションの手順書を作ります。平時に一度実際に交換して、手順が動くことを確認します。
  10. 10

    送信側の宛先検証を点検する

    自社がWebhookを送っている場合、登録URLのスキームとポートの制限、名前解決結果の公開IP確認、リダイレクトの不追跡、専用プロキシまたは専用サブネットの経路、レスポンス本文を登録者へ返さない扱いを確認します。クラウドのメタデータサービスへの到達可否も点検します。

Webhook設計の点検リスト

自組織の現状確認に使える形で並べます。

Webhook受信と送信の点検

  • 公開しているWebhook受信URLをすべて一覧化し、連携先、署名方式、使用シークレット、起こす業務処理を記録した
  • すべての受信エンドポイントで署名を検証しており、検証に失敗したリクエストで業務処理が動かないことを実際に不正な署名を送って確認した
  • 署名の検証をパース前の生バイト列に対して行っており、JSONの再シリアライズを経由していないことを確認した
  • 署名の比較にタイミングセーフな関数を使い、長さの不一致で例外が出る場合も含めて失敗として扱っていることを確認した
  • 検証失敗時の応答が理由を区別しない共通のエラーになっており、内部情報を返していないことを確認した
  • 署名対象にタイムスタンプが含まれる方式では許容範囲を設定し、許容値をゼロにしていないことを確認した
  • サーバのクロックがNTPで同期しており、時刻ずれによる検証失敗が起きない状態であることを確認した
  • イベントIDまたは配送IDを冪等性キーとして永続層に記録し、業務処理と同じトランザクションで一意制約を効かせていることを確認した
  • 冪等性キーの保持期間が、送信側の最大リトライ期間と手動再送の期限を上回っていることを確認した
  • イベントの順序に依存する処理がないことを確認し、必要な箇所ではAPIから現在値を取得するかバージョン比較で古い更新を捨てている
  • イベントが指すリソースの所有者と、イベントの発生元アカウントが一致することを確認してから更新していることを確認した
  • 金額や通貨などの値をイベントの値のまま採用せず、自社の期待値と突き合わせていることを確認した
  • 署名シークレットをエンドポイントごとまたは顧客ごとに分けており、顧客をまたいで再利用していないことを確認した
  • 受信側が複数の秘密を並行して検証でき、無停止でのローテーション手順を文書化して一度実施したことがある
  • ログに署名ヘッダやシークレット、機微なペイロードを残していないことを確認した
  • 同期処理を検証と記録とキュー投入までに絞り、連携先のタイムアウト内に応答が返ることを実測で確認した
  • 受信エンドポイントにボディサイズの上限とレート制限を設定していることを確認した
  • CSRF保護から除外したルートについて、その代わりに署名検証が確実に効いていることを確認した
  • 自社がWebhookを送る場合、登録URLのスキームとポートを制限し、名前解決の結果が公開IPであることを接続直前に確認している
  • 送信時にリダイレクトを追跡せず、3xxを配送失敗として扱っていることを確認した
  • Webhookの送信を専用プロキシまたは内部サービスへ到達できない専用サブネット経由にしていることを確認した
  • 受信側のレスポンス本文を登録者に見せておらず、記録もステータスコードと所要時間程度にとどめていることを確認した
  • リトライを指数バックオフとジッタで組み、410や429などのステータスに応じた扱いを実装していることを確認した
  • 検証失敗率、冪等性キーの衝突件数、処理遅延を監視し、異常値でアラートが出る状態にした

署名の検証は入口の確認であって処理の許可ではありません

Webhookの受信エンドポイントを固める作業は、署名検証の実装で終わりではありません。署名が確定させるのは「このリクエストは共有した秘密を知っている相手が作った」という一点だけです。そこから先の、どのテナントの何を動かしてよいか、この処理をもう一度実行してよいか、この更新は最新か、といった判断はすべて受信側の責任として残ります。

この切り分けを持っていると、実装の優先順位が決まります。署名検証は必須で、正しい署名対象を生ボディのまま組み立て、タイミングセーフな関数で比較する。この2点が外部から偽のイベントを流し込む経路を塞ぐ土台です。方式が署名済みのタイムスタンプを持つ場合は鮮度も検証し、持たない場合は配送IDの重複排除でリプレイを受け止めます。ここは最初に片付ける部分です。そのうえで、イベントの参照先を自社のデータで引き直し、所有者と金額を突き合わせ、イベントIDで二重処理を防ぐ。この後段が抜けていると、署名が完璧でも正規のイベントを使った操作が通ってしまいます。

送信する側に回ると、責任の向きが変わります。利用者が入れたURLへ自社のネットワークから接続する仕組みは、それ自体がSSRFの実装そのものです。宛先の検証、リダイレクトの不追跡、経路の分離という3つを構成として持ち、コードの善し悪しに依存しない形にしておきます。プロキシとサブネットで囲うという対策が繰り返し勧められているのは、アプリケーション側の検証がいつか漏れることを前提にしているからです。

標準化はゆっくり進んでいます。RFC 9421はHTTPメッセージ署名の枠組みを与え、Standard Webhooksはヘッダと署名の作り方を揃えました。どちらもすぐに既存の連携を置き換えるものではありませんが、署名する範囲を明示する、メッセージIDとタイムスタンプを署名対象に含める、鍵をエンドポイントごとに分ける、といった設計上の指針は今日の実装にそのまま持ち込めます。連携先が増えるほど、この共通の型を持っているかどうかが、点検にかかる時間の差になって現れます。

出典・参考

この記事をシェア

関連する記事

セキュアコーディング

JWTの仕組みと実装の落とし穴。署名検証とアルゴリズム混同を基礎から

JWT(JSON Web Token)の構造と署名の役割を基礎から整理します。header.payload.signatureの3パート、HS256とRS256の違い、alg:noneやアルゴリズム混同といった検証まわりの落とし穴、有効期限と失効の設計、保存場所によるXSS/CSRFの注意点まで、一次仕様に沿って実装者向けに解説します。