1
0
مراية لـ https://github.com/postalserver/postal.git تم المزامنة 2026-06-13 17:55:44 +00:00
الملفات
postal/spec/lib/postal/http_spec.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

81 أسطر
3.1 KiB
Ruby

# 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