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):

- 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 (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

Class Method Details

.blocked?(ip) ⇒ Boolean

Returns:

  • (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,
  # 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