مراية لـ
https://github.com/postalserver/postal.git
تم المزامنة 2026-06-13 17:55:44 +00:00
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.
81 أسطر
3.1 KiB
Ruby
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
|