surfguard 0.1.1 → 0.1.3

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: aacbc35ed38c3d141b4feec39de01c2f1ba3abc32c4005c1473e9572d7e19153
4
- data.tar.gz: 208a710d8914dde70f9df917052ef2f488945889d66b91c14ec0f4fc9a839ac3
3
+ metadata.gz: 33a528c627b74ee9bcd275aed8487baa4e357203bb70054dc809d30bba392b90
4
+ data.tar.gz: 50367ef6f086af1bcecf4da7ea6af2154be1d1f681f20677976f3a6a39dc7fca
5
5
  SHA512:
6
- metadata.gz: f363b4ae2a0f75f5de64e4c160b7080944183cbd062eba85838f91518cfd3526a59ca64378499d9c66f582bdf8aeacb9325486c760e3ea0f3f7e22fc39015a5f
7
- data.tar.gz: 5af39621dd81510a02136bd56c7b90eb9e5c1578ab79910b05f80f4a9c869c4686288b6d553cb4b7ec1366f6ead25dba2f5f0d92db195d1cdebdb2c2d0ccbf37
6
+ metadata.gz: ac217d4ef2ff066da26d12b2457d93e639faedde02261dcb7bd23a7c587ea875b3eb2a97044ec110cf40deca66e6a71934b1698c3ee7de331893080aeed4beb4
7
+ data.tar.gz: 946be8043a78695c841c9f694b4910d4300a6876836affbfc150b914d6e3cb1175500981a9a8afc8bdf1dc83c34a5988199dc198eee35423760843ec6046acba
data/README.md CHANGED
@@ -77,6 +77,7 @@ question it was asked and never raises; use `enforce_public_ip` or
77
77
  |---|---|
78
78
  | IPv4 private (10/8, 172.16/12, 192.168/16), loopback (127/8), link-local (169.254/16) | refuse |
79
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 |
80
+ | Azure WireServer `168.63.129.16/32` | refuse — a fixed Azure platform/fabric alias, not an RFC special-use range |
80
81
  | 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
82
  | 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) |
82
83
  | IPv4-mapped `::ffff:0:0/96`, IPv4-compatible `::/96` | refuse outright |
@@ -96,7 +97,9 @@ local overlay rather than routed as ordinary public IP destinations.
96
97
  **1. Numeric parsing and name resolution are both part of the policy.** Before asking DNS,
97
98
  Surfguard asks the system numeric-host parser used by `Socket`/`Net::HTTP` whether the token is an
98
99
  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
+ numeric token is classified directly and is never sent through DNS or a search domain. Malformed
101
+ IPv4-shaped variants with empty dot labels or invalid zone/prefix suffixes are refused rather than
102
+ reinterpreted as DNS names.
100
103
 
101
104
  Names resolve with `Resolv.getaddresses`, which uses Ruby's usual hosts-plus-DNS chain, honours
102
105
  search domains, and returns every address. The obvious alternatives each drop something a guard
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Surfguard
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.3"
5
5
  end
data/lib/surfguard.rb CHANGED
@@ -59,15 +59,17 @@ module Surfguard
59
59
 
60
60
  extend self
61
61
 
