Class: Labimotion::AiEgressGuard

Inherits:
Object
  • Object
show all
Defined in:
lib/labimotion/libs/ai_egress_guard.rb

Overview

Validates a USER-SUPPLIED AI provider base URL before the server makes an outbound request to it, to prevent SSRF. A user may point the AI feature at their own OpenAI-compatible endpoint, but only at a PUBLIC https host — never at loopback / private / link-local / cloud-metadata addresses.

Known limitation: validation resolves DNS at check time; a hostile resolver could rebind between this check and the actual request (TOCTOU). Redirect following is disabled at the HTTP layer, which removes the most common bypass; pinning the connection to the validated IP would close the residual window.

Defined Under Namespace

Classes: BlockedError

Constant Summary collapse

EXTRA_BLOCKED =

Ranges Ruby's IPAddr#private?/#loopback?/#link_local? don't all cover: unspecified, CGNAT, benchmarking/test-net, IPv4-broadcast, reserved, and the IPv6 unique-local / link-local / multicast blocks.

[
  '0.0.0.0/8', '100.64.0.0/10', '192.0.0.0/24', '192.0.2.0/24',
  '198.18.0.0/15', '198.51.100.0/24', '203.0.113.0/24', '240.0.0.0/4',
  '255.255.255.255/32', '::/128', '::1/128', 'fc00::/7', 'fe80::/10', 'ff00::/8'
].map { |cidr| IPAddr.new(cidr) }.freeze

Class Method Summary collapse

Class Method Details

.literal_ip?(str) ⇒ Boolean

Returns:

  • (Boolean)


61
62
63
64
65
66
# File 'lib/labimotion/libs/ai_egress_guard.rb', line 61

def self.literal_ip?(str)
  IPAddr.new(str)
  true
rescue IPAddr::InvalidAddressError
  false
end

.parse(url) ⇒ Object



46
47
48
49
50
# File 'lib/labimotion/libs/ai_egress_guard.rb', line 46

def self.parse(url)
  URI.parse(url.to_s.strip)
rescue URI::InvalidURIError
  raise BlockedError, 'AI provider URL is not a valid URL'
end

.public_ip?(ip) ⇒ Boolean

Public = not loopback/private/link-local and not in EXTRA_BLOCKED.

Returns:

  • (Boolean)


76
77
78
79
80
81
82
# File 'lib/labimotion/libs/ai_egress_guard.rb', line 76

def self.public_ip?(ip)
  return false if ip.loopback? || ip.private? || ip.link_local?

  EXTRA_BLOCKED.none? { |net| net.include?(ip) }
rescue StandardError
  false
end

.resolve(host) ⇒ Object

All A/AAAA addresses for host as IPAddr; a literal IP resolves to itself.



53
54
55
56
57
58
59
# File 'lib/labimotion/libs/ai_egress_guard.rb', line 53

def self.resolve(host)
  return [IPAddr.new(host)] if literal_ip?(host)

  Resolv.getaddresses(host).filter_map { |addr| safe_ipaddr(addr) }
rescue StandardError
  []
end

.safe_ipaddr(addr) ⇒ Object



68
69
70
71
72
73
# File 'lib/labimotion/libs/ai_egress_guard.rb', line 68

def self.safe_ipaddr(addr)
  ip = IPAddr.new(addr.to_s)
  ip.ipv4_mapped? ? ip.native : ip
rescue StandardError
  nil
end

.validate!(url) ⇒ Object

Raise BlockedError unless url is a public https endpoint. Returns url.

Raises:



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/labimotion/libs/ai_egress_guard.rb', line 30

def self.validate!(url)
  uri = parse(url)
  raise BlockedError, 'AI provider URL must use https' unless uri.scheme == 'https'

  host = uri.host.to_s
  raise BlockedError, 'AI provider URL has no host' if host.empty?

  addresses = resolve(host)
  raise BlockedError, "AI provider host could not be resolved: #{host}" if addresses.empty?

  blocked = addresses.find { |ip| !public_ip?(ip) }
  raise BlockedError, "AI provider host resolves to a non-public address (#{blocked})" if blocked

  url
end