Normalize INSTANCE_DOMAIN configuration to require hostnames (#280)

* Ensure INSTANCE_DOMAIN configuration uses hostname

* Define ip_from_domain before use
This commit is contained in:
l5y
2025-10-11 19:39:05 +02:00
committed by GitHub
parent f1dba89d4b
commit 73bdd809bd
2 changed files with 157 additions and 61 deletions

View File

@@ -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\[(?<host>[^\]]+)\](?::(?<port>\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\[(?<host>[^\]]+)\](?::(?<port>\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]

View File

@@ -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])