62
- # IPv4 special-use ranges that must never be a fetch target (RFC 5735/6890,
63
- # plus CGNAT and benchmarking). RFC1918 / loopback / link-local are also
64
- # covered by the IPAddr predicates in #disallowed_ipv4?; they are restated here
65
- # so the policy is complete and auditable in one place.
62
+ # IPv4 special-use ranges and exact platform aliases that must never be a
63
+ # fetch target (RFC 5735/6890, plus CGNAT and benchmarking). RFC1918 /
64
+ # loopback / link-local are also covered by the IPAddr predicates in
65
+ # #disallowed_ipv4?; they are restated here so the policy is complete and
66
+ # auditable in one place.
66
67
  DISALLOWED_IPV4 = [
67
68
  IPAddr.new("0.0.0.0/8"), # "This" network (RFC 1122)
68
69
  IPAddr.new("10.0.0.0/8"), # Private (RFC 1918)
69
70
  IPAddr.new("100.64.0.0/10"), # Carrier-grade NAT (RFC 6598)
70
71
  IPAddr.new("127.0.0.0/8"), # Loopback (RFC 1122)
72
+ IPAddr.new("168.63.129.16/32"), # Azure host-node WireServer virtual IP
71
73
  IPAddr.new("169.254.0.0/16"), # Link-local (RFC 3927) — includes the cloud metadata endpoint
72
74
  IPAddr.new("172.16.0.0/12"), # Private (RFC 1918)
73
75
  IPAddr.new("192.0.0.0/24"), # IETF protocol assignments (RFC 6890)
@@ -135,6 +137,11 @@ module Surfguard
135
137
  # the NAT64 well-known prefix it is a fixed /96, so decode the low 32 bits.
136
138
  IPV4_TRANSLATABLE = IPAddr.new("::ffff:0:0:0/96") # RFC 2765
137
139
 
140
+ # IPv4-compatible IPv6 is deprecated and must never be a DNS fetch target.
141
+ # Classify the prefix directly instead of calling IPAddr#ipv4_compat?, which
142
+ # has been obsolete since Ruby 2.5 and is expected to disappear in ipaddr 2.x.
143
+ IPV4_COMPATIBLE = IPAddr.new("::/96")
144
+
138
145
  # Every PUBLIC address the host resolves to, IPv4 ahead of IPv6, DNS order
139
146
  # preserved within each family so a provider's round-robin still spreads load.
140
147
  # Empty when the host resolves but every address is blocked; raises
@@ -204,7 +211,7 @@ module Surfguard
204
211
 
205
212
  # DNS never legitimately returns an IPv4 address embedded these two ways, so
206
213
  # refuse them regardless of the address they wrap.
207
- if ipaddr.ipv4_mapped? || ipaddr.ipv4_compat?
214
+ if ipaddr.ipv4_mapped? || IPV4_COMPATIBLE.include?(ipaddr)
208
215
  true
209
216
  elsif ipaddr.ipv4?
210
217
  disallowed_ipv4?(ipaddr)
@@ -248,6 +255,13 @@ module Surfguard
248
255
  # here. IPAddr remains as a fallback for syntax it alone accepts, notably a
249
256
  # full-width /32 or /128 host prefix. nil means the token is a name.
250
257
  def numeric_literals(host)
258
+ # Refuse malformed IPv4-shaped text before consulting the platform parser.
259
+ # Some connection layers may accept decorations that others reject; none
260
+ # may turn them into a public DNS name after Surfguard classified them.
261
+ if malformed_numeric_host_candidate?(host)
262
+ raise InvalidHost, "malformed numeric-looking host #{host.inspect}"
263
+ end
264
+
251
265
  Socket.getaddrinfo(
252
266
  host, nil, Socket::AF_UNSPEC, Socket::SOCK_STREAM, 0, Socket::AI_NUMERICHOST
253
267
  ).map { |address| IPAddr.new(address[3]) }.uniq
@@ -268,11 +282,42 @@ module Surfguard
268
282
  text = host.to_s
269
283
  return true if text.include?(":") # Invalid IPv6 syntax is never a DNS name.
270
284
 
271
- parts = text.split(".", -1)
285
+ legacy_ipv4_shape?(text)
286
+ end
287
+
288
+ def malformed_numeric_host_candidate?(host)
289
+ text = host.to_s
290
+ return false if text.include?(":") # IPv6 literals and zones go to AI_NUMERICHOST.
291
+
292
+ # Isolate the address: drop every leading separator, then keep only what
293
+ # precedes the next one. Removing a single separator would let a second one
294
+ # ("//127.0.0.1", "%127.0.0.1%lo") hide the legacy IPv4 shape, so the token
295
+ # would reach the platform parser and, failing there, be handed to DNS as a
296
+ # name — the parser/resolver identity gap this check exists to close.
297
+ core = text.sub(%r{\A[%/]+}, "")[%r{\A[^%/]*}]
298
+ labels = core.split(".", -1)
299
+ malformed = core != text || labels.any?(&:empty?)
300
+ return false unless malformed && legacy_ipv4_shape?(core)
301
+
302
+ # Full-width host prefixes are documented inputs and IPAddr parses them
303
+ # unambiguously. Shorter or otherwise invalid prefixes remain malformed.
304
+ return false if full_width_host_literal?(text)
305
+
306
+ true
307
+ end
308
+
309
+ def legacy_ipv4_shape?(text)
310
+ parts = text.split(".", -1).reject(&:empty?)
272
311
  (1..4).cover?(parts.length) &&
273
312
  parts.all? { |part| part.match?(/\A(?:0[xX][0-9A-Fa-f]+|[0-9]+)\z/) }
274
313
  end
275
314
 
315
+ def full_width_host_literal?(text)
316
+ host_address?(IPAddr.new(text))
317
+ rescue IPAddr::InvalidAddressError
318
+ false
319
+ end
320
+
276
321
  # nil for anything IPAddr cannot parse. A shortened prefix is not a host and
277
322
  # is rejected rather than normalized to its network address or sent to DNS.
278
323
  def ip_literal(host)
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: surfguard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - 37signals
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-08-12 00:00:00.000000000 Z
10
+ date: 2026-08-13 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: 'Surfguard resolves a hostname to the public IP addresses it points at
13
13
  and refuses anything that would reach an internal network: private, loopback, link-local