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

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.

Returns:

  • (Boolean)


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?(embedded_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.

Returns:

  • (Boolean)


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