مراية لـ
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.
هذا الالتزام موجود في:
@@ -1,5 +1,7 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "uri"
|
||||||
|
|
||||||
# == Schema Information
|
# == Schema Information
|
||||||
#
|
#
|
||||||
# Table name: http_endpoints
|
# Table name: http_endpoints
|
||||||
@@ -38,6 +40,7 @@ class HTTPEndpoint < ApplicationRecord
|
|||||||
|
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
validates :url, presence: true
|
validates :url, presence: true
|
||||||
|
validate :url_must_be_http_or_https
|
||||||
validates :encoding, inclusion: { in: ENCODINGS }
|
validates :encoding, inclusion: { in: ENCODINGS }
|
||||||
validates :format, inclusion: { in: FORMATS }
|
validates :format, inclusion: { in: FORMATS }
|
||||||
validates :timeout, numericality: { greater_than_or_equal_to: 5, less_than_or_equal_to: 60 }
|
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") }
|
routes.each { |r| r.update(endpoint: nil, mode: "Reject") }
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -92,6 +92,14 @@ module Postal
|
|||||||
transform { |ip| IPAddr.new(ip) }
|
transform { |ip| IPAddr.new(ip) }
|
||||||
end
|
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
|
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."
|
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
|
default 1
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require "net/https"
|
require "net/https"
|
||||||
|
require "resolv"
|
||||||
require "uri"
|
require "uri"
|
||||||
|
|
||||||
module Postal
|
module Postal
|
||||||
@@ -47,19 +48,24 @@ module Postal
|
|||||||
|
|
||||||
request["User-Agent"] = options[:user_agent] || "Postal/#{Postal.version}"
|
request["User-Agent"] = options[:user_agent] || "Postal/#{Postal.version}"
|
||||||
|
|
||||||
connection = Net::HTTP.new(uri.host, uri.port)
|
timeout = options[:timeout] || 60
|
||||||
|
ssl = uri.scheme == "https"
|
||||||
if uri.scheme == "https"
|
|
||||||
connection.use_ssl = true
|
|
||||||
connection.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
||||||
ssl = true
|
|
||||||
else
|
|
||||||
ssl = false
|
|
||||||
end
|
|
||||||
|
|
||||||
begin
|
begin
|
||||||
timeout = options[:timeout] || 60
|
|
||||||
Timeout.timeout(timeout) do
|
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)
|
result = connection.request(request)
|
||||||
{
|
{
|
||||||
code: result.code.to_i,
|
code: result.code.to_i,
|
||||||
@@ -68,6 +74,13 @@ module Postal
|
|||||||
secure: ssl
|
secure: ssl
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
rescue BlockedDestinationError => e
|
||||||
|
{
|
||||||
|
code: -4,
|
||||||
|
body: e.message,
|
||||||
|
headers: {},
|
||||||
|
secure: ssl
|
||||||
|
}
|
||||||
rescue OpenSSL::SSL::SSLError
|
rescue OpenSSL::SSL::SSLError
|
||||||
{
|
{
|
||||||
code: -3,
|
code: -3,
|
||||||
@@ -75,7 +88,7 @@ module Postal
|
|||||||
headers: {},
|
headers: {},
|
||||||
secure: ssl
|
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,
|
code: -2,
|
||||||
body: e.message,
|
body: e.message,
|
||||||
|
|||||||
202
lib/postal/http/address_guard.rb
Normal file
202
lib/postal/http/address_guard.rb
Normal file
@@ -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
|
||||||
11
lib/postal/http/blocked_destination_error.rb
Normal file
11
lib/postal/http/blocked_destination_error.rb
Normal file
@@ -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
|
||||||
190
spec/lib/postal/http/address_guard_spec.rb
Normal file
190
spec/lib/postal/http/address_guard_spec.rb
Normal file
@@ -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
|
||||||
80
spec/lib/postal/http_spec.rb
Normal file
80
spec/lib/postal/http_spec.rb
Normal file
@@ -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
|
||||||
38
spec/models/http_endpoint_spec.rb
Normal file
38
spec/models/http_endpoint_spec.rb
Normal file
@@ -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" }
|
let(:response_body) { "OK" }
|
||||||
|
|
||||||
before do
|
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)
|
stub_request(:post, webhook.url).to_return(status: response_status, body: response_body)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -116,5 +117,26 @@ RSpec.describe WebhookDeliveryService do
|
|||||||
expect { webhook_request.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
expect { webhook_request.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||||
end
|
end
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|||||||
المرجع في مشكلة جديدة
حظر مستخدم