diff --git a/app/models/http_endpoint.rb b/app/models/http_endpoint.rb index 8cd9044..393b785 100644 --- a/app/models/http_endpoint.rb +++ b/app/models/http_endpoint.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "uri" + # == Schema Information # # Table name: http_endpoints @@ -38,6 +40,7 @@ class HTTPEndpoint < ApplicationRecord validates :name, presence: true validates :url, presence: true + validate :url_must_be_http_or_https validates :encoding, inclusion: { in: ENCODINGS } validates :format, inclusion: { in: FORMATS } validates :timeout, numericality: { greater_than_or_equal_to: 5, less_than_or_equal_to: 60 } @@ -56,4 +59,17 @@ class HTTPEndpoint < ApplicationRecord routes.each { |r| r.update(endpoint: nil, mode: "Reject") } end + private + + def url_must_be_http_or_https + return if url.blank? + + uri = URI.parse(url) + return if uri.is_a?(URI::HTTP) && uri.host.present? + + errors.add(:url, "must be an HTTP or HTTPS URL") + rescue URI::InvalidURIError + errors.add(:url, "must be a valid HTTP or HTTPS URL") + end + end diff --git a/lib/postal/config_schema.rb b/lib/postal/config_schema.rb index e3c6415..871d094 100644 --- a/lib/postal/config_schema.rb +++ b/lib/postal/config_schema.rb @@ -92,6 +92,14 @@ module Postal transform { |ip| IPAddr.new(ip) } end + string :allowed_request_destinations do + array + description "Hostnames or IP/CIDR ranges that outbound webhook and HTTP " \ + "endpoint requests are permitted to reach even when they resolve " \ + "to a private, loopback, link-local or otherwise reserved address. " \ + "All other such destinations are blocked to prevent SSRF." + end + integer :queued_message_lock_stale_days do description "The number of days after which to consider a lock as stale. Messages with stale locks will be removed and not retried." default 1 diff --git a/lib/postal/http.rb b/lib/postal/http.rb index 8a32d9b..4f17feb 100644 --- a/lib/postal/http.rb +++ b/lib/postal/http.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "net/https" +require "resolv" require "uri" module Postal @@ -47,19 +48,24 @@ module Postal request["User-Agent"] = options[:user_agent] || "Postal/#{Postal.version}" - connection = Net::HTTP.new(uri.host, uri.port) - - if uri.scheme == "https" - connection.use_ssl = true - connection.verify_mode = OpenSSL::SSL::VERIFY_PEER - ssl = true - else - ssl = false - end + timeout = options[:timeout] || 60 + ssl = uri.scheme == "https" begin - timeout = options[:timeout] || 60 Timeout.timeout(timeout) do + connect_address = AddressGuard.safe_connect_address(uri.host) + + connection = Net::HTTP.new(uri.host, uri.port) + # Pin the connection to the address we validated above so that the socket + # cannot be redirected to a different (e.g. internal) address via a DNS + # rebinding race between the check and the connection. + connection.ipaddr = connect_address + + if uri.scheme == "https" + connection.use_ssl = true + connection.verify_mode = OpenSSL::SSL::VERIFY_PEER + end + result = connection.request(request) { code: result.code.to_i, @@ -68,6 +74,13 @@ module Postal secure: ssl } end + rescue BlockedDestinationError => e + { + code: -4, + body: e.message, + headers: {}, + secure: ssl + } rescue OpenSSL::SSL::SSLError { code: -3, @@ -75,7 +88,7 @@ module Postal headers: {}, secure: ssl } - rescue SocketError, Errno::ECONNRESET, EOFError, Errno::EINVAL, Errno::ENETUNREACH, Errno::EHOSTUNREACH, Errno::ECONNREFUSED => e + rescue Resolv::ResolvError, SocketError, SystemCallError, EOFError => e { code: -2, body: e.message, diff --git a/lib/postal/http/address_guard.rb b/lib/postal/http/address_guard.rb new file mode 100644 index 0000000..ed863d2 --- /dev/null +++ b/lib/postal/http/address_guard.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require "ipaddr" +require "resolv" +require "socket" + +module Postal + module HTTP + # Guards outbound HTTP requests against SSRF by resolving the destination + # host and refusing to connect to private, loopback, link-local, multicast + # or otherwise reserved addresses (for example cloud metadata endpoints). + # + # Administrators can permit specific destinations by adding hostnames or + # IP/CIDR ranges to the `postal.allowed_request_destinations` config option. + class AddressGuard + + # IP ranges that outbound requests are never allowed to reach unless the + # destination has been explicitly allowlisted. + BLOCKED_RANGES = [ + # IPv4 + "0.0.0.0/8", # "this host on this network" + "10.0.0.0/8", # RFC1918 private + "100.64.0.0/10", # RFC6598 carrier-grade NAT + "127.0.0.0/8", # loopback + "169.254.0.0/16", # link-local (incl. 169.254.169.254 metadata) + "172.16.0.0/12", # RFC1918 private + "192.0.0.0/24", # IETF protocol assignments + "192.168.0.0/16", # RFC1918 private + "198.18.0.0/15", # benchmarking + "224.0.0.0/4", # multicast + "240.0.0.0/4", # reserved + # IPv6 + "::/128", # unspecified + "::1/128", # loopback + "::ffff:0:0/96", # IPv4-mapped (also re-checked against the v4 list) + "fc00::/7", # unique-local + "fe80::/10", # link-local + "ff00::/8", # multicast + ].map { |range| IPAddr.new(range) }.freeze + + class << self + + # Resolve and validate the given host, returning the IP address the + # connection should be pinned to (as a string). Pinning the connection + # to the validated address prevents a DNS-rebinding race between the + # check here and the actual connection. + # + # @param [String] host the hostname or IP literal from the request URL + # @raise [Postal::HTTP::BlockedDestinationError] if the host cannot be + # resolved or any resolved address is not permitted + # @raise [SocketError] if the host only resolves to addresses whose + # family this server cannot reach (e.g. IPv6 with no IPv6 support) + # @return [String] the validated IP address to connect to + def safe_connect_address(host) + new(host).safe_connect_address + end + + # Whether this server has IPv6 connectivity (a global IPv6 address on + # one of its interfaces). Memoized as it does not change at runtime. + def ipv6_supported? + return @ipv6_supported unless @ipv6_supported.nil? + + @ipv6_supported = local_families.include?(:ipv6) + end + + # Whether this server has IPv4 connectivity. Defaults to true unless the + # host clearly only has IPv6, so that a host reporting no global + # addresses at all (e.g. inside a minimal container) still attempts IPv4 + # as it did before this guard existed. + def ipv4_supported? + return @ipv4_supported unless @ipv4_supported.nil? + + families = local_families + @ipv4_supported = families.include?(:ipv4) || !families.include?(:ipv6) + end + + private + + def local_families + families = [] + Socket.ip_address_list.each do |address| + families << :ipv4 if address.ipv4? && !address.ipv4_loopback? + families << :ipv6 if address.ipv6? && !address.ipv6_loopback? && !address.ipv6_linklocal? + end + families.uniq + end + + end + + # @param [String] host + def initialize(host) + @host = host.to_s + end + + def safe_connect_address + if @host.empty? + raise BlockedDestinationError, "No host was given for the request" + end + + addresses = resolve + if addresses.empty? + raise BlockedDestinationError, "Could not resolve '#{@host}' to any IP address" + end + + # Reject the whole request if *any* resolved address is blocked. This is + # checked before the reachability filtering below so that a blocked + # destination is always reported as such, regardless of which address + # families this particular server can reach. It also defeats DNS + # responses that mix a public and a private address to slip past. + addresses.each do |address| + next unless blocked?(address) + + raise BlockedDestinationError, + "Destination '#{@host}' (#{address}) is not permitted" + end + + # Only connect to an address whose family this server can actually + # reach. Otherwise we might pin the connection to an IPv6 address on a + # host without IPv6 connectivity and fail to connect even when a usable + # IPv4 address was available. + usable = addresses.select { |address| family_reachable?(address) } + if usable.empty? + raise SocketError, + "'#{@host}' only resolves to addresses this server cannot reach " \ + "(#{addresses.join(', ')})" + end + + # Prefer IPv4 for predictability; only use IPv6 when it is the only + # reachable option. + (usable.find(&:ipv4?) || usable.first).to_s + end + + private + + # @return [Array] + def resolve + return [IPAddr.new(@host)] if ip_literal? + + Resolv.getaddresses(@host).filter_map do |address| + IPAddr.new(address) + rescue IPAddr::InvalidAddressError + nil + end + end + + def ip_literal? + IPAddr.new(@host) + true + rescue IPAddr::InvalidAddressError + false + end + + # @param [IPAddr] address + def family_reachable?(address) + if address.ipv6? && !address.ipv4_mapped? + self.class.ipv6_supported? + else + self.class.ipv4_supported? + end + end + + # @param [IPAddr] address + def blocked?(address) + return false if allowlisted?(address) + + # IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) must be checked against the + # IPv4 rules using the embedded address, otherwise they bypass the list. + if address.ipv6? && address.ipv4_mapped? + mapped = address.native + return true if mapped.ipv4? && BLOCKED_RANGES.any? { |range| range.include?(mapped) } + end + + BLOCKED_RANGES.any? { |range| range.include?(address) } + end + + # @param [IPAddr] address + def allowlisted?(address) + allowlist.any? do |entry| + if entry.is_a?(IPAddr) + entry.include?(address) + else + entry.casecmp?(@host) + end + end + end + + # Allowlist entries are kept as strings in config. An entry that parses as + # an IP/CIDR is matched against the resolved address; anything else is + # matched against the request hostname (case-insensitively). + # + # @return [Array] + def allowlist + @allowlist ||= Array(Postal::Config.postal.allowed_request_destinations).map do |entry| + IPAddr.new(entry.to_s) + rescue IPAddr::InvalidAddressError + entry.to_s + end + end + + end + end +end diff --git a/lib/postal/http/blocked_destination_error.rb b/lib/postal/http/blocked_destination_error.rb new file mode 100644 index 0000000..c08a444 --- /dev/null +++ b/lib/postal/http/blocked_destination_error.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Postal + module HTTP + # Raised when an outbound request would be sent to an address that is not + # permitted (a private, loopback, link-local or otherwise reserved address + # that has not been explicitly allowlisted). Used as an SSRF guard. + class BlockedDestinationError < StandardError + end + end +end diff --git a/spec/lib/postal/http/address_guard_spec.rb b/spec/lib/postal/http/address_guard_spec.rb new file mode 100644 index 0000000..662534b --- /dev/null +++ b/spec/lib/postal/http/address_guard_spec.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Postal::HTTP::AddressGuard do + describe ".safe_connect_address" do + subject(:call) { described_class.safe_connect_address(host) } + + before do + allow(Postal::Config.postal).to receive(:allowed_request_destinations).and_return(allowlist) + end + + let(:allowlist) { [] } + + context "when given a public IP literal" do + let(:host) { "93.184.216.34" } + + it "returns the address to connect to" do + expect(call).to eq "93.184.216.34" + end + end + + context "when given a public IPv6 literal" do + let(:host) { "2606:2800:220:1:248:1893:25c8:1946" } + + it "returns the address to connect to" do + expect(call).to eq "2606:2800:220:1:248:1893:25c8:1946" + end + end + + [ + "127.0.0.1", + "10.0.0.1", + "172.16.5.4", + "192.168.1.1", + "169.254.169.254", # cloud metadata + "100.64.0.1", # carrier-grade NAT + "0.0.0.0", + "::1", + "fd00::1", # unique-local IPv6 + "fe80::1", # link-local IPv6 + "::ffff:127.0.0.1", # IPv4-mapped loopback + ].each do |blocked| + context "when given the blocked address #{blocked}" do + let(:host) { blocked } + + it "raises BlockedDestinationError" do + expect { call }.to raise_error(Postal::HTTP::BlockedDestinationError) + end + end + end + + context "when given a hostname that resolves to a public address" do + let(:host) { "example.com" } + + before do + allow(Resolv).to receive(:getaddresses).with(host).and_return(["93.184.216.34"]) + end + + it "returns the resolved address" do + expect(call).to eq "93.184.216.34" + end + end + + context "when given a hostname that resolves to a private address" do + let(:host) { "internal.example.com" } + + before do + allow(Resolv).to receive(:getaddresses).with(host).and_return(["10.1.2.3"]) + end + + it "raises BlockedDestinationError" do + expect { call }.to raise_error(Postal::HTTP::BlockedDestinationError) + end + end + + context "when a hostname resolves to both a public and a private address" do + let(:host) { "rebind.example.com" } + + before do + allow(Resolv).to receive(:getaddresses).with(host).and_return(["93.184.216.34", "127.0.0.1"]) + end + + it "raises BlockedDestinationError because one address is blocked" do + expect { call }.to raise_error(Postal::HTTP::BlockedDestinationError) + end + end + + context "when a hostname resolves to both IPv4 and IPv6 addresses" do + let(:host) { "dualstack.example.com" } + let(:ipv6) { "2606:2800:220:1:248:1893:25c8:1946" } + + before do + allow(Resolv).to receive(:getaddresses).with(host).and_return([ipv6, "93.184.216.34"]) + end + + context "and the server does not support IPv6" do + before { allow(described_class).to receive(:ipv6_supported?).and_return(false) } + + it "connects over IPv4" do + expect(call).to eq "93.184.216.34" + end + end + + context "and the server supports IPv6" do + before { allow(described_class).to receive(:ipv6_supported?).and_return(true) } + + it "still prefers IPv4 for predictability" do + expect(call).to eq "93.184.216.34" + end + end + end + + context "when a hostname resolves only to an IPv6 address" do + let(:host) { "v6only.example.com" } + let(:ipv6) { "2606:2800:220:1:248:1893:25c8:1946" } + + before do + allow(Resolv).to receive(:getaddresses).with(host).and_return([ipv6]) + end + + context "and the server does not support IPv6" do + before { allow(described_class).to receive(:ipv6_supported?).and_return(false) } + + it "raises a SocketError because the address is unreachable" do + expect { call }.to raise_error(SocketError) + end + end + + context "and the server supports IPv6" do + before { allow(described_class).to receive(:ipv6_supported?).and_return(true) } + + it "connects over IPv6" do + expect(call).to eq ipv6 + end + end + end + + context "when a hostname cannot be resolved" do + let(:host) { "nope.example.com" } + + before do + allow(Resolv).to receive(:getaddresses).with(host).and_return([]) + end + + it "raises BlockedDestinationError" do + expect { call }.to raise_error(Postal::HTTP::BlockedDestinationError, /resolve/) + end + end + + context "when the host is blank" do + let(:host) { "" } + + it "raises BlockedDestinationError" do + expect { call }.to raise_error(Postal::HTTP::BlockedDestinationError) + end + end + + context "when a blocked address is allowlisted by CIDR" do + let(:host) { "10.0.0.5" } + let(:allowlist) { ["10.0.0.0/8"] } + + it "returns the address" do + expect(call).to eq "10.0.0.5" + end + end + + context "when a blocked address is allowlisted by exact IP" do + let(:host) { "127.0.0.1" } + let(:allowlist) { ["127.0.0.1"] } + + it "returns the address" do + expect(call).to eq "127.0.0.1" + end + end + + context "when a hostname resolving to a private address is allowlisted by name" do + let(:host) { "internal.example.com" } + let(:allowlist) { ["internal.example.com"] } + + before do + allow(Resolv).to receive(:getaddresses).with(host).and_return(["10.1.2.3"]) + end + + it "returns the resolved address" do + expect(call).to eq "10.1.2.3" + end + end + end +end diff --git a/spec/lib/postal/http_spec.rb b/spec/lib/postal/http_spec.rb new file mode 100644 index 0000000..ce90ff9 --- /dev/null +++ b/spec/lib/postal/http_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Postal::HTTP do + before do + allow(Postal::Config.postal).to receive(:allowed_request_destinations).and_return([]) + end + + describe ".post" do + context "when the host resolves to a blocked address" do + before do + allow(Resolv).to receive(:getaddresses).with("internal.example.com").and_return(["127.0.0.1"]) + end + + it "does not make a request and returns a blocked-destination result" do + result = described_class.post("http://internal.example.com/hook", json: "{}") + expect(result[:code]).to eq(-4) + expect(result[:body]).to match(/not permitted/) + expect(WebMock).not_to have_requested(:post, "http://internal.example.com/hook") + end + end + + context "when resolving the host raises an error" do + before do + allow(Resolv).to receive(:getaddresses).with("example.com").and_raise(Resolv::ResolvError, "resolver failed") + end + + it "returns a connection error result" do + result = described_class.post("http://example.com/hook", json: "{}") + expect(result[:code]).to eq(-2) + expect(result[:body]).to match(/resolver failed/) + expect(WebMock).not_to have_requested(:post, "http://example.com/hook") + end + end + + context "when resolving the host exceeds the request timeout" do + before do + allow(Resolv).to receive(:getaddresses).with("example.com") do + sleep 0.2 + ["93.184.216.34"] + end + end + + it "returns a timeout result before making a request" do + result = described_class.post("http://example.com/hook", json: "{}", timeout: 0.05) + expect(result[:code]).to eq(-1) + expect(WebMock).not_to have_requested(:post, "http://example.com/hook") + end + end + + context "when the host resolves to a public address" do + before do + allow(Resolv).to receive(:getaddresses).with("example.com").and_return(["93.184.216.34"]) + stub_request(:post, "http://example.com/hook").to_return(status: 200, body: "OK") + end + + it "pins the connection to the validated address and performs the request" do + expect_any_instance_of(Net::HTTP).to receive(:ipaddr=).with("93.184.216.34").and_call_original + result = described_class.post("http://example.com/hook", json: "{}") + expect(result[:code]).to eq(200) + expect(WebMock).to have_requested(:post, "http://example.com/hook") + end + end + + context "when the blocked host is allowlisted" do + before do + allow(Postal::Config.postal).to receive(:allowed_request_destinations).and_return(["internal.example.com"]) + allow(Resolv).to receive(:getaddresses).with("internal.example.com").and_return(["10.0.0.5"]) + stub_request(:post, "http://internal.example.com/hook").to_return(status: 200, body: "OK") + end + + it "performs the request" do + result = described_class.post("http://internal.example.com/hook", json: "{}") + expect(result[:code]).to eq(200) + expect(WebMock).to have_requested(:post, "http://internal.example.com/hook") + end + end + end +end diff --git a/spec/models/http_endpoint_spec.rb b/spec/models/http_endpoint_spec.rb new file mode 100644 index 0000000..37a6881 --- /dev/null +++ b/spec/models/http_endpoint_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe HTTPEndpoint do + describe "validations" do + subject(:endpoint) { build(:http_endpoint, url: url) } + + [ + "https://example.com/messages/~user;v=1?token=a+b#section", + "http://example.com:8080/path?x=1&y=2", + "https://[2606:2800:220:1:248:1893:25c8:1946]/hook", + ].each do |valid_url| + context "with #{valid_url}" do + let(:url) { valid_url } + + it "is valid" do + expect(endpoint).to be_valid + end + end + end + + [ + "ftp://example.com/hook", + "https:///missing-host", + "not a url", + ].each do |invalid_url| + context "with #{invalid_url}" do + let(:url) { invalid_url } + + it "is invalid" do + expect(endpoint).not_to be_valid + expect(endpoint.errors[:url]).to be_present + end + end + end + end +end diff --git a/spec/services/webhook_delivery_service_spec.rb b/spec/services/webhook_delivery_service_spec.rb index 14b0f59..14a9fa2 100644 --- a/spec/services/webhook_delivery_service_spec.rb +++ b/spec/services/webhook_delivery_service_spec.rb @@ -13,6 +13,7 @@ RSpec.describe WebhookDeliveryService do let(:response_body) { "OK" } before do + allow(Resolv).to receive(:getaddresses).with("example.com").and_return(["93.184.216.34"]) stub_request(:post, webhook.url).to_return(status: response_status, body: response_body) end @@ -116,5 +117,26 @@ RSpec.describe WebhookDeliveryService do expect { webhook_request.reload }.to raise_error(ActiveRecord::RecordNotFound) end end + + context "when the webhook URL resolves to a blocked (private) address" do + let(:webhook_request) do + create(:webhook_request, :locked, webhook: webhook, url: "http://internal.example.com/hook") + end + + before do + allow(Resolv).to receive(:getaddresses).with("internal.example.com").and_return(["127.0.0.1"]) + end + + it "does not make a request to the destination" do + service.call + expect(WebMock).not_to have_requested(:post, "http://internal.example.com/hook") + end + + it "records the failure and schedules a retry" do + service.call + expect(webhook_request.reload.attempts).to eq(1) + expect(webhook_request.retry_after).to be_present + end + end end end