关于 outgoing-webhooks
outgoing-webhooks 是用于安全地向客户提供的 URL 投递事件的开发技能,涵盖 SSRF 防护、HMAC 签名、异步投递、重试、日志记录以及连续失败后的端点停用。
主要功能
- 01阻止私有和环回地址
- 02将 DNS 解析固定到公网 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
当发生某些事件时,向客户提供的 URL 发送 POST 请求。请将该 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 存储在租户数据库中。作业接收一个全局句柄(项目 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,发布仍能成功
反模式
Net::HTTP.post_form(webhook.url)——没有 SSRF 固定、没有超时、没有签名。- 在生产环境中允许
localhost——这会导致元数据服务漏洞。 - 先对 Hash 签名,之后再调用
to_json——键的顺序将不匹配。 - 在请求中内联投递——用户会一直等待订阅方响应。
- 使用一个作业来发布并分发 Webhook——错误 URL 会导致发布重试。
- 记录签名密钥或完整载荷——使用
filter_parameters,并截断响应正文。
相关内容
- 多租户作业:
activerecord-tenanted - 触发这些 Webhook 的事件总线(Fizzy):事件位于应用中;此技能仅负责投递
有关 Fizzy 与 Herald 的区别以及 IP 允许/拒绝列表,请参阅 reference.md。