From aba94b197df284223797521b965d0701aa1bc1e1 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Sun, 12 Oct 2025 09:22:54 +0200 Subject: [PATCH] Handle federation HTTPS CRL verification failures (#293) --- web/lib/potato_mesh/application/federation.rb | 50 ++++++-- web/spec/federation_spec.rb | 119 ++++++++++++++++++ 2 files changed, 161 insertions(+), 8 deletions(-) create mode 100644 web/spec/federation_spec.rb diff --git a/web/lib/potato_mesh/application/federation.rb b/web/lib/potato_mesh/application/federation.rb index d3c4f30..5e9736c 100644 --- a/web/lib/potato_mesh/application/federation.rb +++ b/web/lib/potato_mesh/application/federation.rb @@ -99,10 +99,7 @@ module PotatoMesh instance_uri_candidates(domain, "/api/instances").each do |uri| begin - http = Net::HTTP.new(uri.host, uri.port) - http.open_timeout = PotatoMesh::Config.remote_instance_http_timeout - http.read_timeout = PotatoMesh::Config.remote_instance_http_timeout - http.use_ssl = uri.scheme == "https" + http = build_remote_http_client(uri) response = http.start do |connection| request = Net::HTTP::Post.new(uri) request["Content-Type"] = "application/json" @@ -215,10 +212,7 @@ module PotatoMesh end def perform_instance_http_request(uri) - http = Net::HTTP.new(uri.host, uri.port) - http.open_timeout = PotatoMesh::Config.remote_instance_http_timeout - http.read_timeout = PotatoMesh::Config.remote_instance_http_timeout - http.use_ssl = uri.scheme == "https" + http = build_remote_http_client(uri) http.start do |connection| response = connection.request(Net::HTTP::Get.new(uri)) case response @@ -247,6 +241,46 @@ module PotatoMesh [nil, errors] 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) + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = PotatoMesh::Config.remote_instance_http_timeout + http.read_timeout = PotatoMesh::Config.remote_instance_http_timeout + http.use_ssl = uri.scheme == "https" + return http unless http.use_ssl? + + http.verify_mode = OpenSSL::SSL::VERIFY_PEER + http.min_version = :TLS1_2 if http.respond_to?(:min_version=) + store = remote_instance_cert_store + http.cert_store = store if store + http + end + + # Construct a certificate store that disables strict CRL enforcement. + # + # OpenSSL may fail remote requests when certificate revocation lists are + # unavailable from the issuing authority. The returned store mirrors the + # default system trust store while clearing CRL-related flags so that + # federation announcements gracefully succeed when CRLs cannot be fetched. + # + # @return [OpenSSL::X509::Store, nil] configured store or nil when setup fails. + def remote_instance_cert_store + return @remote_instance_cert_store if defined?(@remote_instance_cert_store) && @remote_instance_cert_store + + store = OpenSSL::X509::Store.new + store.set_default_paths + store.flags = 0 if store.respond_to?(:flags=) + @remote_instance_cert_store = store + rescue OpenSSL::X509::StoreError => e + debug_log( + "Failed to initialize certificate store for federation HTTP: #{e.message}", + ) + @remote_instance_cert_store = nil + end + def validate_well_known_document(document, domain, pubkey) unless document.is_a?(Hash) return [false, "document is not an object"] diff --git a/web/spec/federation_spec.rb b/web/spec/federation_spec.rb new file mode 100644 index 0000000..73dbd3e --- /dev/null +++ b/web/spec/federation_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "spec_helper" +require "net/http" +require "openssl" +require "uri" + +RSpec.describe PotatoMesh::App::Federation do + subject(:federation_helpers) do + Class.new do + extend PotatoMesh::App::Federation + + class << self + def debug_messages + @debug_messages ||= [] + end + + def debug_log(message) + debug_messages << message + end + + def reset_debug_messages + @debug_messages = [] + end + end + end + end + + before do + federation_helpers.instance_variable_set(:@remote_instance_cert_store, nil) + federation_helpers.reset_debug_messages + end + + describe ".remote_instance_cert_store" do + it "initializes the store with default paths and disables CRL checks" do + store_double = Class.new do + attr_reader :default_paths_called, :assigned_flags + + def set_default_paths + @default_paths_called = true + end + + def flags=(value) + @assigned_flags = value + end + + def respond_to_missing?(method_name, include_private = false) + method_name == :flags= || super + end + end.new + + allow(OpenSSL::X509::Store).to receive(:new).and_return(store_double) + + result = federation_helpers.remote_instance_cert_store + + expect(result).to eq(store_double) + expect(store_double.default_paths_called).to be(true) + expect(store_double.assigned_flags).to eq(0) + end + + it "memoizes the generated store" do + first = federation_helpers.remote_instance_cert_store + second = federation_helpers.remote_instance_cert_store + expect(second).to equal(first) + end + + it "logs and returns nil when initialization fails" do + allow(OpenSSL::X509::Store).to receive(:new).and_raise(OpenSSL::X509::StoreError, "boom") + + expect(federation_helpers.remote_instance_cert_store).to be_nil + expect(federation_helpers.debug_messages.last).to include("Failed to initialize certificate store") + end + end + + describe ".build_remote_http_client" do + let(:timeout) { 15 } + + before do + allow(PotatoMesh::Config).to receive(:remote_instance_http_timeout).and_return(timeout) + end + + it "configures SSL settings for HTTPS endpoints" do + uri = URI.parse("https://remote.example.com/api") + store = OpenSSL::X509::Store.new + allow(federation_helpers).to receive(:remote_instance_cert_store).and_return(store) + + http = federation_helpers.build_remote_http_client(uri) + + expect(http.use_ssl?).to be(true) + expect(http.open_timeout).to eq(timeout) + expect(http.read_timeout).to eq(timeout) + expect(http.cert_store).to eq(store) + expect(http.verify_mode).to eq(OpenSSL::SSL::VERIFY_PEER) + if http.respond_to?(:min_version) + expect(http.min_version).to eq(:TLS1_2) + end + end + + it "omits SSL configuration for HTTP endpoints" do + uri = URI.parse("http://remote.example.com/api") + + http = federation_helpers.build_remote_http_client(uri) + + expect(http.use_ssl?).to be(false) + expect(http.cert_store).to be_nil + expect(http.open_timeout).to eq(timeout) + expect(http.read_timeout).to eq(timeout) + end + + it "leaves the certificate store unset when unavailable" do + uri = URI.parse("https://remote.example.com/api") + allow(federation_helpers).to receive(:remote_instance_cert_store).and_return(nil) + + http = federation_helpers.build_remote_http_client(uri) + + expect(http.cert_store).to be_nil + end + end +end