diff --git a/web/app.rb b/web/app.rb index 1a9403b..458e149 100644 --- a/web/app.rb +++ b/web/app.rb @@ -107,19 +107,142 @@ def fetch_config_string(key, default) trimmed.empty? ? default : trimmed end +# Convert a value into a trimmed string or return ``nil`` when blank. +# +# @param value [Object] +# @return [String, nil] +def string_or_nil(value) + return nil if value.nil? + + str = value.is_a?(String) ? value : value.to_s + trimmed = str.strip + trimmed.empty? ? nil : trimmed +end + +# Normalise domain strings supplied by remote instances or configuration inputs. +# +# @param value [Object] untrusted domain string. +# @return [String, nil] canonical domain without schemes or paths. +def sanitize_instance_domain(value) + host = string_or_nil(value) + return nil unless host + + trimmed = host.strip + trimmed = trimmed.delete_suffix(".") while trimmed.end_with?(".") + return nil if trimmed.empty? + return nil if trimmed.match?(%r{[\s/\\@]}) + + trimmed +end + +# Extract the hostname component from an instance domain string, handling IPv6 +# literals and optional port suffixes. +# +# @param domain [String] +# @return [String, nil] +def instance_domain_host(domain) + return nil if domain.nil? + + candidate = domain.strip + return nil if candidate.empty? + + if candidate.start_with?("[") + match = candidate.match(/\A\[(?[^\]]+)\](?::(?\d+))?\z/) + return match[:host] if match + return nil + end + + host, port = candidate.split(":", 2) + if port && !host.include?(":") && port.match?(/\A\d+\z/) + return host + end + + candidate +end + +# Parse an IP address when the provided domain represents an address literal. +# +# @param domain [String] +# @return [IPAddr, nil] +def ip_from_domain(domain) + host = instance_domain_host(domain) + return nil unless host + + IPAddr.new(host) +rescue IPAddr::InvalidAddressError + nil +end + # Attempt to resolve the instance's vanity domain from configuration or reverse # DNS lookup. # # @return [Array<(String, Symbol)>] pair containing the resolved domain (or # ``nil``) and the source used (:environment, :reverse_dns, :unknown). +def canonicalize_configured_instance_domain(raw) + return nil if raw.nil? + + trimmed = raw.to_s.strip + return nil if trimmed.empty? + + candidate = trimmed + + if candidate.include?("://") + begin + uri = URI.parse(candidate) + rescue URI::InvalidURIError => e + raise "INSTANCE_DOMAIN must be a valid hostname or URL, but parsing #{candidate.inspect} failed: #{e.message}" + end + + unless uri.host + raise "INSTANCE_DOMAIN URL must include a hostname: #{candidate.inspect}" + end + + if uri.userinfo + raise "INSTANCE_DOMAIN URL must not include credentials: #{candidate.inspect}" + end + + if uri.path && !uri.path.empty? && uri.path != "/" + raise "INSTANCE_DOMAIN URL must not include a path component: #{candidate.inspect}" + end + + if uri.query || uri.fragment + raise "INSTANCE_DOMAIN URL must not include query or fragment data: #{candidate.inspect}" + end + + hostname = uri.hostname + unless hostname + raise "INSTANCE_DOMAIN URL must include a hostname: #{candidate.inspect}" + end + + candidate = hostname + port = uri.port + if port && (!uri.respond_to?(:default_port) || uri.default_port.nil? || port != uri.default_port) + candidate = "#{candidate}:#{port}" + elsif port && uri.to_s.match?(/:\d+/) + candidate = "#{candidate}:#{port}" + end + end + + sanitized = sanitize_instance_domain(candidate) + unless sanitized + raise "INSTANCE_DOMAIN must be a bare hostname (optionally with a port) without schemes or paths: #{raw.inspect}" + end + + if (ip = ip_from_domain(sanitized)) + raise "INSTANCE_DOMAIN must resolve to a DNS hostname, not an IP address: #{sanitized}" + end + + sanitized.downcase +end + def determine_instance_domain raw = ENV["INSTANCE_DOMAIN"] if raw - trimmed = raw.strip - return [trimmed, :environment] unless trimmed.empty? + canonical = canonicalize_configured_instance_domain(raw) + return [canonical, :environment] if canonical end - reverse = reverse_dns_domain + reverse = sanitize_instance_domain(reverse_dns_domain) return [reverse, :reverse_dns] if reverse public_ip = discover_public_ip_address @@ -654,7 +777,10 @@ end # # @return [String, nil] def self_instance_domain - sanitize_instance_domain(INSTANCE_DOMAIN) || sanitize_instance_domain(discover_local_ip_address) + sanitized = sanitize_instance_domain(INSTANCE_DOMAIN) + return sanitized if sanitized + + raise "INSTANCE_DOMAIN could not be determined" end # Assemble the canonical attributes advertised for this instance when @@ -920,14 +1046,6 @@ end # # @param value [Object] raw value to normalize. # @return [String, nil] string when present or nil for empty inputs. -def string_or_nil(value) - return nil if value.nil? - - str = value.is_a?(String) ? value : value.to_s - trimmed = str.strip - trimmed.empty? ? nil : trimmed -end - # Convert values into integers while tolerating hexadecimal and float inputs. # # @param value [Object] input converted to an integer when possible. @@ -1002,22 +1120,6 @@ def coerce_boolean(value) end end -# Normalise domain strings supplied by remote instances. -# -# @param value [Object] untrusted domain string. -# @return [String, nil] canonical domain without schemes or paths. -def sanitize_instance_domain(value) - host = string_or_nil(value) - return nil unless host - - trimmed = host.strip - trimmed = trimmed.delete_suffix(".") while trimmed.end_with?(".") - return nil if trimmed.empty? - return nil if trimmed.match?(%r{[\s/\\@]}) - - trimmed -end - # Normalise PEM-encoded public keys while preserving their structure. # # @param value [Object] @@ -1036,39 +1138,6 @@ end # # @param domain [String] # @return [String, nil] host portion suitable for IP parsing. -def instance_domain_host(domain) - return nil if domain.nil? - - candidate = domain.strip - return nil if candidate.empty? - - if candidate.start_with?("[") - match = candidate.match(/\A\[(?[^\]]+)\](?::(?\d+))?\z/) - return match[:host] if match - return nil - end - - host, port = candidate.split(":", 2) - if port && !host.include?(":") && port.match?(/\A\d+\z/) - return host - end - - candidate -end - -# Parse an IP address when the provided domain represents an address literal. -# -# @param domain [String] -# @return [IPAddr, nil] -def ip_from_domain(domain) - host = instance_domain_host(domain) - return nil unless host - - IPAddr.new(host) -rescue IPAddr::InvalidAddressError - nil -end - # Determine whether an IP address belongs to a restricted network range. # # @param ip [IPAddr] diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index 60b9bd4..1297623 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -272,6 +272,33 @@ RSpec.describe "Potato Mesh Sinatra app" do expect(source).to eq(:environment) end + it "normalises scheme-based environment overrides" do + ENV["INSTANCE_DOMAIN"] = " https://Example.Org " + + domain, source = determine_instance_domain + + expect(domain).to eq("example.org") + expect(source).to eq(:environment) + end + + it "rejects IP addresses configured via the environment" do + ENV["INSTANCE_DOMAIN"] = "203.0.113.40" + + expect { determine_instance_domain }.to raise_error( + RuntimeError, + /INSTANCE_DOMAIN must resolve to a DNS hostname/, + ) + end + + it "rejects instance domains containing path components" do + ENV["INSTANCE_DOMAIN"] = "https://example.org/app" + + expect { determine_instance_domain }.to raise_error( + RuntimeError, + /must not include a path component/, + ) + end + it "falls back to reverse DNS when available" do address = Addrinfo.ip("203.0.113.10") allow(Socket).to receive(:ip_address_list).and_return([address])