surfguard 0.1.0 → 0.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 743b735d0f0abcce2bcfa3660b7f658cceb1a760b90d970a86df76e91cb19622
4
- data.tar.gz: 866a867fbde519a8512ae93c4fb6d8051d01b7de1591210e7f40f071376860a0
3
+ metadata.gz: aacbc35ed38c3d141b4feec39de01c2f1ba3abc32c4005c1473e9572d7e19153
4
+ data.tar.gz: 208a710d8914dde70f9df917052ef2f488945889d66b91c14ec0f4fc9a839ac3
5
5
  SHA512:
6
- metadata.gz: c3c1b340115fa7bc1190bec41e76ae2171ec23dfab10855122ddc91c3efb74864cdeed7960c4ab6c8d60770f04f520712e36118bf5c39750486b69a9f735f310
7
- data.tar.gz: 587059deea1f701b3a8df14558a7b85c4dc24deb229a4fb61bdb02358c4120647c2e2624da498d4ce15658cbe5de77a1e394af50bf8acd8ac5f9ec080ee6cdef
6
+ metadata.gz: f363b4ae2a0f75f5de64e4c160b7080944183cbd062eba85838f91518cfd3526a59ca64378499d9c66f582bdf8aeacb9325486c760e3ea0f3f7e22fc39015a5f
7
+ data.tar.gz: 5af39621dd81510a02136bd56c7b90eb9e5c1578ab79910b05f80f4a9c869c4686288b6d553cb4b7ec1366f6ead25dba2f5f0d92db195d1cdebdb2c2d0ccbf37
data/README.md CHANGED
@@ -7,19 +7,10 @@ One SSRF address policy for Ruby apps that fetch a URL someone else supplied.
7
7
  It consolidates several drifting in-house copies of this policy into one — copies that had grown
8
8
  four different ideas of what "internal" means, including one that decoded a NAT64 prefix whose length
9
9
  is not recoverable from the address. This gem is their union, decided once and tested against the
10
- full IPv4 × IPv6 range matrix.
10
+ IPv4 × IPv6 range matrix it enforces.
11
11
 
12
12
  ## Installation
13
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
14
  ```ruby
24
15
  gem "surfguard"
25
16
  ```
@@ -35,7 +26,7 @@ Surfguard.resolve_public_ips("feeds.example.com")
35
26
  # => ["93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"] (IPv4 first, blocked removed)
36
27
  # Iterate THIS list on failover; do not resolve again inside a retry loop.
37
28
 
38
- # Non-pinning caller (hands the hostname straight to Net::HTTP, which resolves again):
29
+ # Non-pinning preflight (hands the hostname to Net::HTTP, which resolves again):
39
30
  Surfguard.resolvable_public_ip?("https://feeds.example.com/atom") # => true only if EVERY address is public
40
31
  Surfguard.enforce_public_ip(url) # raises otherwise
41
32
 
@@ -46,6 +37,21 @@ Surfguard.resolve_public_ip(url) # => "93.184.216.34" or nil
46
37
  Surfguard.blocked_address?(IPAddr.new("169.254.169.254")) # => true
