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.
This commit is contained in:
Adam Cooke
2026-06-03 15:09:18 +01:00
parent 4314a6ec1e
commit 11c9814474
9 changed files with 591 additions and 11 deletions
+16
View File
@@ -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