mcpField
O
outgoing-webhooks

outgoing-webhooks

github.com/thinkoodle開発7158 回インストール7 週間前に更新
リポジトリ

outgoing-webhooks について

outgoing-webhooksは、顧客指定URLへイベントを安全に配信するための開発スキルです。SSRF対策、HMAC署名、非同期配信、再試行、ログ、連続失敗時のエンドポイント無効化を扱います。

主な機能

  1. 01プライベートおよびループバック宛先を拒否
  2. 02DNS解決先をパブリックIPに固定
  3. 03ペイロードのバイト列にHMAC署名
  4. 04ログ付きの非同期イベント配信
  5. 05一時的な配信失敗を再試行
  6. 06連続失敗後にエンドポイントを無効化

活用シーン

  1. 01購読エンドポイントへリリースイベントを送信
  2. 02配信ジョブをSSRFリクエストから保護
  3. 03イベントを止めずにタイムアウトを再試行
  4. 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)は逆方向です。このスキルは使用しないでください。

基本方針

  1. まず SSRF 対策です。 ホストを解決し、プライベート/ループバック/リンクローカルを拒否してから、http.ipaddr = でそのアドレスを指定します。これにより、TTL=0 の DNS リバインドで 169.254.169.254 に送信されるのを防げます。
  2. ペイロードに HMAC 署名します。 シークレットは一度だけ表示します。ヘッダーは X-Webhook-Signature: sha256=... のような形式にします。
  3. 配信はログ行を伴うジョブです。 ユーザー向けのアクションをコミットしてから、perform_later を実行します。失敗は再試行しますが、イベントをロールバックしません。
  4. 失敗が連続したら自動で無効化します。 応答のないエンドポイントにいつまでもリクエストを送り続けないでください。

レコード

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 を返しても公開は成功する

アンチパターン

  1. Net::HTTP.post_form(webhook.url) — SSRF 対策の IP 固定も、タイムアウトも、署名もありません。
  2. 本番環境で localhost を許可する — メタデータサービスを悪用されます。
  3. Hash に署名してから、後で to_json する — キーの順序が一致しません。
  4. リクエスト内でインライン配信する — ユーザーがサブスクライバーの応答を待つことになります。
  5. 公開と Webhook のファンアウトを 1 つのジョブで行う — 不正な URL によって公開が再試行されます。
  6. 署名シークレットやペイロード全体をログに記録する — filter_parameters を使い、レスポンス本文を切り詰めてください。

関連項目

  • テナント対応ジョブ:activerecord-tenanted
  • これらをトリガーするイベントバス(Fizzy):イベントはアプリ内にあります。このスキルの対象は配信のみです

Fizzy と Herald の違い、および IP の許可/拒否リストについては、reference.md を参照してください。

コメント