47
38
  ```
48
39
 
40
+ The non-pinning helpers conservatively require every address in their lookup to be public. They do
41
+ not bind the later connection to that answer, so a second lookup can still change underneath them.
42
+ Pinning is required when attacker-controlled DNS or DNS rebinding is in scope.
43
+
44
+ The direct address APIs accept endpoint addresses, not networks. A prefix shorter than `/32` or
45
+ `/128` is refused rather than normalized into a host: `blocked_address?("10.0.0.1/8")` is `true`,
46
+ and `resolve_public_ips("10.0.0.1/8")` is `[]`.
47
+
48
+ ### Caller responsibilities
49
+
50
+ Surfguard classifies an address; it is not an HTTP client. The URL helpers do not restrict schemes,
51
+ follow redirects, or make the request. Allow only the schemes your fetcher supports, and validate
52
+ and pin every redirect target just as you did the original target. A pinning client must connect to
53
+ the selected returned address while retaining the original hostname for HTTP Host and TLS identity.
54
+
49
55
  ## Refused vs. unresolvable
50
56
 
51
57
  "We refuse that address" and "the host didn't answer" are different answers, and
@@ -71,35 +77,54 @@ question it was asked and never raises; use `enforce_public_ip` or
71
77
  |---|---|
72
78
  | IPv4 private (10/8, 172.16/12, 192.168/16), loopback (127/8), link-local (169.254/16) | refuse |
73
79
  | 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 |
80
+ | 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 |
81
+ | 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) |
75
82
  | IPv4-mapped `::ffff:0:0/96`, IPv4-compatible `::/96` | refuse outright |
76
83
  | **SIIT `::ffff:0:0:0/96`** | decode embedded IPv4 (low 32 bits), re-check |
77
84
  | NAT64 well-known `64:ff9b::/96` | decode embedded IPv4 (low 32 bits), re-check |
78
85
  | **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
86
  | 6to4 `2002::/16` | refuse (a 6to4 address is just an IPv4 address in disguise) |
80
87
 
81
- ## Two things worth knowing
88
+ This is an SSRF address policy, not a label-only copy of the IANA special-purpose registry. It
89
+ refuses internal, non-destination, non-global domain, and overlay-identifier ranges plus transition
90
+ encodings that can conceal a blocked target. Actual globally reachable IP-layer services such as
91
+ AMT and AS112 remain valid; ORCHID and DET identifiers do not, because they can be interpreted by a
92
+ local overlay rather than routed as ordinary public IP destinations.
82
93
 
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:
94
+ ## Three things worth knowing
95
+
96
+ **1. Numeric parsing and name resolution are both part of the policy.** Before asking DNS,
97
+ Surfguard asks the system numeric-host parser used by `Socket`/`Net::HTTP` whether the token is an
98
+ address. This recognizes non-canonical decimal, hexadecimal, octal, and shortened IPv4 forms. A
99
+ numeric token is classified directly and is never sent through DNS or a search domain.
100
+
101
+ Names resolve with `Resolv.getaddresses`, which uses Ruby's usual hosts-plus-DNS chain, honours
102
+ search domains, and returns every address. The obvious alternatives each drop something a guard
103
+ can't afford to lose:
86
104
 
87
105
  - `Resolv.getaddress` honours `/etc/hosts` but returns only the **first** address — so an AAAA-only
88
106
  host deterministically takes the IPv6 path, and a multi-homed host is validated on one address
89
107
  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"]`.
108
+ - `Resolv::DNS.open` returns every DNS address but **ignores** `/etc/hosts` and the other parts of
109
+ the default resolver chain.
94
110
 
95
- `getaddresses` is both complete (every address) and faithful (the same chain the connection layer
96
- uses).
111
+ Ruby `Resolv` is not a universal substitute for the system `getaddrinfo` name-service chain. A host
112
+ with custom NSS sources such as mDNS or LDAP can produce a different answer at connection time.
113
+ Pinning a returned address avoids that second name lookup; non-pinning deployments need a resolver
114
+ configuration in which Ruby's hosts-plus-DNS answers match the connection layer, or an independent
115
+ egress control. Resolver-chain equivalence does not eliminate the separate DNS-rebinding window.
97
116
 
98
117
  **2. A resolver-level "no AAAA" switch is not a mitigation.** Disabling AAAA at the system resolver
