outgoing-webhooks
Expert guidance for delivering signed HTTP webhooks from a Rails app to customer URLs. Use when adding webhooks, HMAC signatures, SSRF protection, webhook deliveries, auto-disable on failure, or posting events to Slack/customer endpoints. Covers Fizzy and Herald delivery. Not inbound Stripe/GitHub webhooks.
How do I install this agent skill?
npx skills add https://github.com/thinkoodle/rails-skills --skill outgoing-webhooksIs this agent skill safe to install?
- Gen Agent Trust Hubpass
The skill provides expert guidance and code templates for securely implementing outgoing webhooks in Ruby on Rails applications. It focuses on security best practices such as SSRF protection, DNS rebinding prevention, and HMAC payload signing. No malicious patterns or security risks were detected.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
Outgoing Webhooks
Customer-supplied URLs you POST to when something happens. Treat the URL as attacker-controlled. Sign the body. Pin DNS to a public IP. Never let a broken subscriber block the action that triggered the event.
Inbound webhooks (Stripe, GitHub) are the opposite direction — don't use this skill for those.
Philosophy
- SSRF first. Resolve the host, reject private/loopback/link-local, then
http.ipaddr =that address so a TTL=0 DNS rebind can't send you to 169.254.169.254. - HMAC the payload. Show the secret once. Header like
X-Webhook-Signature: sha256=.... - Delivery is a job with a log row. The user-facing action commits, then
perform_later. Failures retry; they don't roll back the event. - Auto-disable after a streak of failures. Don't hammer a dead endpoint forever.
Records
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
In activerecord-tenanted apps, Webhook lives in the tenant DB. The job receives a global handle (project slug, tenant key) plus delivery_id, then with_tenant.
Step 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
Validate on create and at delivery time (DNS can change). Production only for the hard block if you need localhost webhooks in dev — Herald does that. Fizzy always pins.
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"
Only http/https. Cap response body (Fizzy: 100 KB) so a subscriber can't fill your disk.
Step 2: Sign
def sign(payload)
"sha256=#{OpenSSL::HMAC.hexdigest("SHA256", signing_secret, payload)}"
end
Sign the exact bytes you send (request.body). Store the signature on the delivery row so retries don't re-hash a mutated payload. Filter secret / token from logs.
Show the secret once (same trick as API tokens: short-lived signed id on the show URL). Rotating regenerate_secret! is a first-class action.
Step 3: Fire after commit
# after the domain event is committed
delivery = webhook.deliveries.create!(event: "release.published", payload: payload.to_json)
DeliverWebhookJob.perform_later(project.slug, delivery.id)
Job:
with_tenantfirst- Claim the row (
delivering_at) so two workers don't double-POST - Re-check
webhook.enabled?and plan entitlements - Retry 5xx and timeouts (
retry_on, polynomially_longer, ~5 attempts) - 4xx is terminal
- After N consecutive terminal failures, disable the webhook
A failure in this job must not raise into PublishReleaseJob. Herald keeps publish and delivery on separate jobs for that reason.
Step 4: Headers
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 also sends X-Webhook-Timestamp. Receivers should verify HMAC, not trust the event name alone.
Tests that catch real bugs
http://127.0.0.1/andhttp://169.254.169.254/are rejected in production- Signature header matches HMAC of the stored payload
- Tenanted job without
with_tenantcannot find the webhook - 10 failures auto-disable; a later success (after re-enable) resets the counter
- Publish still succeeds when the endpoint 500s
Anti-Patterns
Net::HTTP.post_form(webhook.url)— no SSRF pin, no timeout, no signature.- Allowing
localhostin production — that's the metadata-service exploit. - Signing a Hash then
to_jsonlater — key order will not match. - Delivering inline in the request — user waits on the subscriber.
- One job that publishes and fans out webhooks — a bad URL retries the publish.
- Logging the signing secret or full payload —
filter_parameters+ truncate response bodies.
Related
- Tenanted jobs:
activerecord-tenanted - Event bus that triggers these (Fizzy): events live in the app; this skill is only delivery
See reference.md for Fizzy vs Herald and the IP allow/deny list.
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/thinkoodle/rails-skills/outgoing-webhooks">View outgoing-webhooks on skillZs</a>