Guard federation DNS resolution against restricted networks (#362)

* Guard federation DNS resolution against restricted networks

* Pin federation HTTP clients to vetted IPs
This commit is contained in:
l5y
2025-10-16 21:15:34 +02:00
committed by GitHub
parent 7055444c4b
commit a6ba9a8227
4 changed files with 134 additions and 0 deletions

View File

@@ -588,12 +588,52 @@ module PotatoMesh
visited
end
# Resolve the host component of a remote URI and ensure the destination is
# safe for federation HTTP requests.
#
# The method performs a DNS lookup using Addrinfo to capture every
# available address for the supplied URI host. The resulting addresses are
# converted to {IPAddr} objects for consistent inspection via
# {restricted_ip_address?}. When all resolved addresses fall within
# restricted ranges, the method raises an ArgumentError so callers can
# abort the federation request before contacting the remote endpoint.
#
# @param uri [URI::Generic] remote endpoint candidate.
# @return [Array<IPAddr>] list of resolved, unrestricted IP addresses.
# @raise [ArgumentError] when +uri.host+ is blank or resolves solely to
# restricted addresses.
def resolve_remote_ip_addresses(uri)
host = uri&.host
raise ArgumentError, "URI missing host" unless host
addrinfo_records = Addrinfo.getaddrinfo(host, nil, Socket::AF_UNSPEC, Socket::SOCK_STREAM)
addresses = addrinfo_records.filter_map do |addr|
begin
IPAddr.new(addr.ip_address)
rescue IPAddr::InvalidAddressError
nil
end
end
unique_addresses = addresses.uniq { |ip| [ip.family, ip.to_s] }
unrestricted_addresses = unique_addresses.reject { |ip| restricted_ip_address?(ip) }
if unique_addresses.any? && unrestricted_addresses.empty?
raise ArgumentError, "restricted domain"
end
unrestricted_addresses
end
# Build an HTTP client configured for communication with a remote instance.
#
# @param uri [URI::Generic] target URI describing the remote endpoint.
# @return [Net::HTTP] HTTP client ready to execute the request.
def build_remote_http_client(uri)
remote_addresses = resolve_remote_ip_addresses(uri)
http = Net::HTTP.new(uri.host, uri.port)
if http.respond_to?(:ipaddr=) && remote_addresses.any?
http.ipaddr = remote_addresses.first.to_s
end
http.open_timeout = PotatoMesh::Config.remote_instance_http_timeout
http.read_timeout = PotatoMesh::Config.remote_instance_read_timeout
http.use_ssl = uri.scheme == "https"

View File

@@ -173,6 +173,22 @@ module PotatoMesh
halt 400, { error: "restricted domain" }.to_json
end
begin
resolve_remote_ip_addresses(URI.parse("https://#{attributes[:domain]}"))
rescue ArgumentError => e
warn_log(
"Instance registration rejected",
context: "ingest.register",
domain: attributes[:domain],
reason: "restricted domain",
error_message: e.message,
)
halt 400, { error: "restricted domain" }.to_json
rescue SocketError
# DNS lookups that fail to resolve are handled later when the
# registration flow attempts to contact the remote instance.
end
well_known, well_known_meta = fetch_instance_json(attributes[:domain], "/.well-known/potato-mesh")
unless well_known
details_list = Array(well_known_meta).map(&:to_s)

View File

@@ -20,6 +20,7 @@ require "json"
require "time"
require "base64"
require "uri"
require "socket"
RSpec.describe "Potato Mesh Sinatra app" do
let(:app) { Sinatra::Application }
@@ -1255,6 +1256,45 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
end
it "rejects registrations when DNS resolves to restricted addresses" do
restricted_addrinfo = Addrinfo.ip("127.0.0.1")
allow(Addrinfo).to receive(:getaddrinfo).and_return([restricted_addrinfo])
warning_calls = []
allow_any_instance_of(Sinatra::Application).to receive(:warn_log).and_wrap_original do |method, *args, **kwargs|
warning_calls << [args, kwargs]
method.call(*args, **kwargs)
end
allow_any_instance_of(Sinatra::Application).to receive(:fetch_instance_json) do
raise "fetch_instance_json should not be called for restricted domains"
end
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "restricted domain")
expect(warning_calls).to include(
[
["Instance registration rejected"],
hash_including(
context: "ingest.register",
domain: domain,
reason: "restricted domain",
),
],
)
with_db(readonly: true) do |db|
stored = db.get_first_value(
"SELECT COUNT(*) FROM instances WHERE id = ?",
[instance_attributes[:id]],
)
expect(stored).to eq(0)
end
end
it "accepts bracketed IPv6 domains" do
ipv6_domain = "[2001:db8::1]"
ipv6_attributes = instance_attributes.merge(domain: ipv6_domain)

View File

@@ -17,6 +17,7 @@ require "net/http"
require "openssl"
require "set"
require "uri"
require "socket"
RSpec.describe PotatoMesh::App::Federation do
subject(:federation_helpers) do
@@ -129,10 +130,12 @@ RSpec.describe PotatoMesh::App::Federation do
describe ".build_remote_http_client" do
let(:connect_timeout) { 5 }
let(:read_timeout) { 12 }
let(:public_addrinfo) { Addrinfo.ip("203.0.113.5") }
before do
allow(PotatoMesh::Config).to receive(:remote_instance_http_timeout).and_return(connect_timeout)
allow(PotatoMesh::Config).to receive(:remote_instance_read_timeout).and_return(read_timeout)
allow(Addrinfo).to receive(:getaddrinfo).and_return([public_addrinfo])
end
it "configures SSL settings for HTTPS endpoints" do
@@ -176,6 +179,32 @@ RSpec.describe PotatoMesh::App::Federation do
expect(http.cert_store).to be_nil
expect(http.verify_callback).to be_nil
end
it "rejects URIs that resolve exclusively to restricted addresses" do
uri = URI.parse("https://loopback.mesh/api")
allow(Addrinfo).to receive(:getaddrinfo).and_return([Addrinfo.ip("127.0.0.1")])
expect do
federation_helpers.build_remote_http_client(uri)
end.to raise_error(ArgumentError, "restricted domain")
end
it "binds the HTTP client to the first unrestricted address" do
uri = URI.parse("https://remote.example.com/api")
allow(Addrinfo).to receive(:getaddrinfo).and_return([
Addrinfo.ip("127.0.0.1"),
public_addrinfo,
Addrinfo.ip("10.0.0.3"),
])
http = federation_helpers.build_remote_http_client(uri)
if http.respond_to?(:ipaddr)
expect(http.ipaddr).to eq("203.0.113.5")
else
skip "Net::HTTP#ipaddr accessor unavailable"
end
end
end
describe ".ingest_known_instances_from!" do
@@ -312,6 +341,15 @@ RSpec.describe PotatoMesh::App::Federation do
federation_helpers.send(:perform_instance_http_request, uri)
end.to raise_error(PotatoMesh::App::InstanceFetchError, "Net::ReadTimeout")
end
it "wraps restricted address resolution failures" do
allow(federation_helpers).to receive(:build_remote_http_client).and_call_original
allow(Addrinfo).to receive(:getaddrinfo).and_return([Addrinfo.ip("127.0.0.1")])
expect do
federation_helpers.send(:perform_instance_http_request, uri)
end.to raise_error(PotatoMesh::App::InstanceFetchError, "ArgumentError: restricted domain")
end
end
describe ".announce_instance_to_domain" do