99
118
  (for example Kamal's `dns-opt: no-aaaa`) is a glibc `getaddrinfo` option. Surfguard resolves through
100
119
  pure-Ruby `Resolv`, which requests AAAA regardless, so IPv6 answers still reach it. Don't treat that
101
120
  deploy setting as if it narrowed Surfguard's input.
102
121
 
122
+ **3. Custom DNS64 prefixes need their own enforcement.** Surfguard can decode and re-check the
123
+ fixed NAT64 well-known prefix `64:ff9b::/96`, and it refuses the RFC 8215 local-use block outright.
124
+ It cannot infer the embedded IPv4 address in an arbitrary network-specific Pref64 from the
125
+ synthesized IPv6 address alone. A deployment using DNS64 with another Pref64 must enforce the same
126
+ blocked-address policy at its DNS64/NAT64 or egress layer.
127
+
103
128
  ## Testing
104
129
 
105
130
  ```bash
@@ -116,5 +141,5 @@ issue tracker.
116
141
  ## Status
117
142
 
118
143
  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
144
+ policy matrix. Resolve-and-classify only; callers pin. See the
120
145
  [releases page](https://github.com/basecamp/surfguard/releases) for versions and changes.
data/SECURITY.md CHANGED
@@ -23,16 +23,20 @@ Anything that lets a blocked address through, for example:
23
23
  - An address in a blocked range that Surfguard classifies as public.
24
24
  - A resolution path (encoding, embedding, transition mechanism) that reaches a blocked address
25
25
  despite validation.
26
- - A discrepancy between what Surfguard validates and what a caller following the documented
27
- pinning contract would connect to.
26
+ - A parser or resolver discrepancy that lets a caller following a documented API contract connect
27
+ to a different address than Surfguard classified.
28
+
29
+ Surfguard does not perform the fetch itself. Scheme restrictions, redirect handling, connection
30
+ pinning, custom DNS64 prefixes, and nonstandard NSS sources have caller or deployment requirements
31
+ documented in the README. Those boundaries by themselves are not classification bugs, but a bypass
32
+ when the documented contract is followed qualifies.
28
33
 
29
34
  Non-security bugs and questions belong in the
30
35
  [regular issue tracker](https://github.com/basecamp/surfguard/issues).
31
36
 
32
37
  ## Supported versions
33
38
 
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.
39
+ The latest released version. Fixes ship as a new release, not backports.
36
40
 
37
41
  ## What to expect
38
42
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Surfguard
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
data/lib/surfguard.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "ipaddr"
4
4
  require "resolv"
5
+ require "socket"
5
6
  require "uri"
6
7
 
7
8
  require_relative "surfguard/version"
@@ -15,13 +16,16 @@ require_relative "surfguard/version"
15
16
  # Two policy decisions the copies drifted on, settled here and explained where
16
17
  # they live in the code:
17
18
  #
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.
19
+ # * Resolver (see .resolve_public_ips): first use getaddrinfo's numeric-only
20
+ # parser so legacy decimal, hex, octal, and short IPv4 forms mean exactly
21
+ # what they do to Net::HTTP; never send those tokens to DNS. Names resolve
22
+ # with Resolv.getaddresses, which honours /etc/hosts and search domains AND
23
+ # returns every address. One copy used Resolv.getaddress (honours hosts, but
24
+ # only the FIRST address, so an AAAA-only host deterministically selected the
25
+ # IPv6 path); another used Resolv::DNS.open (all addresses, but ignores
26
+ # /etc/hosts, so it validated a different set than Net::HTTP would connect
27
+ # to). getaddresses is complete for Ruby's hosts-plus-DNS chain; system
28
+ # getaddrinfo may additionally consult NSS sources such as mDNS or LDAP.
25
29
  #
26
30
  # * `no-aaaa` in Kamal `dns-opt` is NOT a mitigation. It is a glibc
27
31
  # getaddrinfo option; every guard here resolves through pure-Ruby Resolv,
@@ -39,6 +43,12 @@ require_relative "surfguard/version"
39
43
  module Surfguard
40
44
  class Violation < StandardError; end
41
45
 
46
+ # Internal signal for something IPAddr can parse, but which is a network
47
+ # rather than a host. Keep it separate from a lookup failure: callers must
48
+ # refuse these inputs without asking DNS to reinterpret them as names.
49
+ class InvalidHost < ArgumentError; end
50
+ private_constant :InvalidHost
51
+
42
52
  # The host answered with no address at all. Deliberately NOT a Violation:
43
53
  # nothing was refused here, the lookup simply came back empty, which is
44
54
  # usually a transient upstream failure. A caller that gives up permanently on
@@ -78,14 +88,31 @@ module Surfguard
78
88
  DISALLOWED_IPV6 = [
79
89
  IPAddr.new("::/128"), # Unspecified (RFC 4291)
80
90
  IPAddr.new("100::/64"), # Discard-only (RFC 6666)
91
+ IPAddr.new("100:0:0:1::/64"), # Dummy IPv6 destination (RFC 9780)
81
92
  IPAddr.new("2001::/32"), # Teredo (RFC 4380)
82
93
  IPAddr.new("2001:2::/48"), # Benchmark testing (RFC 5180)
83
94
  IPAddr.new("2001:db8::/32"), # Documentation (RFC 3849)
84
95
  IPAddr.new("2002::/16"), # 6to4 (RFC 3056)
96
+ IPAddr.new("3fff::/20"), # Documentation (RFC 9637)
97
+ IPAddr.new("5f00::/16"), # SRv6 SIDs, confined to SR domains (RFC 9602)
85
98
  IPAddr.new("fec0::/10"), # Deprecated site-local (RFC 3879)
86
99
  IPAddr.new("ff00::/8") # Multicast (RFC 4291)
87
100
  ].freeze
88
101
 
102
+ # The IANA parent assignment is non-source, non-destination, non-forwardable,
103
+ # and non-global unless a more-specific allocation says otherwise. Refuse it
104
+ # by default so unallocated/future-special space cannot become an internal
105
+ # fetch path. These are the current, narrowly scoped IP-layer services whose
106
+ # registrations explicitly permit globally reachable destinations without
107
+ # selecting nearby infrastructure. PCP, TURN, and DNS-SD SRP anycast remain
108
+ # refused because they deliberately target on-path or local services. ORCHID,
109
+ # ORCHIDv2, and DET are overlay identifiers, not ordinary IP-layer locators.
110
+ IETF_PROTOCOL_ASSIGNMENTS = IPAddr.new("2001::/23")
111
+ GLOBALLY_REACHABLE_IETF_ASSIGNMENTS = [
112
+ IPAddr.new("2001:3::/32"), # AMT (RFC 7450)
113
+ IPAddr.new("2001:4:112::/48") # AS112-v6 (RFC 7535)
114
+ ].freeze
115
+
89
116
  # NAT64 embeds an IPv4 target that must be re-checked as IPv4. The well-known
90
117
  # prefix is a fixed /96, so the embedded octets are always the low 32 bits:
91
118
  # decode and re-check them, and NAT64 to a public address still resolves.
@@ -123,6 +150,8 @@ module Surfguard
123
150
  .partition(&:ipv4?)
124
151
  .flatten
125
152
  .map(&:to_s)
153
+ rescue InvalidHost
154
+ []
126
155
  end
127
156
 
128
157
  # True only if the URL's host resolves to at least one address and NONE are
@@ -171,6 +200,7 @@ module Surfguard
171
200
  # it cannot parse is blocked.
172
201
  def blocked_address?(ip)
173
202
  ipaddr = ip.is_a?(IPAddr) ? ip : IPAddr.new(ip.to_s)
203
+ return true unless host_address?(ipaddr)
174
204
 
175
205
  # DNS never legitimately returns an IPv4 address embedded these two ways, so
176
206
  # refuse them regardless of the address they wrap.
@@ -191,15 +221,19 @@ module Surfguard
191
221
 
192
222
  private
193
223
 
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].
224
+ # A numeric host skips DNS so a public literal URL resolves directly and an
225
+ # internal literal is still caught by blocked_address?. Socket's numeric-only
226
+ # parser comes first because it accepts every legacy IPv4 spelling the
227
+ # connection layer does (decimal, hex, octal, and shortened forms), while
228
+ # IPAddr does not. Otherwise resolve via Resolv.getaddresses, returning every
229
+ # address from Ruby's hosts-plus-DNS chain. System getaddrinfo may consult
230
+ # additional NSS sources; pinning an address returned here avoids that second
231
+ # resolution. Returns [IPAddr].
198
232
  def resolve(host)
199
- literal = ip_literal(host)
233
+ literals = numeric_literals(host)
200
234
 
201
- if literal
202
- [ literal ]
235
+ if literals
236
+ literals
203
237
  else
204
238
  Resolv.getaddresses(host).map { |a| IPAddr.new(a) }
205
239
  end
@@ -207,15 +241,53 @@ module Surfguard
207
241
  []
208
242
  end
209
243
 
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.
244
+ # Every address Socket's numeric-only parser finds. AI_NUMERICHOST guarantees
245
+ # this call never falls through to DNS. Its success is authoritative: these
246
+ # tokens must not be reinterpreted as names by Resolv, because Net::HTTP's
247
+ # getaddrinfo call will reinterpret them as the numeric addresses returned
248
+ # here. IPAddr remains as a fallback for syntax it alone accepts, notably a
249
+ # full-width /32 or /128 host prefix. nil means the token is a name.
250
+ def numeric_literals(host)
251
+ Socket.getaddrinfo(
252
+ host, nil, Socket::AF_UNSPEC, Socket::SOCK_STREAM, 0, Socket::AI_NUMERICHOST
253
+ ).map { |address| IPAddr.new(address[3]) }.uniq
254
+ rescue SocketError
255
+ literal = ip_literal(host)
256
+ return [ literal ] if literal
257
+
258
+ # An ordinary name may proceed to Resolv after AI_NUMERICHOST says it is
259
+ # not numeric. A token shaped like a legacy IPv4 spelling may not: if the
260
+ # system numeric parser itself failed abnormally, asking DNS would recreate
261
+ # the parser/resolver identity gap this method exists to close.
262
+ raise InvalidHost, "numeric-looking host #{host.inspect} could not be classified" if numeric_host_candidate?(host)
263
+
264
+ nil
265
+ end
266
+
267
+ def numeric_host_candidate?(host)
268
+ text = host.to_s
269
+ return true if text.include?(":") # Invalid IPv6 syntax is never a DNS name.
270
+
271
+ parts = text.split(".", -1)
272
+ (1..4).cover?(parts.length) &&
273
+ parts.all? { |part| part.match?(/\A(?:0[xX][0-9A-Fa-f]+|[0-9]+)\z/) }
274
+ end
275
+
276
+ # nil for anything IPAddr cannot parse. A shortened prefix is not a host and
277
+ # is rejected rather than normalized to its network address or sent to DNS.
213
278
  def ip_literal(host)
214
- IPAddr.new(host)
279
+ ipaddr = IPAddr.new(host)
280
+ raise InvalidHost, "#{host.inspect} is a network, not a host" unless host_address?(ipaddr)
281
+
282
+ ipaddr
215
283
  rescue IPAddr::InvalidAddressError
216
284
  nil
217
285
  end
218
286
 
287
+ def host_address?(ipaddr)
288
+ ipaddr.prefix == (ipaddr.ipv4? ? 32 : 128)
289
+ end
290
+
219
291
  def host_of(url)
220
292
  # URI#host is "" for "http://" and nil when there's no authority at all.
221
293
  # Neither is a name to look up, so both are malformed rather than a lookup
@@ -232,7 +304,10 @@ module Surfguard
232
304
  end
233
305
 
234
306
  def disallowed_ipv6?(ipaddr)
307
+ return false if GLOBALLY_REACHABLE_IETF_ASSIGNMENTS.any? { |range| range.include?(ipaddr) }
308
+
235
309
  ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? ||
310
+ IETF_PROTOCOL_ASSIGNMENTS.include?(ipaddr) ||
236
311
  DISALLOWED_IPV6.any? { |range| range.include?(ipaddr) }
237
312
  end
238
313
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: surfguard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - 37signals