Skip to content

Webhookを安全に処理する ​

Hibikiは本文の生バイトで署名を検証してから、JSONを解析します。本番の受信エンドポイントでは、本文サイズの制限、重複処理の防止、ハンドラーが実行する操作の権限確認も必要です。

本文はデフォルトで1 MiBまで ​

new Hibiki({ maxBodyBytes: 1_048_576 }) の本文上限は、デフォルトで1 MiBです。Content-Length がない場合や、実際より小さい値が指定された場合も、読み込んだバイト数で判定します。上限を超えると署名検証前に 413 / HIBIKI_PAYLOAD_TOO_LARGE を返します。ハンドラーは元のリクエスト本文を引き続き読み取れます。大きなイベントを受け取る場合は、上限を明示的に変更してください。

リクエスト数の制限とタイムアウトは、リバースプロキシや実行基盤で設定します。ハンドラーのミドルウェアは署名検証後に動くため、未認証リクエストの本文読み込みには適用されません。

同じイベントの処理を抑止する ​

組み込みプロバイダーは、署名で確認した情報から重複判定用のIDを作ります。StripeはイベントID、DiscordはInteraction ID、GitHubは本文のSHA-256ハッシュを使います。GitHubの X-GitHub-Delivery は署名対象外なので、重複判定には使いません。本文のバイト列が同じなら、イベント種別や配信IDのヘッダーが違っても同一イベントとして扱います。そのため、正規の配信でも本文が完全に同じ場合は重複と判定されます。

登録済みハンドラーの実行前にIDを確保します。処理済みの重複には 204、処理中の重複には再試行を促す 503 / HIBIKI_EVENT_IN_PROGRESS を返します。ハンドラーが例外を投げた場合や、2xx以外を返した場合は、IDの確保を解除して再試行を受け付けます。未対応イベントやハンドラー未登録のイベントは、IDを確保しません。独自プロバイダーでも、署名で確認したデータからIDを作ると、この仕組みを利用できます。

デフォルトでは、処理済みIDを24時間、アプリケーションあたり最大10,000件までメモリに保持します。上限に達すると既存IDを削除せず、503 / HIBIKI_IDEMPOTENCY_UNAVAILABLE を返します。処理中のIDは期限切れになりません。use() が返すビルダーは同じ保存先を使います。

ts
import { Hibiki, createMemoryIdempotencyStore } from "@hibiki-js/core"

const app = new Hibiki({
  maxBodyBytes: 1_048_576,
  idempotencyStore: createMemoryIdempotencyStore({
    retentionMs: 86_400_000,
    maxEntries: 10_000,
  }),
})

メモリ上のIDは期限切れやプロセスの再起動で失われます。複数インスタンスでも共有されません。本番では、永続化した共有の IdempotencyStore を idempotencyStore に指定してください。この保存先には次の動作が必要です。

  • claim(key) は、IDの確保と判定を一つの不可分な操作で実行します。戻り値は { status: "acquired", token }、{ status: "pending" }、{ status: "completed" } のいずれかです。トークンは確保した処理を一意に識別します。
  • complete(key, token) は、トークンが一致する処理中のIDだけを処理済みにします。
  • release(key, token) は、トークンが一致する処理中のIDだけを解除します。処理済みIDや、別の処理が確保したIDは削除しません。

保存先の障害には 503 を返します。ハンドラーの成功後に処理済みの記録が失敗した場合は、同じ副作用を繰り返さないようIDを処理中のまま保持します。共有保存先でも、ハンドラーの実行中にIDの確保が期限切れにならない設計が必要です。取り残されたIDを解除するときは、元の処理がもう動いていないことを確認してください。処理済みIDの保持期間は、再試行と再送攻撃に備える期間に合わせて決めます。GitHubの署名には時刻が含まれないため、IDを削除した後は古い署名付き本文も再送できます。

この仕組みだけでは、ID保存先と業務データベースへの書き込みを一つのトランザクションにできません。決済やアカウント作成などでは、データベースの一意制約とトランザクション、または呼び出し先APIの重複実行防止キーも使ってください。waitUntil に渡した処理は、ハンドラーの完了待ちに含まれません。成功応答を返す前に、処理内容を永続キューなどへ保存します。

GitHubの受信対象を絞る ​

GitHubが署名するのは本文で、X-GitHub-Event ヘッダーは署名対象外です。Hibikiはハンドラーに渡す前に、イベントごとの必須フィールドを確認します。例えば、ref_type がない push の本文を delete として処理することはできません。受信するイベントとリポジトリも指定できます。

ts
import { github } from "@hibiki-js/github"

const provider = github({
  secret: process.env.GITHUB_WEBHOOK_SECRET!,
  allowedEvents: ["pull_request.opened", "pull_request.closed"],
  allowedRepositories: ["your-org/your-repo"],
})

どちらもデフォルトでは制限しません。指定した場合は、対応済みイベントのハンドラー実行前に確認します。リポジトリを制限した場合、そのリポジトリ情報がないpingも拒否します。エンドポイントごとに専用のシークレットを使い、必要なイベントだけを許可してください。

本文の構造確認では、署名対象外のヘッダーの真正性を保証できません。GitHubの create と delete は本文が同じ構造になり得ます。最初の配信を受け付ける前にヘッダーを変えられると、許可済みの別のハンドラーが選ばれる余地があります。削除などの操作を受けるエンドポイントは、専用のシークレットと一つの受信イベントで分離し、実行前にGitHub APIで対象の状態を確認してください。重複防止が効くのは、署名付き本文のIDを確保した後です。

署名の設定値を検証する ​

StripeとGitHubは、空のシークレットを拒否します。StripeとDiscordの時刻許容値には、負数、NaN、無限大を指定できません。StripeとGitHubのSHA-256署名は、64文字の16進数だけを受け付けます。Stripeの対応済みイベントとDiscordの対応済みInteractionには、空でないIDが必要です。Webhook送信機能はリダイレクトを拒否し、署名付き本文が別の宛先に転送されるのを防ぎます。

シークレットやDiscordのWebhook URLは、クライアント側のコードやログに含めないでください。ペイロード内の文字列をSQL、シェルコマンド、HTML、外部リクエストに使うときも、未検証の入力として扱います。