Module: Insika::EgressGuard
- Defined in:
- lib/insika/egress_guard.rb
Overview
EGRESS guard for data-tools (SSRF). A data-tool makes a server-side HTTP request with a URL coming from UI-editable config — without a guard, it's an SSRF vector (hitting cloud metadata, internal services, localhost). Rules (spec NF2):
- https only by default (http requires explicit opt-in);
- host required;
- optional host allowlist (when present, only it passes);
- resolves the host and BLOCKS if ANY address falls into a private/
loopback/link-local/metadata network (defense against DNS rebinding);
- `allow_private:` (opt-in) ALLOWS the private target — to reach a trusted
INTERNAL API (NF4: the consumer's /api/internal/* comes in via an
allowlist). Dangerous without `host_allowlist`: PIN it to a known host.
Default false = strict guard.
violation(url, ...) returns nil (ok) or a String with the reason — the
DataDefinedTool turns the reason into { error: } to the model (never raises).
Constant Summary collapse
- BLOCKED =
[ "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.168.0.0/16", "198.18.0.0/15", "::1/128", "fc00::/7", "fe80::/10", "::ffff:0:0/96" ].map { |c| IPAddr.new(c) }.freeze
Class Method Summary collapse
- .blocked?(ip) ⇒ Boolean
- .ip_or_nil(str) ⇒ Object
-
.resolve(host) ⇒ Object
Literal host (IP) -> itself; hostname -> resolve via DNS.
-
.violation(url, allow_http: false, host_allowlist: nil, allow_private: false) ⇒ Object
-> nil (allowed) | String (block reason).
Class Method Details
.blocked?(ip) ⇒ Boolean
67 |
# File 'lib/insika/egress_guard.rb', line 67 def blocked?(ip) = BLOCKED.any? { |net| net.include?(ip) } |
.ip_or_nil(str) ⇒ Object
69 70 71 72 73 |
# File 'lib/insika/egress_guard.rb', line 69 def ip_or_nil(str) IPAddr.new(str) rescue IPAddr::InvalidAddressError nil end |
.resolve(host) ⇒ Object
Literal host (IP) -> itself; hostname -> resolve via DNS. -> [IPAddr].
58 59 60 61 62 63 64 65 |
# File 'lib/insika/egress_guard.rb', line 58 def resolve(host) literal = ip_or_nil(host.delete_prefix("[").delete_suffix("]")) return [literal] if literal Resolv.getaddresses(host).filter_map { |a| ip_or_nil(a) } rescue Resolv::ResolvError, SocketError [] end |
.violation(url, allow_http: false, host_allowlist: nil, allow_private: false) ⇒ Object
-> nil (allowed) | String (block reason).
34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
# File 'lib/insika/egress_guard.rb', line 34 def violation(url, allow_http: false, host_allowlist: nil, allow_private: false) uri = begin URI.parse(url.to_s) rescue URI::InvalidURIError return "invalid URL" end return "unsupported scheme" unless %w[http https].include?(uri.scheme) return "http not allowed (use https)" if uri.scheme == "http" && !allow_http host = uri.host return "missing host" if host.nil? || host.empty? return "host not in allowlist" if host_allowlist && !host_allowlist.include?(host) addrs = resolve(host) return "host did not resolve" if addrs.empty? # allow_private skips the private-network block (trusted internal API, # NF4). Without it, a private/loopback/metadata target is always blocked. return "private-network destination blocked" if !allow_private && addrs.any? { |ip| blocked?(ip) } nil end |