配信の検証
`OpenEmail.verify_webhook_signature`:一定時間での比較、リプレイ許容ウィンドウ、そしてパース済みイベントの返却。
リクエストハンドラーでの使い方
Webhook の URL は公開されています。インターネット上の誰でも、正しい形の JSON をそこへ POST できます。したがって、署名を検証せずにイベントの type を読むハンドラーは、誰でも書き込める API にほかなりません。
class OpenEmailWebhooksController < ActionController::API def create event = OpenEmail.verify_webhook_signature( payload: request.raw_post, headers: request.headers, secret: ENV.fetch("OPENEMAIL_WEBHOOK_SECRET"), tolerance_seconds: 300 ) Rails.logger.info("#{event[:type]} #{event[:data]}") head :no_content rescue OpenEmail::WebhookSignatureError head :bad_request endend「生の」ボディを String として渡してください。パースして再びシリアライズするとキーの順序や空白が変わり、署名が一致しなくなります。そのため、Rails の params のような Hash は検証されるのではなく ArgumentError を送出します。headers: は、ヘッダー名の大文字小文字を問わない Hash、ヘッダーが HTTP_X_OPENEMAIL_SIGNATURE として届く Rack の env、または Rails の request.headers を受け取ります。値が Array の場合は最初の要素が読まれます。
自前の検証が取りこぼしがちな 2 点をこのメソッドは処理します:1 つは MAC を一定時間で比較することで、処理時間から正しい先頭部分を割り出せないようにします。もう 1 つは、前後いずれの方向でも tolerance_seconds:(指定しなければ 5 分)を超えて古い配信を拒否することで、傍受されたリクエストが永久に再送可能にならないようにします。tolerance_seconds: 0 にするとリプレイ検査は無効になります。どちらの不具合も表に出ません。いずれかを抱えたハンドラーでも、思いつく限りのテストはすべて通ってしまいます。
失敗した場合は必ず OpenEmail::WebhookSignatureError を送出します:X-OpenEmail-Signature ヘッダーがない、形式が t=<seconds>,v1=<hex> になっていない、タイムスタンプが許容範囲外である、署名が一致しない、のいずれの場合も同様です。成功した場合は、ボディを id、type、createdAt、data を持つ Symbol キーの Hash にパースして返すため、間違えやすい 2 度目の JSON.parse は不要です。
secret: がない、または空の場合は代わりに ArgumentError を送出します。それは不正な配信ではなく、設定の誤りだからです。rescue するのは OpenEmail::WebhookSignatureError だけにして 400 を返してください。そうすれば設定を誤ったサーバーは 500 を返し、修正後に配信が再試行されます。
gem がすでに読み込んでいる標準ライブラリの OpenSSL を使うため、ほかには何も必要ありません。
Rack や Sinatra のアプリでは
Rails の外では、Rack の env そのものを headers: として渡し、生のボディをリクエストから読み取ります。
require "openemail"require "rack" webhook = lambda do |env| event = OpenEmail.verify_webhook_signature( payload: Rack::Request.new(env).body.read, headers: env, secret: ENV.fetch("OPENEMAIL_WEBHOOK_SECRET") ) warn "#{event[:type]} #{event[:id]}" [204, {}, []]rescue OpenEmail::WebhookSignatureError [400, {"content-type" => "text/plain"}, ["bad signature"]]end run webhookRack 版は config.ru の全体です。どちらでもすぐに応答してください:5 秒以内に応答のない配信は失敗とみなされ、後で再送されるため、処理はジョブに渡して 2xx を返してください。
OpenEmail::WEBHOOK_SIGNATURE_HEADERS は、配信が運ぶ 3 つのヘッダーを定義しています:X-OpenEmail-Signature、イベントの種類を持つ X-OpenEmail-Event、そしてその id(ボディの id と同じもの)を持つ X-OpenEmail-Delivery です。この id はイベントのリトライや再送のたびに同じなので、処理済みのイベントをスキップするときに保存すべきものはこれです。
シークレットのローテーション中
webhooks.rotate_secret には移行期間がありません:配信は、呼び出しが戻った瞬間から新しいシークレットで署名されます。まずどちらのシークレットも受け付ける受信側をデプロイし、ローテーションして、新しいシークレットを受信側が読む場所に保存し、それから古いものを外してください。
def verify_delivery(payload, headers) secrets = [ENV.fetch("OPENEMAIL_WEBHOOK_SECRET"), ENV["OPENEMAIL_WEBHOOK_SECRET_NEXT"]].compact secrets.each_with_index do |secret, index| return OpenEmail.verify_webhook_signature(payload:, headers:, secret:) rescue OpenEmail::WebhookSignatureError raise if index == secrets.size - 1 endendwebhooks.test は署名付きの合成イベントを送るため、ローテーション後に呼び出し、古いシークレットを外す前に新しいシークレットで検証が通ることを確かめてください。