mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
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:
191
web/app.rb
191
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\[(?<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]
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user