Class: Labimotion::AiEgressGuard
- Inherits:
-
Object
- Object
- Labimotion::AiEgressGuard
- 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
- .literal_ip?(str) ⇒ Boolean
- .parse(url) ⇒ Object
-
.public_ip?(ip) ⇒ Boolean
Public = not loopback/private/link-local and not in EXTRA_BLOCKED.
-
.resolve(host) ⇒ Object
All A/AAAA addresses for host as IPAddr; a literal IP resolves to itself.
- .safe_ipaddr(addr) ⇒ Object
-
.validate!(url) ⇒ Object
Raise BlockedError unless url is a public https endpoint.
Class Method Details
.literal_ip?(str) ⇒ 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.
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.
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 |