مراية لـ
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.
هذا الالتزام موجود في:
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
|
||||
المرجع في مشكلة جديدة
حظر مستخدم