Module: Surfguard
- Extended by:
- Surfguard
- Included in:
- Surfguard
- Defined in:
- lib/surfguard.rb,
lib/surfguard/version.rb
Overview
One SSRF address policy for a Ruby app that fetches a URL someone else supplied. It consolidates several drifting in-house copies of this policy — copies that had grown four different ideas of what "internal" means, including one that decoded a NAT64 prefix whose length is not recoverable from the address. This is their union, decided once.
Two policy decisions the copies drifted on, settled here and explained where they live in the code:
* Resolver (see .resolve_public_ips): first use getaddrinfo's numeric-only
parser so legacy decimal, hex, octal, and short IPv4 forms mean exactly
what they do to Net::HTTP; never send those tokens to DNS. Names resolve
with Resolv.getaddresses, which honours /etc/hosts and search domains AND
returns every address. One copy used Resolv.getaddress (honours hosts, but
only the FIRST address, so an AAAA-only host deterministically selected the
IPv6 path); another used Resolv::DNS.open (all addresses, but ignores
/etc/hosts, so it validated a different set than Net::HTTP would connect
to). getaddresses is complete for Ruby's hosts-plus-DNS chain; system
getaddrinfo may additionally consult NSS sources such as mDNS or LDAP.
* `no-aaaa` in Kamal `dns-opt` is NOT a mitigation. It is a glibc
getaddrinfo option; every guard here resolves through pure-Ruby Resolv,
which asks for AAAA regardless. IPv6 answers reach this code in production.
Do not treat the deploy config as if it narrowed the input.
THREAT MODEL / caller contract: this only resolves and classifies. It cannot stop DNS rebinding on its own. A caller must PIN the connection to an address this returned (Net::HTTP#ipaddr=), because a second lookup at connect time can answer differently than the one that was validated. The plural API exists so that a caller failing over between addresses iterates the validated list rather than resolving again inside its retry loop. A non-pinning caller must instead use .resolvable_public_ip?/.enforce_public_ip, which refuse unless EVERY resolved address is public.
Defined Under Namespace
Classes: Unresolvable, Violation
Constant Summary collapse
- DISALLOWED_IPV4 =
IPv4 special-use ranges and exact platform aliases that must never be a fetch target (RFC 5735/6890, plus CGNAT and benchmarking). RFC1918 / loopback / link-local are also covered by the IPAddr predicates in #disallowed_ipv4?; they are restated here so the policy is complete and auditable in one place.
[ IPAddr.new("0.0.0.0/8"), # "This" network (RFC 1122) IPAddr.new("10.0.0.0/8"), # Private (RFC 1918) IPAddr.new("100.64.0.0/10"), # Carrier-grade NAT (RFC 6598) IPAddr.new("127.0.0.0/8"), # Loopback (RFC 1122) IPAddr.new("168.63.129.16/32"), # Azure host-node WireServer virtual IP IPAddr.new("169.254.0.0/16"), # Link-local (RFC 3927) — includes the cloud metadata endpoint IPAddr.new("172.16.0.0/12"), # Private (RFC 1918) IPAddr.new("192.0.0.0/24"), # IETF protocol assignments (RFC 6890) IPAddr.new("192.0.2.0/24"), # TEST-NET-1 (RFC 5737) IPAddr.new("192.88.99.0/24"), # 6to4 relay anycast (RFC 7526) IPAddr.new("192.168.0.0/16"), # Private (RFC 1918) IPAddr.new("198.18.0.0/15"), # Benchmark testing (RFC 2544) IPAddr.new("198.51.100.0/24"), # TEST-NET-2 (RFC 5737) IPAddr.new("203.0.113.0/24"), # TEST-NET-3 (RFC 5737) IPAddr.new("224.0.0.0/4"), # Multicast (RFC 5771) IPAddr.new("240.0.0.0/4") # Reserved / future use (RFC 1112) ].freeze
- DISALLOWED_IPV6 =
IPv6 special-use ranges beyond what private? (ULA fc00::/7, incl. the IMDSv6 address fd00:ec2::254), loopback (::1) and link-local (fe80::/10) already cover. 6to4 and Teredo are deprecated transition mechanisms with no legitimate fetch target — 2002:7f00:1:: is just a 6to4 spelling of 127.0.0.1.
[ IPAddr.new("::/128"), # Unspecified (RFC 4291) IPAddr.new("100::/64"), # Discard-only (RFC 6666) IPAddr.new("100:0:0:1::/64"), # Dummy IPv6 destination (RFC 9780) IPAddr.new("2001::/32"), # Teredo (RFC 4380) IPAddr.new("2001:2::/48"), # Benchmark testing (RFC 5180) IPAddr.new("2001:db8::/32"), # Documentation (RFC 3849) IPAddr.new("2002::/16"), # 6to4 (RFC 3056) IPAddr.new("3fff::/20"), # Documentation (RFC 9637) IPAddr.new("5f00::/16"), # SRv6 SIDs, confined to SR domains (RFC 9602) IPAddr.new("fec0::/10"), # Deprecated site-local (RFC 3879) IPAddr.new("ff00::/8") # Multicast (RFC 4291) ].freeze
- IETF_PROTOCOL_ASSIGNMENTS =
The IANA parent assignment is non-source, non-destination, non-forwardable, and non-global unless a more-specific allocation says otherwise. Refuse it by default so unallocated/future-special space cannot become an internal fetch path. These are the current, narrowly scoped IP-layer services whose registrations explicitly permit globally reachable destinations without selecting nearby infrastructure. PCP, TURN, and DNS-SD SRP anycast remain refused because they deliberately target on-path or local services. ORCHID, ORCHIDv2, and DET are overlay identifiers, not ordinary IP-layer locators.
IPAddr.new("2001::/23")
- GLOBALLY_REACHABLE_IETF_ASSIGNMENTS =
[ IPAddr.new("2001:3::/32"), # AMT (RFC 7450) IPAddr.new("2001:4:112::/48") # AS112-v6 (RFC 7535) ].freeze
- NAT64_WELL_KNOWN =
NAT64 embeds an IPv4 target that must be re-checked as IPv4. The well-known prefix is a fixed /96, so the embedded octets are always the low 32 bits: decode and re-check them, and NAT64 to a public address still resolves.
IPAddr.new("64:ff9b::/96")
- NAT64_LOCAL_USE =
The RFC 8215 local-use block is refused whole, not decoded. It can host a Pref64 of any length (/32…/96) whose embedded position is NOT recoverable from the address alone (RFC 6052 §2.2), so decoding the low 32 bits reads the wrong octets and can under-block. It is also never globally routed, so there is no legitimate feed behind it. This is the divergence some in-house copies got wrong by decoding both prefixes the same way.
IPAddr.new("64:ff9b:1::/48")
- IPV4_TRANSLATABLE =
SIIT's IPv4-translated form is the third way an IPv4 address rides inside an IPv6 one, and the only one Ruby has no predicate for: ipv4_mapped?, ipv4_compat?, private?, loopback? and link_local? are all false for ::ffff:0:169.254.169.254, so it would reach the metadata address straight through the branches below. Note the extra group — ::ffff:0:0:0/96 is NOT the familiar IPv4-mapped ::ffff:0:0/96, and the two ranges do not overlap. Like the NAT64 well-known prefix it is a fixed /96, so decode the low 32 bits.
IPAddr.new("::ffff:0:0:0/96")
- VERSION =
"0.1.2"
Instance Method Summary collapse
-
#blocked_address?(ip) ⇒ Boolean
The classification core.
-
#enforce_public_ip(url) ⇒ Object
Raise unless the URL's host is safe, for call sites that want a hard stop rather than a boolean: Unresolvable when it answers with nothing, Violation when it answers with something we refuse.
-
#resolvable_public_ip?(url) ⇒ Boolean
True only if the URL's host resolves to at least one address and NONE are blocked.
-
#resolve_public_ip(url) ⇒ Object
The single-address compatibility shim for callers migrating from an older first-address-only guard.
-
#resolve_public_ips(host) ⇒ Object
Every PUBLIC address the host resolves to, IPv4 ahead of IPv6, DNS order preserved within each family so a provider's round-robin still spreads load.
Instance Method Details
#blocked_address?(ip) ⇒ Boolean
The classification core. True if this address must never be a fetch target. Accepts an IPAddr or anything IPAddr.new understands. Errs closed: an address it cannot parse is blocked.
203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 |
# File 'lib/surfguard.rb', line 203 def blocked_address?(ip) ipaddr = ip.is_a?(IPAddr) ? ip : IPAddr.new(ip.to_s) return true unless host_address?(ipaddr) # DNS never legitimately returns an IPv4 address embedded these two ways, so # refuse them regardless of the address they wrap. if ipaddr.ipv4_mapped? || ipaddr.ipv4_compat? true elsif ipaddr.ipv4? disallowed_ipv4?(ipaddr) elsif NAT64_LOCAL_USE.include?(ipaddr) true elsif NAT64_WELL_KNOWN.include?(ipaddr) || IPV4_TRANSLATABLE.include?(ipaddr) disallowed_ipv4?((ipaddr)) else disallowed_ipv6?(ipaddr) end rescue IPAddr::InvalidAddressError true end |
#enforce_public_ip(url) ⇒ Object
Raise unless the URL's host is safe, for call sites that want a hard stop rather than a boolean: Unresolvable when it answers with nothing, Violation when it answers with something we refuse. A malformed URL is a Violation -- there was never a lookup to fail.
176 177 178 179 180 181 182 |
# File 'lib/surfguard.rb', line 176 def enforce_public_ip(url) addresses = resolve(host_of(url)) raise Unresolvable, "No address for #{url}" if addresses.empty? raise Violation, "Refusing to fetch private/internal address for #{url}" if addresses.any? { |ip| blocked_address?(ip) } rescue URI::InvalidURIError, IPAddr::InvalidAddressError, ArgumentError raise Violation, "Refusing to fetch malformed address for #{url}" end |
#resolvable_public_ip?(url) ⇒ Boolean
True only if the URL's host resolves to at least one address and NONE are blocked. For non-pinning callers (they hand the hostname straight to Net::HTTP, which resolves again), so anything short of "every address is public" is unsafe. A predicate answers the question it was asked and doesn't raise: false covers unresolvable and malformed alike. Reach for .enforce_public_ip or .resolve_public_ip when you need to tell those apart.
165 166 167 168 169 170 |
# File 'lib/surfguard.rb', line 165 def resolvable_public_ip?(url) addresses = resolve(host_of(url)) addresses.any? && addresses.none? { |ip| blocked_address?(ip) } rescue URI::InvalidURIError, IPAddr::InvalidAddressError, ArgumentError false end |
#resolve_public_ip(url) ⇒ Object
The single-address compatibility shim for callers migrating from an older first-address-only guard. Returns the first public address as a String, nil if the host is malformed or resolves to anything blocked, and raises Unresolvable if it resolves to nothing -- which is where an older guard built on Resolv.getaddress raised Resolv::ResolvError, so callers that distinguished a lookup failure keep doing so. Prefer .resolve_public_ips.
190 191 192 193 194 195 196 197 198 |
# File 'lib/surfguard.rb', line 190 def resolve_public_ip(url) addresses = resolve(host_of(url)) raise Unresolvable, "No address for #{url}" if addresses.empty? return nil if addresses.any? { |ip| blocked_address?(ip) } addresses.first.to_s rescue URI::InvalidURIError, IPAddr::InvalidAddressError, ArgumentError nil end |
#resolve_public_ips(host) ⇒ Object
Every PUBLIC address the host resolves to, IPv4 ahead of IPv6, DNS order preserved within each family so a provider's round-robin still spreads load. Empty when the host resolves but every address is blocked; raises Unresolvable when it resolves to nothing at all, so the caller can tell a refusal from a lookup failure. A caller that fails over MUST iterate this list and pin each address; resolving again reopens the rebinding window. Accepts a hostname or an IP-literal host.
147 148 149 150 151 152 153 154 155 156 157 |
# File 'lib/surfguard.rb', line 147 def resolve_public_ips(host) addresses = resolve(host) raise Unresolvable, "No address for #{host}" if addresses.empty? addresses.reject { |ip| blocked_address?(ip) } .partition(&:ipv4?) .flatten .map(&:to_s) rescue InvalidHost [] end |