mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
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:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user