surfguard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 743b735d0f0abcce2bcfa3660b7f658cceb1a760b90d970a86df76e91cb19622
4
+ data.tar.gz: 866a867fbde519a8512ae93c4fb6d8051d01b7de1591210e7f40f071376860a0
5
+ SHA512:
6
+ metadata.gz: c3c1b340115fa7bc1190bec41e76ae2171ec23dfab10855122ddc91c3efb74864cdeed7960c4ab6c8d60770f04f520712e36118bf5c39750486b69a9f735f310
7
+ data.tar.gz: 587059deea1f701b3a8df14558a7b85c4dc24deb229a4fb61bdb02358c4120647c2e2624da498d4ce15658cbe5de77a1e394af50bf8acd8ac5f9ec080ee6cdef
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 37signals LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # Surfguard
2
+
3
+ [![CI](https://github.com/basecamp/surfguard/actions/workflows/ci.yml/badge.svg)](https://github.com/basecamp/surfguard/actions/workflows/ci.yml)
4
+
5
+ One SSRF address policy for Ruby apps that fetch a URL someone else supplied.
6
+
7
+ It consolidates several drifting in-house copies of this policy into one — copies that had grown
8
+ four different ideas of what "internal" means, including one that decoded a NAT64 prefix whose length
9
+ is not recoverable from the address. This gem is their union, decided once and tested against the
10
+ full IPv4 × IPv6 range matrix.
11
+
12
+ ## Installation
13
+
14
+ Not yet published to RubyGems — `v0.1.0` will be the first packaged release. Until it ships,
15
+ install from source:
16
+
17
+ ```ruby
18
+ gem "surfguard", github: "basecamp/surfguard"
19
+ ```
20
+
21
+ Once `v0.1.0` is published:
22
+
23
+ ```ruby
24
+ gem "surfguard"
25
+ ```
26
+
27
+ ## What it does
28
+
29
+ Resolve **and classify only**. It cannot stop DNS rebinding by itself — the caller owns the fetch
30
+ and must **pin** the connection to an address this returned.
31
+
32
+ ```ruby
33
+ # Pinning caller (preferred): validate, then pin each address you try.
34
+ Surfguard.resolve_public_ips("feeds.example.com")
35
+ # => ["93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"] (IPv4 first, blocked removed)
36
+ # Iterate THIS list on failover; do not resolve again inside a retry loop.
37
+
38
+ # Non-pinning caller (hands the hostname straight to Net::HTTP, which resolves again):
39
+ Surfguard.resolvable_public_ip?("https://feeds.example.com/atom") # => true only if EVERY address is public
40
+ Surfguard.enforce_public_ip(url) # raises otherwise
41
+
42
+ # Single-address compatibility shim:
43
+ Surfguard.resolve_public_ip(url) # => "93.184.216.34" or nil
44
+
45
+ # The classification core, if you already hold an address:
46
+ Surfguard.blocked_address?(IPAddr.new("169.254.169.254")) # => true
47
+ ```
48
+
49
+ ## Refused vs. unresolvable
50
+
51
+ "We refuse that address" and "the host didn't answer" are different answers, and
52
+ collapsing them bites callers that treat a refusal as permanent. A webhook that
53
+ deactivates a customer's endpoint on `Violation` would retire it on one bad DNS
54
+ minute if a failed lookup arrived the same way.
55
+
56
+ | | resolves to something public | resolves, all blocked | resolves to nothing | malformed URL |
57
+ |---|---|---|---|---|
58
+ | `resolve_public_ips` (takes a host) | the public addresses | `[]` | raises `Unresolvable` | — |
59
+ | `resolve_public_ip` | first public address | `nil` | raises `Unresolvable` | `nil` |
60
+ | `enforce_public_ip` | returns | raises `Violation` | raises `Unresolvable` | raises `Violation` |
61
+ | `resolvable_public_ip?` | `true` | `false` | `false` | `false` |
62
+
63
+ `Unresolvable` is **not** a subclass of `Violation` — that's the whole point.
64
+ Rescue both where you don't care which it was. Note the predicate answers the
65
+ question it was asked and never raises; use `enforce_public_ip` or
66
+ `resolve_public_ip` when you need to tell the cases apart.
67
+
68
+ ## The policy
69
+
70
+ | Range | Handling |
71
+ |---|---|
72
+ | IPv4 private (10/8, 172.16/12, 192.168/16), loopback (127/8), link-local (169.254/16) | refuse |
73
+ | 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 |
74
+ | IPv6 ULA (fc00::/7, incl. IMDSv6 `fd00:ec2::254`), loopback (::1), link-local (fe80::/10), site-local (fec0::/10), multicast (ff00::/8), unspecified (::), discard (100::/64), Teredo (2001::/32), docs (2001:db8::/32), benchmark (2001:2::/48) | refuse |
75
+ | IPv4-mapped `::ffff:0:0/96`, IPv4-compatible `::/96` | refuse outright |
76
+ | **SIIT `::ffff:0:0:0/96`** | decode embedded IPv4 (low 32 bits), re-check |
77
+ | NAT64 well-known `64:ff9b::/96` | decode embedded IPv4 (low 32 bits), re-check |
78
+ | **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 |
79
+ | 6to4 `2002::/16` | refuse (a 6to4 address is just an IPv4 address in disguise) |
80
+
81
+ ## Two things worth knowing
82
+
83
+ **1. The resolver is part of the policy.** Surfguard resolves with `Resolv.getaddresses`, which
84
+ honours `/etc/hosts` and search domains **and** returns every address. The obvious alternatives each
85
+ drop something a guard can't afford to lose:
86
+
87
+ - `Resolv.getaddress` honours `/etc/hosts` but returns only the **first** address — so an AAAA-only
88
+ host deterministically takes the IPv6 path, and a multi-homed host is validated on one address
89
+ while the connection may use another.
90
+ - `Resolv::DNS.open` returns every address but **ignores** `/etc/hosts` and search domains — so it
91
+ validates a different set than `Net::HTTP` (which resolves through `getaddrinfo`) will actually
92
+ connect to. Demonstrable: `Resolv.getaddresses("localhost")` → `["::1","127.0.0.1"]` while
93
+ `Resolv::DNS.open`'s `each_address("localhost")` → `["::","0.0.0.0"]`.
94
+
95
+ `getaddresses` is both complete (every address) and faithful (the same chain the connection layer
96
+ uses).
97
+
98
+ **2. A resolver-level "no AAAA" switch is not a mitigation.** Disabling AAAA at the system resolver
99
+ (for example Kamal's `dns-opt: no-aaaa`) is a glibc `getaddrinfo` option. Surfguard resolves through
100
+ pure-Ruby `Resolv`, which requests AAAA regardless, so IPv6 answers still reach it. Don't treat that
101
+ deploy setting as if it narrowed Surfguard's input.
102
+
103
+ ## Testing
104
+
105
+ ```bash
106
+ ruby -Ilib test/surfguard_test.rb
107
+ # The full BLOCKED/ALLOWED matrix, checked as execution. Bare Ruby, no gems needed.
108
+ ```
109
+
110
+ ## Security
111
+
112
+ Surfguard is a security control, so classification bugs are vulnerabilities. Report them privately
113
+ per the [security policy](https://github.com/basecamp/surfguard/security/policy) — not the public
114
+ issue tracker.
115
+
116
+ ## Status
117
+
118
+ Extracted and consolidated from several in-house SSRF guards, tested against the full
119
+ IPv4 × IPv6 special-use matrix. Resolve-and-classify only; callers pin. See the
120
+ [releases page](https://github.com/basecamp/surfguard/releases) for versions and changes.
data/SECURITY.md ADDED
@@ -0,0 +1,40 @@
1
+ # Security Policy
2
+
3
+ Surfguard is a security control — an SSRF address guard — so classification bugs are security
4
+ vulnerabilities, not ordinary defects. Please report them privately.
5
+
6
+ ## Reporting a vulnerability
7
+
8
+ Report privately via GitHub's vulnerability reporting:
9
+ [**Report a vulnerability**](https://github.com/basecamp/surfguard/security/advisories/new)
10
+ (the repo's **Security** tab → **Report a vulnerability**).
11
+
12
+ Open-source reports aren't bounty-eligible, but we also accept them via
13
+ [hackerone.com/basecamp](https://hackerone.com/basecamp) if you'd like the report on your
14
+ HackerOne record — see the
15
+ [37signals security response policy](https://37signals.com/policies/security/response/).
16
+
17
+ **Do not open a public issue for security bugs.**
18
+
19
+ ## What qualifies
20
+
21
+ Anything that lets a blocked address through, for example:
22
+
23
+ - An address in a blocked range that Surfguard classifies as public.
24
+ - A resolution path (encoding, embedding, transition mechanism) that reaches a blocked address
25
+ despite validation.
26
+ - A discrepancy between what Surfguard validates and what a caller following the documented
27
+ pinning contract would connect to.
28
+
29
+ Non-security bugs and questions belong in the
30
+ [regular issue tracker](https://github.com/basecamp/surfguard/issues).
31
+
32
+ ## Supported versions
33
+
34
+ No packaged release exists yet, so `main` is the supported version. Once releases ship, it
35
+ becomes the latest released version. Fixes ship as a new release, not backports.
36
+
37
+ ## What to expect
38
+
39
+ We'll acknowledge your report, work with you on a fix, and coordinate disclosure through a
40
+ GitHub Security Advisory. Reporters are credited in the advisory unless they prefer otherwise.
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Surfguard
4
+ VERSION = "0.1.0"
5
+ end
data/lib/surfguard.rb ADDED
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "resolv"
5
+ require "uri"
6
+
7
+ require_relative "surfguard/version"
8
+
9
+ # One SSRF address policy for a Ruby app that fetches a URL someone else supplied.
10
+ # It consolidates several drifting in-house copies of this policy — copies that had
11
+ # grown four different ideas of what "internal" means, including one that decoded a
12
+ # NAT64 prefix whose length is not recoverable from the address. This is their
13
+ # union, decided once.
14
+ #
15
+ # Two policy decisions the copies drifted on, settled here and explained where
16
+ # they live in the code:
17
+ #
18
+ # * Resolver (see .resolve_public_ips): resolve with Resolv.getaddresses, which
19
+ # honours /etc/hosts and search domains AND returns every address. One copy
20
+ # used Resolv.getaddress (honours hosts, but only the FIRST address, so an
21
+ # AAAA-only host deterministically selected the IPv6 path); another used
22
+ # Resolv::DNS.open (all addresses, but ignores /etc/hosts, so it validated a
23
+ # different set than Net::HTTP would actually connect to). getaddresses is
24
+ # both complete and faithful to what the connection layer resolves.
25
+ #
26
+ # * `no-aaaa` in Kamal `dns-opt` is NOT a mitigation. It is a glibc
27
+ # getaddrinfo option; every guard here resolves through pure-Ruby Resolv,
28
+ # which asks for AAAA regardless. IPv6 answers reach this code in production.
29
+ # Do not treat the deploy config as if it narrowed the input.
30
+ #
31
+ # THREAT MODEL / caller contract: this only resolves and classifies. It cannot
32
+ # stop DNS rebinding on its own. A caller must PIN the connection to an address
33
+ # this returned (Net::HTTP#ipaddr=), because a second lookup at connect time can
34
+ # answer differently than the one that was validated. The plural API exists so
35
+ # that a caller failing over between addresses iterates the validated list rather
36
+ # than resolving again inside its retry loop. A non-pinning caller must instead
37
+ # use .resolvable_public_ip?/.enforce_public_ip, which refuse unless EVERY
38
+ # resolved address is public.
39
+ module Surfguard
40
+ class Violation < StandardError; end
41
+
42
+ # The host answered with no address at all. Deliberately NOT a Violation:
43
+ # nothing was refused here, the lookup simply came back empty, which is
44
+ # usually a transient upstream failure. A caller that gives up permanently on
45
+ # a Violation -- a webhook deactivating a customer's endpoint, say -- must not
46
+ # give up the same way on this, or one bad DNS minute retires the endpoint for
47
+ # good. Callers that don't care can rescue both.
48
+ class Unresolvable < StandardError; end
49
+
50
+ extend self
51
+
52
+ # IPv4 special-use ranges that must never be a fetch target (RFC 5735/6890,
53
+ # plus CGNAT and benchmarking). RFC1918 / loopback / link-local are also
54
+ # covered by the IPAddr predicates in #disallowed_ipv4?; they are restated here
55
+ # so the policy is complete and auditable in one place.
56
+ DISALLOWED_IPV4 = [
57
+ IPAddr.new("0.0.0.0/8"), # "This" network (RFC 1122)
58
+ IPAddr.new("10.0.0.0/8"), # Private (RFC 1918)
59
+ IPAddr.new("100.64.0.0/10"), # Carrier-grade NAT (RFC 6598)
60
+ IPAddr.new("127.0.0.0/8"), # Loopback (RFC 1122)
61
+ IPAddr.new("169.254.0.0/16"), # Link-local (RFC 3927) — includes the cloud metadata endpoint
62
+ IPAddr.new("172.16.0.0/12"), # Private (RFC 1918)
63
+ IPAddr.new("192.0.0.0/24"), # IETF protocol assignments (RFC 6890)
64
+ IPAddr.new("192.0.2.0/24"), # TEST-NET-1 (RFC 5737)
65
+ IPAddr.new("192.88.99.0/24"), # 6to4 relay anycast (RFC 7526)
66
+ IPAddr.new("192.168.0.0/16"), # Private (RFC 1918)
67
+ IPAddr.new("198.18.0.0/15"), # Benchmark testing (RFC 2544)
68
+ IPAddr.new("198.51.100.0/24"), # TEST-NET-2 (RFC 5737)
69
+ IPAddr.new("203.0.113.0/24"), # TEST-NET-3 (RFC 5737)
70
+ IPAddr.new("224.0.0.0/4"), # Multicast (RFC 5771)
71
+ IPAddr.new("240.0.0.0/4") # Reserved / future use (RFC 1112)
72
+ ].freeze
73
+
74
+ # IPv6 special-use ranges beyond what private? (ULA fc00::/7, incl. the IMDSv6
75
+ # address fd00:ec2::254), loopback (::1) and link-local (fe80::/10) already
76
+ # cover. 6to4 and Teredo are deprecated transition mechanisms with no
77
+ # legitimate fetch target — 2002:7f00:1:: is just a 6to4 spelling of 127.0.0.1.
78
+ DISALLOWED_IPV6 = [
79
+ IPAddr.new("::/128"), # Unspecified (RFC 4291)
80
+ IPAddr.new("100::/64"), # Discard-only (RFC 6666)
81
+ IPAddr.new("2001::/32"), # Teredo (RFC 4380)
82
+ IPAddr.new("2001:2::/48"), # Benchmark testing (RFC 5180)
83
+ IPAddr.new("2001:db8::/32"), # Documentation (RFC 3849)
84
+ IPAddr.new("2002::/16"), # 6to4 (RFC 3056)
85
+ IPAddr.new("fec0::/10"), # Deprecated site-local (RFC 3879)
86
+ IPAddr.new("ff00::/8") # Multicast (RFC 4291)
87
+ ].freeze
88
+
89
+ # NAT64 embeds an IPv4 target that must be re-checked as IPv4. The well-known
90
+ # prefix is a fixed /96, so the embedded octets are always the low 32 bits:
91
+ # decode and re-check them, and NAT64 to a public address still resolves.
92
+ NAT64_WELL_KNOWN = IPAddr.new("64:ff9b::/96") # RFC 6052
93
+
94
+ # The RFC 8215 local-use block is refused whole, not decoded. It can host a
95
+ # Pref64 of any length (/32…/96) whose embedded position is NOT recoverable
96
+ # from the address alone (RFC 6052 §2.2), so decoding the low 32 bits reads the
97
+ # wrong octets and can under-block. It is also never globally routed, so there
98
+ # is no legitimate feed behind it. This is the divergence some in-house copies
99
+ # got wrong by decoding both prefixes the same way.
100
+ NAT64_LOCAL_USE = IPAddr.new("64:ff9b:1::/48") # RFC 8215
101
+
102
+ # SIIT's IPv4-translated form is the third way an IPv4 address rides inside an
103
+ # IPv6 one, and the only one Ruby has no predicate for: ipv4_mapped?,
104
+ # ipv4_compat?, private?, loopback? and link_local? are all false for
105
+ # ::ffff:0:169.254.169.254, so it would reach the metadata address straight
106
+ # through the branches below. Note the extra group — ::ffff:0:0:0/96 is NOT the
107
+ # familiar IPv4-mapped ::ffff:0:0/96, and the two ranges do not overlap. Like
108
+ # the NAT64 well-known prefix it is a fixed /96, so decode the low 32 bits.
109
+ IPV4_TRANSLATABLE = IPAddr.new("::ffff:0:0:0/96") # RFC 2765
110
+
111
+ # Every PUBLIC address the host resolves to, IPv4 ahead of IPv6, DNS order
112
+ # preserved within each family so a provider's round-robin still spreads load.
113
+ # Empty when the host resolves but every address is blocked; raises
114
+ # Unresolvable when it resolves to nothing at all, so the caller can tell a
115
+ # refusal from a lookup failure. A caller that fails over MUST iterate this
116
+ # list and pin each address; resolving again reopens the rebinding window.
117
+ # Accepts a hostname or an IP-literal host.
118
+ def resolve_public_ips(host)
119
+ addresses = resolve(host)
120
+ raise Unresolvable, "No address for #{host}" if addresses.empty?
121
+
122
+ addresses.reject { |ip| blocked_address?(ip) }
123
+ .partition(&:ipv4?)
124
+ .flatten
125
+ .map(&:to_s)
126
+ end
127
+
128
+ # True only if the URL's host resolves to at least one address and NONE are
129
+ # blocked. For non-pinning callers (they hand the hostname straight to
130
+ # Net::HTTP, which resolves again), so anything short of "every address is
131
+ # public" is unsafe. A predicate answers the question it was asked and doesn't
132
+ # raise: false covers unresolvable and malformed alike. Reach for
133
+ # .enforce_public_ip or .resolve_public_ip when you need to tell those apart.
134
+ def resolvable_public_ip?(url)
135
+ addresses = resolve(host_of(url))
136
+ addresses.any? && addresses.none? { |ip| blocked_address?(ip) }
137
+ rescue URI::InvalidURIError, IPAddr::InvalidAddressError, ArgumentError
138
+ false
139
+ end
140
+
141
+ # Raise unless the URL's host is safe, for call sites that want a hard stop
142
+ # rather than a boolean: Unresolvable when it answers with nothing, Violation
143
+ # when it answers with something we refuse. A malformed URL is a Violation --
144
+ # there was never a lookup to fail.
145
+ def enforce_public_ip(url)
146
+ addresses = resolve(host_of(url))
147
+ raise Unresolvable, "No address for #{url}" if addresses.empty?
148
+ raise Violation, "Refusing to fetch private/internal address for #{url}" if addresses.any? { |ip| blocked_address?(ip) }
149
+ rescue URI::InvalidURIError, IPAddr::InvalidAddressError, ArgumentError
150
+ raise Violation, "Refusing to fetch malformed address for #{url}"
151
+ end
152
+
153
+ # The single-address compatibility shim for callers migrating from an older
154
+ # first-address-only guard. Returns the first public address as a String, nil
155
+ # if the host is malformed or resolves to anything blocked, and raises
156
+ # Unresolvable if it resolves to nothing -- which is where an older guard
157
+ # built on Resolv.getaddress raised Resolv::ResolvError, so callers that
158
+ # distinguished a lookup failure keep doing so. Prefer .resolve_public_ips.
159
+ def resolve_public_ip(url)
160
+ addresses = resolve(host_of(url))
161
+ raise Unresolvable, "No address for #{url}" if addresses.empty?
162
+ return nil if addresses.any? { |ip| blocked_address?(ip) }
163
+
164
+ addresses.first.to_s
165
+ rescue URI::InvalidURIError, IPAddr::InvalidAddressError, ArgumentError
166
+ nil
167
+ end
168
+
169
+ # The classification core. True if this address must never be a fetch target.
170
+ # Accepts an IPAddr or anything IPAddr.new understands. Errs closed: an address
171
+ # it cannot parse is blocked.
172
+ def blocked_address?(ip)
173
+ ipaddr = ip.is_a?(IPAddr) ? ip : IPAddr.new(ip.to_s)
174
+
175
+ # DNS never legitimately returns an IPv4 address embedded these two ways, so
176
+ # refuse them regardless of the address they wrap.
177
+ if ipaddr.ipv4_mapped? || ipaddr.ipv4_compat?
178
+ true
179
+ elsif ipaddr.ipv4?
180
+ disallowed_ipv4?(ipaddr)
181
+ elsif NAT64_LOCAL_USE.include?(ipaddr)
182
+ true
183
+ elsif NAT64_WELL_KNOWN.include?(ipaddr) || IPV4_TRANSLATABLE.include?(ipaddr)
184
+ disallowed_ipv4?(embedded_ipv4(ipaddr))
185
+ else
186
+ disallowed_ipv6?(ipaddr)
187
+ end
188
+ rescue IPAddr::InvalidAddressError
189
+ true
190
+ end
191
+
192
+ private
193
+
194
+ # An IP-literal host skips DNS so a public literal URL resolves directly and an
195
+ # internal literal is still caught by blocked_address?. Otherwise resolve via
196
+ # Resolv.getaddresses — the Hosts+DNS chain, matching what the connection layer
197
+ # will use, and returning every address. Returns [IPAddr].
198
+ def resolve(host)
199
+ literal = ip_literal(host)
200
+
201
+ if literal
202
+ [ literal ]
203
+ else
204
+ Resolv.getaddresses(host).map { |a| IPAddr.new(a) }
205
+ end
206
+ rescue Resolv::ResolvError, Resolv::ResolvTimeout
207
+ []
208
+ end
209
+
210
+ # nil for anything that isn't already an address. Kept separate so the DNS
211
+ # call above sits in the method body, where the ResolvError rescue can reach
212
+ # it — inside a sibling rescue clause it could not.
213
+ def ip_literal(host)
214
+ IPAddr.new(host)
215
+ rescue IPAddr::InvalidAddressError
216
+ nil
217
+ end
218
+
219
+ def host_of(url)
220
+ # URI#host is "" for "http://" and nil when there's no authority at all.
221
+ # Neither is a name to look up, so both are malformed rather than a lookup
222
+ # that failed. URI#host keeps IPv6 brackets ([::1]); IPAddr.new won't take them.
223
+ host = URI.parse(url).host
224
+ raise URI::InvalidURIError, "no host in #{url.inspect}" if host.nil? || host.empty?
225
+
226
+ host.delete_prefix("[").delete_suffix("]")
227
+ end
228
+
229
+ def disallowed_ipv4?(ipaddr)
230
+ ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? ||
231
+ DISALLOWED_IPV4.any? { |range| range.include?(ipaddr) }
232
+ end
233
+
234
+ def disallowed_ipv6?(ipaddr)
235
+ ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? ||
236
+ DISALLOWED_IPV6.any? { |range| range.include?(ipaddr) }
237
+ end
238
+
239
+ # RFC 6052 §2.2: a fixed /96 translation prefix carries the IPv4 target in the
240
+ # low 32 bits.
241
+ def embedded_ipv4(ipaddr)
242
+ IPAddr.new([ ipaddr.to_i & 0xffffffff ].pack("N").unpack("C4").join("."))
243
+ end
244
+ end
metadata ADDED
@@ -0,0 +1,52 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: surfguard
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - 37signals
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-08-12 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'Surfguard resolves a hostname to the public IP addresses it points at
13
+ and refuses anything that would reach an internal network: private, loopback, link-local
14
+ and carrier-grade NAT space, plus the IPv6 transition ranges a naive guard misses
15
+ (IPv4-mapped, SIIT, NAT64, 6to4, Teredo). It resolves and classifies only; the caller
16
+ owns the fetch and pins the connection to a returned address so DNS rebinding cannot
17
+ swap in a blocked one. Standard library only, no runtime dependencies.'
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - LICENSE
23
+ - README.md
24
+ - SECURITY.md
25
+ - lib/surfguard.rb
26
+ - lib/surfguard/version.rb
27
+ homepage: https://github.com/basecamp/surfguard
28
+ licenses:
29
+ - MIT
30
+ metadata:
31
+ bug_tracker_uri: https://github.com/basecamp/surfguard/issues
32
+ changelog_uri: https://github.com/basecamp/surfguard/releases
33
+ rubygems_mfa_required: 'true'
34
+ rdoc_options: []
35
+ require_paths:
36
+ - lib
37
+ required_ruby_version: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '3.1'
42
+ required_rubygems_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ requirements: []
48
+ rubygems_version: 4.0.18
49
+ specification_version: 4
50
+ summary: 'One SSRF address policy: resolve a host and classify special-use IPv4/IPv6
51
+ ranges'
52
+ test_files: []