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
-
.allowed_ip?(ip) ⇒ Boolean
Outside production, a private-network address (e.g. a self-hosted model on another machine on the LAN) is allowed; loopback/link-local/other EXTRA_BLOCKED ranges (cloud metadata etc.) stay blocked regardless of env.
- .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 (http is also accepted outside production, e.g. against a local dev provider).
Class Method Details
.allowed_ip?(ip) ⇒ Boolean
Outside production, a private-network address (e.g. a self-hosted model on another machine on the LAN) is allowed; loopback/link-local/other EXTRA_BLOCKED ranges (cloud metadata etc.) stay blocked regardless of env.
90 91 92 93 94 95 96 97 |
# File 'lib/labimotion/libs/ai_egress_guard.rb', line 90 def self.allowed_ip?(ip) return public_ip?(ip) if Rails.env.production? return false if ip.loopback? || ip.link_local? EXTRA_BLOCKED.none? { |net| net.include?(ip) } rescue StandardError false end |
.literal_ip?(str) ⇒ Boolean
64 65 66 67 68 69 |
# File 'lib/labimotion/libs/ai_egress_guard.rb', line 64 def self.literal_ip?(str) IPAddr.new(str) true rescue IPAddr::InvalidAddressError false end |
.parse(url) ⇒ Object
49 50 51 52 53 |
# File 'lib/labimotion/libs/ai_egress_guard.rb', line 49 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.
79 80 81 82 83 84 85 |
# File 'lib/labimotion/libs/ai_egress_guard.rb', line 79 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.
56 57 58 59 60 61 62 |
# File 'lib/labimotion/libs/ai_egress_guard.rb', line 56 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
71 72 73 74 75 76 |
# File 'lib/labimotion/libs/ai_egress_guard.rb', line 71 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 (http is also accepted outside production, e.g. against a local dev provider). Returns url.
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
# File 'lib/labimotion/libs/ai_egress_guard.rb', line 31 def self.validate!(url) uri = parse(url) allowed_schemes = Rails.env.production? ? %w[https] : %w[https http] raise BlockedError, "AI provider URL must use #{allowed_schemes.join(' or ')}" unless allowed_schemes.include?(uri.scheme) 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| !allowed_ip?(ip) } raise BlockedError, "AI provider host resolves to a non-public address (#{blocked})" if blocked url end |