Surfguard
One SSRF address policy for Ruby apps that fetch a URL someone else supplied.
It consolidates several drifting in-house copies of this policy into one — 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 gem is their union, decided once and tested against the IPv4 × IPv6 range matrix it enforces.
Installation
gem "surfguard"
What it does
Resolve and classify only. It cannot stop DNS rebinding by itself — the caller owns the fetch and must pin the connection to an address this returned.
# Pinning caller (preferred): validate, then pin each address you try.
Surfguard.resolve_public_ips("feeds.example.com")
# => ["93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"] (IPv4 first, blocked removed)
# Iterate THIS list on failover; do not resolve again inside a retry loop.
# Non-pinning preflight (hands the hostname to Net::HTTP, which resolves again):
Surfguard.resolvable_public_ip?("https://feeds.example.com/atom") # => true only if EVERY address is public
Surfguard.enforce_public_ip(url) # raises otherwise
# Single-address compatibility shim:
Surfguard.resolve_public_ip(url) # => "93.184.216.34" or nil
# The classification core, if you already hold an address:
Surfguard.blocked_address?(IPAddr.new("169.254.169.254")) # => true
The non-pinning helpers conservatively require every address in their lookup to be public. They do not bind the later connection to that answer, so a second lookup can still change underneath them. Pinning is required when attacker-controlled DNS or DNS rebinding is in scope.
The direct address APIs accept endpoint addresses, not networks. A prefix shorter than /32 or
/128 is refused rather than normalized into a host: blocked_address?("10.0.0.1/8") is true,
and resolve_public_ips("10.0.0.1/8") is [].
Caller responsibilities
Surfguard classifies an address; it is not an HTTP client. The URL helpers do not restrict schemes, follow redirects, or make the request. Allow only the schemes your fetcher supports, and validate and pin every redirect target just as you did the original target. A pinning client must connect to the selected returned address while retaining the original hostname for HTTP Host and TLS identity.
Refused vs. unresolvable
"We refuse that address" and "the host didn't answer" are different answers, and
collapsing them bites callers that treat a refusal as permanent. A webhook that
deactivates a customer's endpoint on Violation would retire it on one bad DNS
minute if a failed lookup arrived the same way.
| resolves to something public | resolves, all blocked | resolves to nothing | malformed URL | |
|---|---|---|---|---|
resolve_public_ips (takes a host) |
the public addresses | [] |
raises Unresolvable |
— |
resolve_public_ip |
first public address | nil |
raises Unresolvable |
nil |
enforce_public_ip |
returns | raises Violation |
raises Unresolvable |
raises Violation |
resolvable_public_ip? |
true |
false |
false |
false |
Unresolvable is not a subclass of Violation — that's the whole point.
Rescue both where you don't care which it was. Note the predicate answers the
question it was asked and never raises; use enforce_public_ip or
resolve_public_ip when you need to tell the cases apart.
The policy
| Range | Handling |
|---|---|
| IPv4 private (10/8, 172.16/12, 192.168/16), loopback (127/8), link-local (169.254/16) | refuse |
| CGNAT (100.64/10), benchmark (198.18/15), TEST-NETs, IETF (192.0.0/24), 6to4 relay anycast (192.88.99/24), multicast (224/4), reserved (240/4), "this" (0/8) | refuse |
Azure WireServer 168.63.129.16/32 |
refuse — a fixed Azure platform/fabric alias, not an RFC special-use range |
IPv6 ULA (fc00::/7, incl. IMDSv6 fd00:ec2::254), loopback (::1), link-local (fe80::/10), site-local (fec0::/10), multicast (ff00::/8), unspecified (::), discard/dummy (100::/64, 100:0:0:1::/64), documentation (2001:db8::/32, 3fff::/20), SRv6 SID (5f00::/16) |
refuse |
| IETF protocol assignments (2001::/23) | refuse by default, including Teredo, benchmark, PCP/TURN/DNS-SD anycast to nearby infrastructure, deprecated ORCHID, ORCHIDv2, DET, and unallocated space; allow only AMT (2001:3::/32) and AS112-v6 (2001:4:112::/48) |
IPv4-mapped ::ffff:0:0/96, IPv4-compatible ::/96 |
refuse outright |
SIIT ::ffff:0:0:0/96 |
decode embedded IPv4 (low 32 bits), re-check |
NAT64 well-known 64:ff9b::/96 |
decode embedded IPv4 (low 32 bits), re-check |
RFC 8215 NAT64 local-use 64:ff9b:1::/48 |
refuse outright — the Pref64 length is not recoverable from the address (RFC 6052 §2.2), so a low-32-bit decode reads the wrong octets; and the block is never globally routed |
6to4 2002::/16 |
refuse (a 6to4 address is just an IPv4 address in disguise) |
This is an SSRF address policy, not a label-only copy of the IANA special-purpose registry. It refuses internal, non-destination, non-global domain, and overlay-identifier ranges plus transition encodings that can conceal a blocked target. Actual globally reachable IP-layer services such as AMT and AS112 remain valid; ORCHID and DET identifiers do not, because they can be interpreted by a local overlay rather than routed as ordinary public IP destinations.
Three things worth knowing
1. Numeric parsing and name resolution are both part of the policy. Before asking DNS,
Surfguard asks the system numeric-host parser used by Socket/Net::HTTP whether the token is an
address. This recognizes non-canonical decimal, hexadecimal, octal, and shortened IPv4 forms. A
numeric token is classified directly and is never sent through DNS or a search domain.
Names resolve with Resolv.getaddresses, which uses Ruby's usual hosts-plus-DNS chain, honours
search domains, and returns every address. The obvious alternatives each drop something a guard
can't afford to lose:
Resolv.getaddresshonours/etc/hostsbut returns only the first address — so an AAAA-only host deterministically takes the IPv6 path, and a multi-homed host is validated on one address while the connection may use another.Resolv::DNS.openreturns every DNS address but ignores/etc/hostsand the other parts of the default resolver chain.
Ruby Resolv is not a universal substitute for the system getaddrinfo name-service chain. A host
with custom NSS sources such as mDNS or LDAP can produce a different answer at connection time.
Pinning a returned address avoids that second name lookup; non-pinning deployments need a resolver
configuration in which Ruby's hosts-plus-DNS answers match the connection layer, or an independent
egress control. Resolver-chain equivalence does not eliminate the separate DNS-rebinding window.
2. A resolver-level "no AAAA" switch is not a mitigation. Disabling AAAA at the system resolver
(for example Kamal's dns-opt: no-aaaa) is a glibc getaddrinfo option. Surfguard resolves through
pure-Ruby Resolv, which requests AAAA regardless, so IPv6 answers still reach it. Don't treat that
deploy setting as if it narrowed Surfguard's input.
3. Custom DNS64 prefixes need their own enforcement. Surfguard can decode and re-check the
fixed NAT64 well-known prefix 64:ff9b::/96, and it refuses the RFC 8215 local-use block outright.
It cannot infer the embedded IPv4 address in an arbitrary network-specific Pref64 from the
synthesized IPv6 address alone. A deployment using DNS64 with another Pref64 must enforce the same
blocked-address policy at its DNS64/NAT64 or egress layer.
Testing
ruby -Ilib test/surfguard_test.rb
# The full BLOCKED/ALLOWED matrix, checked as execution. Bare Ruby, no gems needed.
Security
Surfguard is a security control, so classification bugs are vulnerabilities. Report them privately per the security policy — not the public issue tracker.
Status
Extracted and consolidated from several in-house SSRF guards, tested against the full policy matrix. Resolve-and-classify only; callers pin. See the releases page for versions and changes.