1
0
مراية لـ https://github.com/postalserver/postal.git تم المزامنة 2026-06-03 21:45:48 +00:00
الملفات
postal/spec/services/webhook_delivery_service_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

143 أسطر
4.9 KiB
Ruby

# frozen_string_literal: true
require "rails_helper"
RSpec.describe WebhookDeliveryService do
let(:server) { create(:server) }
let(:webhook) { create(:webhook, server: server) }
let(:webhook_request) { create(:webhook_request, :locked, webhook: webhook) }
subject(:service) { described_class.new(webhook_request: webhook_request) }
let(:response_status) { 200 }
let(:response_body) { "OK" }
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)
end
describe "#call" do
it "sends a request to the webhook's url" do
service.call
expect(WebMock).to have_requested(:post, webhook.url).with({
body: {
event: webhook_request.event,
timestamp: webhook_request.created_at.to_f,
payload: webhook_request.payload,
uuid: webhook_request.uuid
}.to_json,
headers: {
"Content-Type" => "application/json",
"X-Postal-Signature" => /\A[a-z0-9\/+]+=*\z/i,
"X-Postal-Signature-256" => /\A[a-z0-9\/+]+=*\z/i,
"X-Postal-Signature-KID" => /\A[a-f0-9\/+]{64}\z/i
}
})
end
context "when the endpoint returns a 200 OK" do
it "creates a webhook request for the server" do
service.call
expect(server.message_db.webhooks.list(1)[:total]).to eq(1)
webhook_request = server.message_db.webhooks.list(1)[:records].first
expect(webhook_request).to have_attributes(
event: webhook_request.event,
url: webhook_request.url,
status_code: 200,
body: "OK",
uuid: webhook_request.uuid,
will_retry?: false,
payload: webhook_request.payload,
attempt: 1,
timestamp: webhook_request.timestamp
)
end
it "deletes the webhook request" do
service.call
expect { webhook_request.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
it "updates the last used at time on the webhook" do
frozen_time = Time.current.change(usec: 0)
Timecop.freeze(frozen_time) do
service.call
expect(webhook.reload.last_used_at).to eq(frozen_time)
end
end
end
context "when the request returns a 500 Internal Server Error for the first time" do
let(:response_status) { 500 }
let(:response_body) { "internal server error!" }
it "unlocks the webhook request if locked" do
expect { service.call }.to change { webhook_request.reload.locked? }.from(true).to(false)
end
it "updates the retry time and attempt counter" do
service.call
expect(webhook_request.reload.attempts).to eq(1)
expect(webhook_request.retry_after).to be_within(1.second).of(2.minutes.from_now)
end
end
context "when the request returns a 500 Internal Server Error for the second time" do
let(:webhook_request) { create(:webhook_request, :locked, webhook: webhook, attempts: 1) }
let(:response_status) { 500 }
let(:response_body) { "internal server error!" }
it "updates the retry time and attempt counter" do
service.call
expect(webhook_request.reload.attempts).to eq(2)
expect(webhook_request.retry_after).to be_within(1.second).of(3.minutes.from_now)
end
end
context "when the request returns a 500 Internal Server Error for the sixth time" do
let(:webhook_request) { create(:webhook_request, :locked, webhook: webhook, attempts: 5) }
let(:response_status) { 500 }
let(:response_body) { "internal server error!" }
it "creates a webhook request for the server" do
service.call
expect(server.message_db.webhooks.list(1)[:total]).to eq(1)
webhook_request = server.message_db.webhooks.list(1)[:records].first
expect(webhook_request).to have_attributes(
status_code: 500,
body: "internal server error!",
will_retry?: false,
attempt: 6
)
end
it "deletes the webhook request" do
service.call
expect { webhook_request.reload }.to raise_error(ActiveRecord::RecordNotFound)
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