Files
fizzy/app/models/webhook/delivery.rb
T
Rosa Gutierrez 29c7926307 Stream response in webhook request manually to check size
This addresses a DoS vulnerability where the response might be massive
leading to OOM errors, as the response is read in full in memory by
default.

To prevent this, we need to read the body in chunks, checking the
size of the chunks we've read and raising if we go over a certain limit.

I've set the limit to 100 KB because the responses to these requests
should be fairly small or even empty, and we only care about the status
code in the end.
2025-12-09 11:25:12 +01:00

162 lines
4.0 KiB
Ruby

class Webhook::Delivery < ApplicationRecord
class ResponseTooLarge < StandardError; end
STALE_TRESHOLD = 7.days
USER_AGENT = "fizzy/1.0.0 Webhook"
ENDPOINT_TIMEOUT = 7.seconds
MAX_RESPONSE_SIZE = 100.kilobytes
belongs_to :account, default: -> { webhook.account }
belongs_to :webhook
belongs_to :event
store :request, coder: JSON
store :response, coder: JSON
enum :state, %w[ pending in_progress completed errored ].index_by(&:itself), default: :pending
scope :ordered, -> { order created_at: :desc, id: :desc }
scope :stale, -> { where(created_at: ...STALE_TRESHOLD.ago) }
after_create_commit :deliver_later
def self.cleanup
stale.delete_all
end
def deliver_later
Webhook::DeliveryJob.perform_later(self)
end
def deliver
in_progress!
self.request[:headers] = headers
self.response = perform_request
self.state = :completed
save!
webhook.delinquency_tracker.record_delivery_of(self)
rescue
errored!
raise
end
def failed?
(errored? || completed?) && !succeeded?
end
def succeeded?
completed? && response[:error].blank? && response[:code].between?(200, 299)
end
private
def perform_request
if resolved_ip.nil?
{ error: :private_uri }
else
request = Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload }
response = http.request(request) do |net_http_response|
stream_body_with_limit(net_http_response)
end
{ code: response.code.to_i }
end
rescue ResponseTooLarge
{ error: :response_too_large }
rescue Resolv::ResolvTimeout, Resolv::ResolvError, SocketError
{ error: :dns_lookup_failed }
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT
{ error: :connection_timeout }
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ECONNRESET
{ error: :destination_unreachable }
rescue OpenSSL::SSL::SSLError
{ error: :failed_tls }
end
def stream_body_with_limit(response)
bytes_read = 0
response.read_body do |chunk|
bytes_read += chunk.bytesize
raise ResponseTooLarge if bytes_read > MAX_RESPONSE_SIZE
end
end
def resolved_ip
return @resolved_ip if defined?(@resolved_ip)
@resolved_ip = SsrfProtection.resolve_public_ip(uri.host)
end
def uri
@uri ||= URI(webhook.url)
end
def http
Net::HTTP.new(uri.host, uri.port).tap do |http|
http.ipaddr = resolved_ip
http.use_ssl = (uri.scheme == "https")
http.open_timeout = ENDPOINT_TIMEOUT
http.read_timeout = ENDPOINT_TIMEOUT
end
end
def headers
{
"User-Agent" => USER_AGENT,
"Content-Type" => content_type,
"X-Webhook-Signature" => signature,
"X-Webhook-Timestamp" => event.created_at.utc.iso8601
}
end
def signature
OpenSSL::HMAC.hexdigest("SHA256", webhook.signing_secret, payload)
end
def content_type
if webhook.for_campfire?
"text/html"
elsif webhook.for_basecamp?
"application/x-www-form-urlencoded"
else
"application/json"
end
end
def payload
@payload ||= if webhook.for_basecamp?
{ content: render_payload(formats: :html) }.to_query
elsif webhook.for_campfire?
render_payload(formats: :html)
elsif webhook.for_slack?
html = render_payload(formats: :html)
{ text: convert_html_to_mrkdwn(html) }.to_json
else
render_payload(formats: :json)
end
end
def render_payload(**options)
webhook.renderer.render(layout: false, template: "webhooks/event", assigns: { event: event }, **options).strip
end
def convert_html_to_mrkdwn(html)
document = Nokogiri::HTML5(html)
document.css("a").each do |a|
a.replace("<#{a["href"].strip}|#{a.text}>") if a["href"].present?
end
document.css("b").each do |b|
b.replace("*#{b.text}*")
end
document.css("i").each do |i|
i.replace("_#{i.text}_")
end
document.text
end
end