surfguard 0.1.2 → 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: 2bbb6e8891a860c9c927fbcced35382c1ecdaf965bc4d3e41d56b06f94c5adf2
4
- data.tar.gz: 0f309037fa0b37414dd964a9d15afdf0b3a68dbd706a90ee5f24660d8721dd4e
3
+ metadata.gz: 33a528c627b74ee9bcd275aed8487baa4e357203bb70054dc809d30bba392b90
4
+ data.tar.gz: 50367ef6f086af1bcecf4da7ea6af2154be1d1f681f20677976f3a6a39dc7fca
5
5
  SHA512:
6
- metadata.gz: 8f12a95962cfa260c0fc1754d09c78b34c95e3db42dd35837b77b6532a030fd0562cf227d57be5b979c243832cfdd1c71612c495e6c305031f519050dd33d28f
7
- data.tar.gz: 6f571b898b5c21214b12c27e2dcf6b8cec6b0fce2005cc48aa9b82753a49b4fd7f1b743abe20bb84141cf393f5c9a7e04d98efe9c57d7bb2692452d5ef0346a5
6
+ metadata.gz: ac217d4ef2ff066da26d12b2457d93e639faedde02261dcb7bd23a7c587ea875b3eb2a97044ec110cf40deca66e6a71934b1698c3ee7de331893080aeed4beb4
7
+ data.tar.gz: 946be8043a78695c841c9f694b4910d4300a6876836affbfc150b914d6e3cb1175500981a9a8afc8bdf1dc83c34a5988199dc198eee35423760843ec6046acba
data/README.md CHANGED
@@ -97,7 +97,9 @@ local overlay rather than routed as ordinary public IP destinations.
97
97
  **1. Numeric parsing and name resolution are both part of the policy.** Before asking DNS,
98
98
  Surfguard asks the system numeric-host parser used by `Socket`/`Net::HTTP` whether the token is an
99
99
  address. This recognizes non-canonical decimal, hexadecimal, octal, and shortened IPv4 forms. A
100
- 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.
101
103
 
102
104
  Names resolve with `Resolv.getaddresses`, which uses Ruby's usual hosts-plus-DNS chain, honours
103
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.2"
4
+ VERSION = "0.1.3"
5
5
  end
data/lib/surfguard.rb CHANGED
@@ -137,6 +137,11 @@ module Surfguard
137
137
  # the NAT64 well-known prefix it is a fixed /96, so decode the low 32 bits.
138
138
  IPV4_TRANSLATABLE = IPAddr.new("::ffff:0:0:0/96") # RFC 2765
139
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
+
140
145
  # Every PUBLIC address the host resolves to, IPv4 ahead of IPv6, DNS order
141
146
  # preserved within each family so a provider's round-robin still spreads load.
142
147
  # Empty when the host resolves but every address is blocked; raises
@@ -206,7 +211,7 @@ module Surfguard
206
211
 
207
212
  # DNS never legitimately returns an IPv4 address embedded these two ways, so
208
213
  # refuse them regardless of the address they wrap.
209
- if ipaddr.ipv4_mapped? || ipaddr.ipv4_compat?
214
+ if ipaddr.ipv4_mapped? || IPV4_COMPATIBLE.include?(ipaddr)
210
215
  true
211
216
  elsif ipaddr.ipv4?
212
217
  disallowed_ipv4?(ipaddr)
@@ -250,6 +255,13 @@ module Surfguard
250
255
  # here. IPAddr remains as a fallback for syntax it alone accepts, notably a
251
256
  # full-width /32 or /128 host prefix. nil means the token is a name.
252
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
+
253
265
  Socket.getaddrinfo(
254
266
  host, nil, Socket::AF_UNSPEC, Socket::SOCK_STREAM, 0, Socket::AI_NUMERICHOST
255
267
  ).map { |address| IPAddr.new(address[3]) }.uniq
@@ -270,11 +282,42 @@ module Surfguard
270
282
  text = host.to_s
271
283
  return true if text.include?(":") # Invalid IPv6 syntax is never a DNS name.
272
284
 
273
- 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?)
274
311
  (1..4).cover?(parts.length) &&
275
312
  parts.all? { |part| part.match?(/\A(?:0[xX][0-9A-Fa-f]+|[0-9]+)\z/) }
276
313
  end
277
314
 
315
+ def full_width_host_literal?(text)
316
+ host_address?(IPAddr.new(text))
317
+ rescue IPAddr::InvalidAddressError
318
+ false
319
+ end
320
+
278
321
  # nil for anything IPAddr cannot parse. A shortened prefix is not a host and
279
322
  # is rejected rather than normalized to its network address or sent to DNS.
280
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.2
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