Module: Angarium::AddressPolicy
- Defined in:
- lib/angarium/address_policy.rb
Overview
Central SSRF policy. Decides whether a destination IP is permitted for a given endpoint, composing three controls:
1. endpoint.allowed_networks (CIDR list) -> if present, ONLY those are allowed
2. endpoint.allow_private_network -> bypass the private-IP denylist
3. Angarium.config.block_private_ips -> default denylist of private ranges
Constant Summary collapse
- SPECIAL_USE_RANGES =
IANA special-use ranges that are unsafe webhook destinations but that Ruby's IPAddr predicates (loopback?/private?/link_local?/unique_local?) do NOT flag. Without these, e.g.
https://0.0.0.1/(routes to localhost on Linux) orhttps://[64:ff9b::a9fe:a9fe]/(NAT64 of the cloud metadata IP) would pass validation and be delivered to. Documentation ranges (192.0.2.0/24, 2001:db8::/32, etc.) are intentionally omitted: they aren't an SSRF vector and blocking them would reject the TEST-NET fixtures. [ "0.0.0.0/8", # "this host" — routes to localhost on Linux "100.64.0.0/10", # CGNAT (RFC 6598) "192.0.0.0/24", # IETF protocol assignments (RFC 6890) "198.18.0.0/15", # benchmarking (RFC 2544) "240.0.0.0/4", # reserved / future use, incl. 255.255.255.255 broadcast "64:ff9b::/96", # NAT64 well-known prefix (RFC 6052) "64:ff9b:1::/48" # NAT64 local-use prefix (RFC 8215) ].map { |cidr| IPAddr.new(cidr) }.freeze
Class Method Summary collapse
- .cidr_include?(cidr, ip) ⇒ Boolean
-
.host_permitted_for_validation?(host, endpoint) ⇒ Boolean
Resolve host and return true only if EVERY resolved address is allowed.
-
.ip_allowed?(ip, endpoint) ⇒ Boolean
Is a single IP (String or IPAddr) permitted for this endpoint?.
-
.normalize(ip) ⇒ Object
Collapse IPv4-mapped IPv6 (::ffff:x.x.x.x) to native IPv4 so the predicates above see the real address; leave other addresses untouched.
- .private?(ip) ⇒ Boolean
- .resolve(host) ⇒ Object
- .special_use?(ip) ⇒ Boolean
- .to_ipaddr(value) ⇒ Object
Class Method Details
.cidr_include?(cidr, ip) ⇒ Boolean
102 103 104 105 106 |
# File 'lib/angarium/address_policy.rb', line 102 def cidr_include?(cidr, ip) IPAddr.new(cidr.to_s).include?(ip) rescue IPAddr::InvalidAddressError false end |
.host_permitted_for_validation?(host, endpoint) ⇒ Boolean
Resolve host and return true only if EVERY resolved address is allowed. Unresolvable hosts return [] and this returns true (can't prove disallowed); callers that need strictness at connect time check each resolved IP instead.
40 41 42 |
# File 'lib/angarium/address_policy.rb', line 40 def host_permitted_for_validation?(host, endpoint) resolve(host).all? { |ip| ip_allowed?(ip, endpoint) } end |
.ip_allowed?(ip, endpoint) ⇒ Boolean
Is a single IP (String or IPAddr) permitted for this endpoint?
Two independent gates, both must pass:
Gate A (private denylist): private/loopback/link-local addresses are
blocked unless endpoint.allow_private_network is set. An allowlist entry
does NOT by itself unlock a private address.
Gate B (allowlist): when endpoint.allowed_networks is non-empty, the
address must fall within one of those CIDRs.
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
# File 'lib/angarium/address_policy.rb', line 21 def ip_allowed?(ip, endpoint) ip = to_ipaddr(ip) return false unless ip if private?(ip) && Angarium.config.block_private_ips return false unless endpoint.allow_private_network end allowlist = Array(endpoint.allowed_networks).reject(&:blank?) unless allowlist.empty? return false unless allowlist.any? { |cidr| cidr_include?(cidr, ip) } end true end |
.normalize(ip) ⇒ Object
Collapse IPv4-mapped IPv6 (::ffff:x.x.x.x) to native IPv4 so the predicates above see the real address; leave other addresses untouched.
96 97 98 99 100 |
# File 'lib/angarium/address_policy.rb', line 96 def normalize(ip) ip.respond_to?(:native) ? ip.native : ip rescue ip end |
.private?(ip) ⇒ Boolean
81 82 83 84 85 86 87 88 |
# File 'lib/angarium/address_policy.rb', line 81 def private?(ip) ip = normalize(ip) return true if ip.to_i.zero? # 0.0.0.0 / :: (unspecified -> localhost on Linux) ip.loopback? || ip.private? || ip.link_local? || (ip.respond_to?(:unique_local?) && ip.unique_local?) || special_use?(ip) end |
.resolve(host) ⇒ Object
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
# File 'lib/angarium/address_policy.rb', line 44 def resolve(host) literal = to_ipaddr(host) return [literal] if literal # Bound DNS with config.dns_timeout via an explicit Resolv::DNS — an # unbounded lookup lets a host with a deliberately slow authoritative # nameserver tie up a delivery worker well past our HTTP timeouts. With the # hosts file enabled (default), wrap it in a Resolv that consults # /etc/hosts first (so an internally-pinned endpoint resolves, and a hosts # hit short-circuits DNS); otherwise resolve DNS-only. dns = Resolv::DNS.new dns.timeouts = Angarium.config.dns_timeout if Angarium.config.dns_timeout resolvers = Angarium.config.resolve_dns_with_hosts_file ? [Resolv::Hosts.new, dns] : [dns] Resolv.new(resolvers).getaddresses(host).filter_map { |a| to_ipaddr(a.to_s) } rescue [] ensure dns&.close end |
.special_use?(ip) ⇒ Boolean
90 91 92 |
# File 'lib/angarium/address_policy.rb', line 90 def special_use?(ip) SPECIAL_USE_RANGES.any? { |range| range.family == ip.family && range.include?(ip) } end |
.to_ipaddr(value) ⇒ Object
108 109 110 111 112 113 |
# File 'lib/angarium/address_policy.rb', line 108 def to_ipaddr(value) ip = value.is_a?(IPAddr) ? value : IPAddr.new(value.to_s) normalize(ip) rescue IPAddr::InvalidAddressError nil end |