29c7926307
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.
349 lines
10 KiB
Ruby
349 lines
10 KiB
Ruby
require "test_helper"
|
|
|
|
class Webhook::DeliveryTest < ActiveSupport::TestCase
|
|
PUBLIC_TEST_IP = "93.184.216.34" # example.com's real IP, used as a public IP stand-in
|
|
|
|
setup do
|
|
stub_dns_resolution(PUBLIC_TEST_IP)
|
|
end
|
|
|
|
test "create" do
|
|
webhook = webhooks(:active)
|
|
event = events(:layout_commented)
|
|
delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
|
|
|
|
assert_equal "pending", delivery.state
|
|
end
|
|
|
|
test "succeeded" do
|
|
webhook = webhooks(:active)
|
|
event = events(:layout_commented)
|
|
delivery = Webhook::Delivery.new(
|
|
webhook: webhook,
|
|
event: event,
|
|
response: { code: 200 },
|
|
state: :completed
|
|
)
|
|
assert delivery.succeeded?
|
|
|
|
delivery.response[:code] = 422
|
|
assert_not delivery.succeeded?, "resonse must have a 2XX status"
|
|
|
|
delivery.response[:code] = 200
|
|
delivery.state = :pending
|
|
assert_not delivery.succeeded?, "state must be completed"
|
|
|
|
delivery.state = :in_progress
|
|
assert_not delivery.succeeded?, "state must be completed"
|
|
|
|
delivery.state = :errored
|
|
assert_not delivery.succeeded?, "state must be completed"
|
|
|
|
delivery.state = :completed
|
|
delivery.response[:error] = :destination_unreachable
|
|
|
|
assert_not delivery.succeeded?, "the response can't have an error"
|
|
end
|
|
|
|
test "deliver_later" do
|
|
delivery = webhook_deliveries(:pending)
|
|
|
|
assert_enqueued_with job: Webhook::DeliveryJob, args: [ delivery ] do
|
|
delivery.deliver_later
|
|
end
|
|
end
|
|
|
|
test "deliver" do
|
|
delivery = webhook_deliveries(:pending)
|
|
|
|
stub_request(:post, delivery.webhook.url)
|
|
.to_return(status: 200, headers: { "content-type" => "application/json" })
|
|
|
|
assert_equal "pending", delivery.state
|
|
|
|
tracker = delivery.webhook.delinquency_tracker
|
|
tracker.update!(consecutive_failures_count: 0)
|
|
|
|
assert_no_difference -> { tracker.reload.consecutive_failures_count } do
|
|
delivery.deliver
|
|
end
|
|
|
|
assert delivery.persisted?
|
|
assert_equal "completed", delivery.state
|
|
assert delivery.request[:headers].present?
|
|
assert_equal 200, delivery.response[:code]
|
|
assert delivery.response[:error].blank?
|
|
assert delivery.succeeded?
|
|
end
|
|
|
|
test "deliver when the network timeouts" do
|
|
delivery = webhook_deliveries(:pending)
|
|
stub_request(:post, delivery.webhook.url).to_timeout
|
|
|
|
tracker = delivery.webhook.delinquency_tracker
|
|
assert_difference -> { tracker.reload.consecutive_failures_count }, 1 do
|
|
delivery.deliver
|
|
end
|
|
|
|
assert_equal "completed", delivery.state
|
|
assert_equal "connection_timeout", delivery.response[:error]
|
|
assert_not delivery.succeeded?
|
|
end
|
|
|
|
test "deliver when the connection is refused" do
|
|
delivery = webhook_deliveries(:pending)
|
|
stub_request(:post, delivery.webhook.url).to_raise(Errno::ECONNREFUSED)
|
|
|
|
delivery.deliver
|
|
|
|
assert_equal "completed", delivery.state
|
|
assert_equal "destination_unreachable", delivery.response[:error]
|
|
end
|
|
|
|
test "deliver when an SSL error occurs" do
|
|
delivery = webhook_deliveries(:pending)
|
|
stub_request(:post, delivery.webhook.url).to_raise(OpenSSL::SSL::SSLError)
|
|
|
|
delivery.deliver
|
|
|
|
assert_equal "completed", delivery.state
|
|
assert_equal "failed_tls", delivery.response[:error]
|
|
end
|
|
|
|
test "deliver when an unexpected error occurs" do
|
|
delivery = webhook_deliveries(:pending)
|
|
stub_request(:post, delivery.webhook.url).to_raise(StandardError, "Unexpected error")
|
|
|
|
assert_raises(StandardError) do
|
|
delivery.deliver
|
|
end
|
|
|
|
assert_equal "errored", delivery.state
|
|
end
|
|
|
|
test "deliver with basecamp webhook format" do
|
|
webhook = Webhook.create!(
|
|
board: boards(:writebook),
|
|
name: "Basecamp",
|
|
url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/789/lines"
|
|
)
|
|
event = events(:layout_commented)
|
|
delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
|
|
|
|
request_stub = stub_request(:post, webhook.url)
|
|
.with do |request|
|
|
body = CGI.parse(request.body)
|
|
body.key?("content") && body["content"].first.present? &&
|
|
request.headers["Content-Type"] == "application/x-www-form-urlencoded"
|
|
end
|
|
.to_return(status: 200)
|
|
|
|
delivery.deliver
|
|
|
|
assert_requested request_stub
|
|
assert delivery.succeeded?
|
|
end
|
|
|
|
test "deliver with campfire webhook format" do
|
|
webhook = Webhook.create!(
|
|
board: boards(:writebook),
|
|
name: "Campfire",
|
|
url: "https://example.com/rooms/123/456-room-name/messages"
|
|
)
|
|
event = events(:layout_commented)
|
|
delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
|
|
|
|
request_stub = stub_request(:post, webhook.url)
|
|
.with do |request|
|
|
request.body.is_a?(String) && !request.body.start_with?("{") && request.body.present? &&
|
|
request.headers["Content-Type"] == "text/html"
|
|
end
|
|
.to_return(status: 200)
|
|
|
|
delivery.deliver
|
|
|
|
assert_requested request_stub
|
|
assert delivery.succeeded?
|
|
end
|
|
|
|
test "deliver with slack webhook format" do
|
|
webhook = Webhook.create!(
|
|
board: boards(:writebook),
|
|
name: "Slack",
|
|
url: "https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx" # gitleaks:allow
|
|
)
|
|
event = events(:layout_commented)
|
|
delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
|
|
|
|
request_stub = stub_request(:post, webhook.url)
|
|
.with do |request|
|
|
body = JSON.parse(request.body)
|
|
body.key?("text") && body["text"].present? &&
|
|
request.headers["Content-Type"] == "application/json"
|
|
end
|
|
.to_return(status: 200)
|
|
|
|
delivery.deliver
|
|
|
|
assert_requested request_stub
|
|
assert delivery.succeeded?
|
|
end
|
|
|
|
test "deliver with generic webhook format" do
|
|
webhook = Webhook.create!(
|
|
board: boards(:writebook),
|
|
name: "Generic",
|
|
url: "https://example.com/webhook"
|
|
)
|
|
event = events(:layout_commented)
|
|
delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
|
|
|
|
request_stub = stub_request(:post, webhook.url)
|
|
.with do |request|
|
|
body = JSON.parse(request.body)
|
|
body.present? && !body.key?("line") && !body.key?("text") &&
|
|
request.headers["Content-Type"] == "application/json"
|
|
end
|
|
.to_return(status: 200)
|
|
|
|
delivery.deliver
|
|
|
|
assert_requested request_stub
|
|
assert delivery.succeeded?
|
|
end
|
|
|
|
test "cleanup" do
|
|
webhook = webhooks(:active)
|
|
event = events(:layout_commented)
|
|
|
|
fresh_delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
|
|
stale_delivery = Webhook::Delivery.create!(webhook: webhook, event: event, created_at: 8.days.ago)
|
|
|
|
Webhook::Delivery.cleanup
|
|
|
|
assert Webhook::Delivery.exists?(fresh_delivery.id)
|
|
assert_not Webhook::Delivery.exists?(stale_delivery.id)
|
|
end
|
|
|
|
test "renders the creator name when event creator is current user" do
|
|
webhook = Webhook.create!(
|
|
board: boards(:writebook),
|
|
name: "Basecamp",
|
|
url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/789/lines"
|
|
)
|
|
event = events(:logo_published)
|
|
delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
|
|
|
|
Current.session = sessions(:david)
|
|
|
|
request_stub = stub_request(:post, webhook.url)
|
|
.with { |request| CGI.parse(request.body)["content"].first.include?("David added") }
|
|
.to_return(status: 200)
|
|
|
|
delivery.deliver
|
|
|
|
assert_requested request_stub
|
|
end
|
|
|
|
test "renders creator name when event creator is not current user" do
|
|
webhook = Webhook.create!(
|
|
board: boards(:writebook),
|
|
name: "Basecamp",
|
|
url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/789/lines"
|
|
)
|
|
event = events(:logo_published)
|
|
delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
|
|
|
|
Current.session = sessions(:kevin)
|
|
|
|
request_stub = stub_request(:post, webhook.url)
|
|
.with { |request| CGI.parse(request.body)["content"].first.include?("David added") }
|
|
.to_return(status: 200)
|
|
|
|
delivery.deliver
|
|
|
|
assert_requested request_stub
|
|
end
|
|
|
|
test "blocks DNS rebinding attack where hostname resolves to private IP after validation" do
|
|
webhook = Webhook.create!(
|
|
board: boards(:writebook),
|
|
name: "Rebind Attack",
|
|
url: "https://rebind.attacker.example/webhook"
|
|
)
|
|
event = events(:layout_commented)
|
|
delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
|
|
|
|
# Stub DNS to return a private IP (simulating rebind to internal host)
|
|
stub_dns_resolution("169.254.169.254") # AWS IMDS link-local address
|
|
|
|
delivery.deliver
|
|
|
|
assert_equal "completed", delivery.state
|
|
assert_equal "private_uri", delivery.response[:error]
|
|
assert_not delivery.succeeded?
|
|
end
|
|
|
|
test "connects to the pinned IP address preventing DNS re-resolution" do
|
|
webhook = Webhook.create!(
|
|
board: boards(:writebook),
|
|
name: "Pinned IP",
|
|
url: "https://example.com/webhook"
|
|
)
|
|
event = events(:layout_commented)
|
|
delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
|
|
|
|
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).yields(response_mock).returns(response_mock)
|
|
|
|
Net::HTTP.expects(:new).with("example.com", 443).returns(http_mock)
|
|
|
|
delivery.deliver
|
|
|
|
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")
|
|
dns_mock.stubs(:each_address).multiple_yields(*ips)
|
|
Resolv::DNS.stubs(:open).yields(dns_mock)
|
|
end
|
|
end
|