1
1
مراية لـ https://github.com/postalserver/postal.git تم المزامنة 2026-07-23 10:41:54 +00:00
الملفات
postal/lib/postal/http.rb
Adam Cooke 11c9814474 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.
2026-06-03 15:09:18 +01:00

110 أسطر
3.1 KiB
Ruby

# frozen_string_literal: true
require "net/https"
require "resolv"
require "uri"
module Postal
module HTTP
def self.get(url, options = {})
request(Net::HTTP::Get, url, options)
end
def self.post(url, options = {})
request(Net::HTTP::Post, url, options)
end
def self.request(method, url, options = {})
options[:headers] ||= {}
uri = URI.parse(url)
request = method.new((uri.path.empty? ? "/" : uri.path) + (uri.query ? "?" + uri.query : ""))
options[:headers].each { |k, v| request.add_field k, v }
if options[:username] || uri.user
request.basic_auth(options[:username] || uri.user, options[:password] || uri.password)
end
if options[:params].is_a?(Hash)
# If params has been provided, sent it them as form encoded values
request.set_form_data(options[:params])
elsif options[:json].is_a?(String)
# If we have a JSON string, set the content type and body to be the JSON
# data
request.add_field "Content-Type", "application/json"
request.body = options[:json]
elsif options[:text_body]
# Add a plain text body if we have one
request.body = options[:text_body]
end
if options[:sign]
request.add_field "X-Postal-Signature-KID", Postal.signer.jwk.kid
request.add_field "X-Postal-Signature", Postal.signer.sha1_sign64(request.body.to_s)
request.add_field "X-Postal-Signature-256", Postal.signer.sign64(request.body.to_s)
end
request["User-Agent"] = options[:user_agent] || "Postal/#{Postal.version}"
timeout = options[:timeout] || 60
ssl = uri.scheme == "https"
begin
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,
body: result.body,
headers: result.to_hash,
secure: ssl
}
end
rescue BlockedDestinationError => e
{
code: -4,
body: e.message,
headers: {},
secure: ssl
}
rescue OpenSSL::SSL::SSLError
{
code: -3,
body: "Invalid SSL certificate",
headers: {},
secure: ssl
}
rescue Resolv::ResolvError, SocketError, SystemCallError, EOFError => e
{
code: -2,
body: e.message,
headers: {},
secure: ssl
}
rescue Timeout::Error
{
code: -1,
body: "Timed out after #{timeout}s",
headers: {},
secure: ssl
}
end
end
end
end