mcpField
O
outgoing-webhooks

outgoing-webhooks

github.com/thinkoodle开发7158 次安装7 周前更新
代码仓库

关于 outgoing-webhooks

outgoing-webhooks 是用于安全地向客户提供的 URL 投递事件的开发技能,涵盖 SSRF 防护、HMAC 签名、异步投递、重试、日志记录以及连续失败后的端点停用。

主要功能

  1. 01阻止私有和环回地址
  2. 02将 DNS 解析固定到公网 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

当发生某些事件时,向客户提供的 URL 发送 POST 请求。请将该 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 存储在租户数据库中。作业接收一个全局句柄(项目 slug、租户密钥)以及 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 令牌使用相同方式:在展示页面 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),避免两个工作进程重复发送 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 固定、没有超时、没有签名。
  2. 在生产环境中允许 localhost——这会导致元数据服务漏洞。
  3. 先对 Hash 签名,之后再调用 to_json——键的顺序将不匹配。
  4. 在请求中内联投递——用户会一直等待订阅方响应。
  5. 使用一个作业来发布并分发 Webhook——错误 URL 会导致发布重试。
  6. 记录签名密钥或完整载荷——使用 filter_parameters,并截断响应正文。

相关内容

  • 多租户作业:activerecord-tenanted
  • 触发这些 Webhook 的事件总线(Fizzy):事件位于应用中;此技能仅负责投递

有关 Fizzy 与 Herald 的区别以及 IP 允许/拒绝列表,请参阅 reference.md。

评论

支持 Markdown