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.
This commit is contained in:
Rosa Gutierrez
2025-12-09 11:25:12 +01:00
parent e4b5139683
commit 29c7926307
2 changed files with 48 additions and 4 deletions
+18 -3
View File
@@ -1,7 +1,10 @@
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
@@ -52,12 +55,16 @@ class Webhook::Delivery < ApplicationRecord
if resolved_ip.nil?
{ error: :private_uri }
else
response = http.request(
Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload }
)
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
@@ -68,6 +75,14 @@ class Webhook::Delivery < ApplicationRecord
{ 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)
+30 -1
View File
@@ -296,12 +296,15 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase
stub_dns_resolution(PUBLIC_TEST_IP)
# Verify Net::HTTP.new is called with the pinned IP
response_mock = stub(code: "200")
response_mock.stubs(:read_body)
http_mock = mock("http")
http_mock.stubs(:use_ssl=)
http_mock.stubs(:ipaddr=)
http_mock.stubs(:open_timeout=)
http_mock.stubs(:read_timeout=)
http_mock.stubs(:request).returns(stub(code: "200"))
http_mock.stubs(:request).yields(response_mock).returns(response_mock)
Net::HTTP.expects(:new).with("example.com", 443).returns(http_mock)
@@ -310,6 +313,32 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase
assert delivery.succeeded?
end
test "handles response too large error" do
delivery = webhook_deliveries(:pending)
large_body = "x" * 200.kilobytes
stub_request(:post, delivery.webhook.url).to_return(status: 200, body: large_body)
delivery.deliver
assert_equal "completed", delivery.state
assert_equal "response_too_large", delivery.response[:error]
assert_not delivery.succeeded?
end
test "allows responses within size limit" do
delivery = webhook_deliveries(:pending)
small_body = "x" * 50.kilobytes
stub_request(:post, delivery.webhook.url).to_return(status: 200, body: small_body)
delivery.deliver
assert_equal "completed", delivery.state
assert_equal 200, delivery.response[:code]
assert delivery.succeeded?
end
private
def stub_dns_resolution(*ips)
dns_mock = mock("dns")