1
0
مراية لـ https://github.com/postalserver/postal.git تم المزامنة 2026-06-03 21:45:48 +00:00

fix(http): prevent SSRF in outbound webhook and HTTP endpoint requests

Webhook and HTTP message endpoint deliveries both flow through
Postal::HTTP, which parsed the user-supplied URL and connected to its
host with no address validation. An authenticated user could point a
webhook or endpoint at a private, loopback or link-local address (e.g.
127.0.0.1, 169.254.169.254 cloud metadata, RFC1918 hosts) and make the
server issue requests into its own internal network.

Add Postal::HTTP::AddressGuard, which resolves the destination host and
rejects private/loopback/link-local/reserved/multicast IPv4 and IPv6
addresses, then pins the connection to the validated address so it cannot
be redirected via a DNS-rebinding race. Administrators can permit specific
destinations via the new postal.allowed_request_destinations config option
(hostnames or IP/CIDR ranges).

Address selection only uses families this server can actually reach so we
do not pin to an IPv6 address on a host without IPv6 connectivity; IPv4 is
preferred for predictability. HTTPEndpoint now validates that its URL is a
well-formed HTTP(S) URL with a host.
هذا الالتزام موجود في:
Adam Cooke
2026-06-03 14:15:48 +01:00
الأصل 4314a6ec1e
التزام 11c9814474
9 ملفات معدلة مع 591 إضافات و11 حذوفات

عرض الملف

@@ -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

عرض الملف

@@ -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

عرض الملف

@@ -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,

عرض الملف

@@ -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<IPAddr>]
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<IPAddr, String>]
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

عرض الملف

@@ -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

عرض الملف

@@ -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

عرض الملف

@@ -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

عرض الملف

@@ -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

عرض الملف

@@ -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