outgoing-webhooks について
outgoing-webhooksは、顧客指定URLへイベントを安全に配信するための開発スキルです。SSRF対策、HMAC署名、非同期配信、再試行、ログ、連続失敗時のエンドポイント無効化を扱います。
主な機能
- 01プライベートおよびループバック宛先を拒否
- 02DNS解決先をパブリックIPに固定
- 03ペイロードのバイト列にHMAC署名
- 04ログ付きの非同期イベント配信
- 05一時的な配信失敗を再試行
- 06連続失敗後にエンドポイントを無効化
活用シーン
- 01購読エンドポイントへリリースイベントを送信
- 02配信ジョブをSSRFリクエストから保護
- 03イベントを止めずにタイムアウトを再試行
- 04失敗配信を調べて停止したエンドポイントを無効化
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ thinkoodle/ rails-skills --skill outgoing-webhooksインストール後、Claude Code で/outgoing-webhooksを入力して読み込まれたか確認します。
同梱ファイルreference.md
詳細
送信 Webhook
何かが起きたときに POST する、顧客が指定した URL です。URL は攻撃者が制御するものとして扱ってください。本文に署名してください。DNS をパブリック IP に固定してください。サブスクライバーに障害があっても、イベントをトリガーしたアクションを決してブロックしないでください。
受信 Webhook(Stripe、GitHub)は逆方向です。このスキルは使用しないでください。
基本方針
- まず SSRF 対策です。 ホストを解決し、プライベート/ループバック/リンクローカルを拒否してから、
http.ipaddr =でそのアドレスを指定します。これにより、TTL=0 の DNS リバインドで 169.254.169.254 に送信されるのを防げます。 - ペイロードに HMAC 署名します。 シークレットは一度だけ表示します。ヘッダーは
X-Webhook-Signature: sha256=...のような形式にします。 - 配信はログ行を伴うジョブです。 ユーザー向けのアクションをコミットしてから、
perform_laterを実行します。失敗は再試行しますが、イベントをロールバックしません。 - 失敗が連続したら自動で無効化します。 応答のないエンドポイントにいつまでもリクエストを送り続けないでください。
レコード
class Webhook < ApplicationRecord # tenanted, or board-scoped in Fizzy
has_secure_token :signing_secret # or "whsec_#{SecureRandom.urlsafe_base64(32)}"
has_many :deliveries, dependent: :destroy
validates :url, presence: true
validate :url_is_deliverable
end
class WebhookDelivery < ApplicationRecord
belongs_to :webhook
# payload, event, attempts, delivering_at, delivered_at, failed_at,
# response_status, error_message, signature
end
activerecord-tenanted を使用するアプリでは、Webhook はテナント DB にあります。ジョブには グローバル ハンドル(プロジェクトのスラッグ、テナントキー)と delivery_id を渡し、その後 with_tenant を実行します。
ステップ 1:SSRF 対策
module SsrfProtection
DISALLOWED = [
IPAddr.new("0.0.0.0/8"),
IPAddr.new("10.0.0.0/8"),
IPAddr.new("100.64.0.0/10"),
IPAddr.new("127.0.0.0/8"),
IPAddr.new("169.254.0.0/16"),
IPAddr.new("172.16.0.0/12"),
IPAddr.new("192.168.0.0/16"),
IPAddr.new("198.18.0.0/15"),
IPAddr.new("::1/128"),
IPAddr.new("fc00::/7"),
IPAddr.new("fe80::/10")
].freeze
def self.public_ip(host)
addrs = Resolv.getaddresses(host.to_s).map { |a| IPAddr.new(a) }
addrs.find { |ip| DISALLOWED.none? { |net| net.include?(ip.native) } && !ip.loopback? && !ip.private? && !ip.link_local? }
rescue Resolv::ResolvError, ArgumentError
nil
end
end
作成時と配信時の両方で検証します(DNS は変更される可能性があります)。開発環境で localhost Webhook を必要とする場合は、本番環境でのみ厳格にブロックしてください。Herald はそのようにしています。Fizzy は常に IP を固定します。
http = Net::HTTP.new(uri.host, uri.port)
http.ipaddr = public_ip.to_s
http.open_timeout = 10
http.read_timeout = 10
http.use_ssl = uri.scheme == "https"
http/https のみ許可します。サブスクライバーにディスクをいっぱいにされないよう、レスポンス本文のサイズを制限してください(Fizzy の場合は 100 KB)。
ステップ 2:署名
def sign(payload)
"sha256=#{OpenSSL::HMAC.hexdigest("SHA256", signing_secret, payload)}"
end
送信する正確なバイト列(request.body)に署名してください。変更されたペイロードを再ハッシュしないよう、署名は配信レコードに保存します。ログから secret/token を除外してください。
シークレットは一度だけ表示します(API トークンと同じ方法で、show URL に短期間有効な署名付き ID を使用します)。regenerate_secret! によるローテーションは、第一級のアクションです。
ステップ 3:コミット後に送信
# after the domain event is committed
delivery = webhook.deliveries.create!(event: "release.published", payload: payload.to_json)
DeliverWebhookJob.perform_later(project.slug, delivery.id)
ジョブ:
- 最初に
with_tenantを実行する - レコードを確保する(
delivering_atを設定する)。2 つのワーカーが二重に POST しないようにする webhook.enabled?とプランの利用資格を再確認する- 5xx とタイムアウトを再試行する(
retry_on、polynomially_longer、約 5 回) - 4xx は再試行せず終了する
- 終了となる失敗が N 回連続したら、Webhook を無効化する
このジョブの失敗を PublishReleaseJob に伝播させてはいけません。そのため、Herald では公開と配信を別々のジョブにしています。
ステップ 4:ヘッダー
Content-Type: application/json
User-Agent: App-Webhook/1.0
X-App-Event: release.published
X-App-Delivery: <delivery id>
X-App-Signature: sha256=...
Fizzy は X-Webhook-Timestamp も送信します。受信側はイベント名だけを信頼せず、HMAC を検証してください。
実際のバグを検出するテスト
http://127.0.0.1/とhttp://169.254.169.254/が本番環境で拒否される- 署名ヘッダーが、保存されたペイロードの HMAC と一致する
with_tenantのないテナント対応ジョブでは、Webhook を見つけられない- 10 回失敗すると自動で無効化される。再有効化後に成功すると、カウンターがリセットされる
- エンドポイントが 500 を返しても公開は成功する
アンチパターン
Net::HTTP.post_form(webhook.url)— SSRF 対策の IP 固定も、タイムアウトも、署名もありません。- 本番環境で
localhostを許可する — メタデータサービスを悪用されます。 - Hash に署名してから、後で
to_jsonする — キーの順序が一致しません。 - リクエスト内でインライン配信する — ユーザーがサブスクライバーの応答を待つことになります。
- 公開と Webhook のファンアウトを 1 つのジョブで行う — 不正な URL によって公開が再試行されます。
- 署名シークレットやペイロード全体をログに記録する —
filter_parametersを使い、レスポンス本文を切り詰めてください。
関連項目
- テナント対応ジョブ:
activerecord-tenanted - これらをトリガーするイベントバス(Fizzy):イベントはアプリ内にあります。このスキルの対象は配信のみです
Fizzy と Herald の違い、および IP の許可/拒否リストについては、reference.md を参照してください。