Class: RailsAiBridge::Registry::EndpointPolicy
- Inherits:
-
Object
- Object
- RailsAiBridge::Registry::EndpointPolicy
- Defined in:
- lib/rails_ai_bridge/registry/endpoint_policy.rb
Overview
Validates an external provider endpoint against the configured host allowlist, scheme rules, and network restrictions. It returns a typed result rather than raising, keeping policy decisions explicit and testable without network I/O.
Defined Under Namespace
Classes: Result
Instance Method Summary collapse
-
#call(endpoint) ⇒ Result
Checks the endpoint against scheme, host, and network policy.
- #initialize(resolver:, allowed_hosts:, allowed_loopback_ports:, allow_private_networks:, timeout_seconds: nil, max_resolved_addresses: nil) ⇒ EndpointPolicy constructor
Constructor Details
#initialize(resolver:, allowed_hosts:, allowed_loopback_ports:, allow_private_networks:, timeout_seconds: nil, max_resolved_addresses: nil) ⇒ EndpointPolicy
40 41 42 43 44 45 46 47 48 49 50 51 |
# File 'lib/rails_ai_bridge/registry/endpoint_policy.rb', line 40 def initialize(resolver:, allowed_hosts:, allowed_loopback_ports:, allow_private_networks:, timeout_seconds: nil, max_resolved_addresses: nil) @resolver = resolver @allowed_hosts = allowed_hosts.map { |host| normalize_host(host.to_s) }.freeze @allowed_loopback_ports = allowed_loopback_ports.map(&:to_i).freeze @allow_private_networks = allow_private_networks @timeout_seconds = timeout_seconds @max_resolved_addresses = max_resolved_addresses return unless @max_resolved_addresses && (!@max_resolved_addresses.is_a?(Integer) || @max_resolved_addresses <= 0) raise RailsAiBridge::ConfigurationError, "max_resolved_addresses must be a positive integer, got #{max_resolved_addresses.inspect}" end |
Instance Method Details
#call(endpoint) ⇒ Result
Checks the endpoint against scheme, host, and network policy.
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 |
# File 'lib/rails_ai_bridge/registry/endpoint_policy.rb', line 58 def call(endpoint) uri = URI.parse(endpoint) return failure('endpoint must not include credentials') if uri.userinfo scheme = uri.scheme.to_s.downcase raw_host = uri.host return failure("unsupported scheme #{scheme.inspect}") unless %w[https http].include?(scheme) return failure('endpoint is missing a host') if raw_host.to_s.empty? host = normalize_host(raw_host) host_label = host.inspect raw_addresses = resolve_with_timeout(host).map(&:to_s) address_count = raw_addresses.length return failure("no addresses resolved for #{host_label}") if address_count.zero? return failure('endpoint resolved to too many addresses') if @max_resolved_addresses && address_count > @max_resolved_addresses approved = filter_addresses(raw_addresses, uri, host) # Fail closed when any resolved address is rejected. Approving only the # permitted subset would still let an unpinned transport re-resolve DNS # and connect to a blocked address, so every answer must pass policy. if approved.empty? failure('endpoint is not permitted by policy') elsif approved.length < address_count failure('endpoint resolved to a mix of permitted and blocked addresses') else # AC-2b: remote HTTPS is restricted to the default port so an allowlisted # host cannot be used to reach arbitrary ports on that host. non_default_https = scheme == 'https' && uri.port != URI::HTTPS::DEFAULT_PORT remote_endpoint = !approved.all? { |raw| loopback_address?(raw) } return failure('remote HTTPS must use the default port 443') if non_default_https && remote_endpoint return failure('plain HTTP is only permitted for loopback or private endpoints') if scheme == 'http' && !plaintext_permitted?(approved) Result.new(success: true, error: nil, uri: canonicalize(uri), addresses: approved) end rescue URI::Error failure('endpoint is not a valid URL') rescue Timeout::Error # DNS or resolver-level timeouts are operation timeouts, not policy # rejections. Return a typed timeout result so callers can classify # them correctly instead of treating them as policy failures. Result.new(success: false, error: RailsAiBridge::Registry::TimeoutError.new('endpoint resolution timed out')) rescue Resolv::ResolvError, SocketError, IPAddr::Error resolve_failure rescue RailsAiBridge::Registry::TimeoutError # Re-raise client-level timeout exceptions so callers can classify the # whole policy evaluation as a timeout, not a policy rejection. raise rescue StandardError => error log_error(error) resolve_failure end |