Class: RailsAiBridge::Registry::EndpointPolicy

Inherits:
Object
  • Object
show all
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

Constructor Details

#initialize(resolver:, allowed_hosts:, allowed_loopback_ports:, allow_private_networks:, timeout_seconds: nil, max_resolved_addresses: nil) ⇒ EndpointPolicy

Parameters:

  • resolver (#getaddresses)

    injected DNS resolver; returns an array of IP strings

  • allowed_hosts (Array<String>)

    exact allowed hostnames or public IP strings

  • allowed_loopback_ports (Array<Integer>)

    allowed loopback ports

  • allow_private_networks (Boolean)

    whether private/link-local network destinations are allowed

  • timeout_seconds (Numeric, nil) (defaults to: nil)

    per-call DNS resolution timeout in seconds; nil disables the timeout

  • max_resolved_addresses (Integer, nil) (defaults to: nil)

    maximum number of addresses to accept from DNS; nil disables the cap

Raises:



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.

Parameters:

  • endpoint (String)

    the raw endpoint URL

Returns:

  • (Result)

    a successful result with the canonical URI and approved addresses, or a failure result with a PolicyError



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