Skip to content

Commit 1dedff1

Browse files
committed
Security: close narrow window of exposure to DNS rebinding
1 parent cac0ca1 commit 1dedff1

File tree

3 files changed

+100
-20
lines changed

3 files changed

+100
-20
lines changed

app/models/ssrf_protection.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
module SsrfProtection
2+
extend self
3+
4+
DNS_RESOLUTION_TIMEOUT = 2
5+
6+
DISALLOWED_IP_RANGES = [
7+
IPAddr.new("0.0.0.0/8") # Broadcasts
8+
].freeze
9+
10+
def resolve_public_ip(hostname)
11+
ip_addresses = resolve_dns(hostname)
12+
public_ips = ip_addresses.reject { |ip| private_address?(ip) }
13+
public_ips.first&.to_s
14+
end
15+
16+
def private_address?(ip)
17+
ip = IPAddr.new(ip.to_s) unless ip.is_a?(IPAddr)
18+
ip.private? || ip.loopback? || ip.link_local? || ip.ipv4_mapped? || in_disallowed_range?(ip)
19+
end
20+
21+
private
22+
def resolve_dns(hostname)
23+
ip_addresses = []
24+
25+
Resolv::DNS.open(timeouts: DNS_RESOLUTION_TIMEOUT) do |dns|
26+
dns.each_address(hostname) do |ip_address|
27+
ip_addresses << IPAddr.new(ip_address.to_s)
28+
end
29+
end
30+
31+
ip_addresses
32+
end
33+
34+
def in_disallowed_range?(ip)
35+
DISALLOWED_IP_RANGES.any? { |range| range.include?(ip) }
36+
end
37+
end

app/models/webhook/delivery.rb

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,6 @@ class Webhook::Delivery < ApplicationRecord
22
STALE_TRESHOLD = 7.days
33
USER_AGENT = "fizzy/1.0.0 Webhook"
44
ENDPOINT_TIMEOUT = 7.seconds
5-
DNS_RESOLUTION_TIMEOUT = 2
6-
DISALLOWED_IP_RANGES = [
7-
# Broadcasts
8-
IPAddr.new("0.0.0.0/8")
9-
].freeze
105

116
belongs_to :account, default: -> { webhook.account }
127
belongs_to :webhook
@@ -54,14 +49,14 @@ def succeeded?
5449

5550
private
5651
def perform_request
57-
if private_uri?
52+
if resolved_ip.nil?
5853
{ error: :private_uri }
5954
else
6055
response = http.request(
6156
Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload }
6257
)
6358

64-
{ code: response.code.to_i }
59+
{ code: response.code.to_i }
6560
end
6661
rescue Resolv::ResolvTimeout, Resolv::ResolvError, SocketError
6762
{ error: :dns_lookup_failed }
@@ -73,26 +68,17 @@ def perform_request
7368
{ error: :failed_tls }
7469
end
7570

76-
def private_uri?
77-
ip_addresses = []
78-
79-
Resolv::DNS.open(timeouts: DNS_RESOLUTION_TIMEOUT) do |dns|
80-
dns.each_address(uri.host) do |ip_address|
81-
ip_addresses << IPAddr.new(ip_address)
82-
end
83-
end
84-
85-
ip_addresses.any? do |ip|
86-
ip.private? || ip.loopback? || ip.link_local? || ip.ipv4_mapped? || DISALLOWED_IP_RANGES.any? { |range| range.include?(ip) }
87-
end
71+
def resolved_ip
72+
return @resolved_ip if defined?(@resolved_ip)
73+
@resolved_ip = SsrfProtection.resolve_public_ip(uri.host)
8874
end
8975

9076
def uri
9177
@uri ||= URI(webhook.url)
9278
end
9379

9480
def http
95-
Net::HTTP.new(uri.host, uri.port).tap do |http|
81+
Net::HTTP.new(uri.host, uri.port, ipaddr: resolved_ip).tap do |http|
9682
http.use_ssl = (uri.scheme == "https")
9783
http.open_timeout = ENDPOINT_TIMEOUT
9884
http.read_timeout = ENDPOINT_TIMEOUT

test/models/webhook/delivery_test.rb

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
require "test_helper"
22

33
class Webhook::DeliveryTest < ActiveSupport::TestCase
4+
PUBLIC_TEST_IP = "93.184.216.34" # example.com's real IP, used as a public IP stand-in
5+
6+
setup do
7+
stub_dns_resolution(PUBLIC_TEST_IP)
8+
end
9+
410
test "create" do
511
webhook = webhooks(:active)
612
event = events(:layout_commented)
@@ -258,4 +264,55 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase
258264

259265
assert_requested request_stub
260266
end
267+
268+
test "blocks DNS rebinding attack where hostname resolves to private IP after validation" do
269+
webhook = Webhook.create!(
270+
board: boards(:writebook),
271+
name: "Rebind Attack",
272+
url: "https://rebind.attacker.example/webhook"
273+
)
274+
event = events(:layout_commented)
275+
delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
276+
277+
# Stub DNS to return a private IP (simulating rebind to internal host)
278+
stub_dns_resolution("169.254.169.254") # AWS IMDS link-local address
279+
280+
delivery.deliver
281+
282+
assert_equal "completed", delivery.state
283+
assert_equal "private_uri", delivery.response[:error]
284+
assert_not delivery.succeeded?
285+
end
286+
287+
test "connects to the pinned IP address preventing DNS re-resolution" do
288+
webhook = Webhook.create!(
289+
board: boards(:writebook),
290+
name: "Pinned IP",
291+
url: "https://example.com/webhook"
292+
)
293+
event = events(:layout_commented)
294+
delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
295+
296+
stub_dns_resolution(PUBLIC_TEST_IP)
297+
298+
# Verify Net::HTTP.new is called with the pinned IP
299+
http_mock = mock("http")
300+
http_mock.stubs(:use_ssl=)
301+
http_mock.stubs(:open_timeout=)
302+
http_mock.stubs(:read_timeout=)
303+
http_mock.stubs(:request).returns(stub(code: "200"))
304+
305+
Net::HTTP.expects(:new).with("example.com", 443, ipaddr: PUBLIC_TEST_IP).returns(http_mock)
306+
307+
delivery.deliver
308+
309+
assert delivery.succeeded?
310+
end
311+
312+
private
313+
def stub_dns_resolution(*ips)
314+
dns_mock = mock("dns")
315+
dns_mock.stubs(:each_address).multiple_yields(*ips)
316+
Resolv::DNS.stubs(:open).yields(dns_mock)
317+
end
261318
end

0 commit comments

Comments
 (0)