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 +4 -4
- data/README.md +4 -1
- data/lib/surfguard/version.rb +1 -1
- data/lib/surfguard.rb +51 -6
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 33a528c627b74ee9bcd275aed8487baa4e357203bb70054dc809d30bba392b90
|
|
4
|
+
data.tar.gz: 50367ef6f086af1bcecf4da7ea6af2154be1d1f681f20677976f3a6a39dc7fca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/surfguard/version.rb
CHANGED
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
|
|
63
|
-
# plus CGNAT and benchmarking). RFC1918 /
|
|
64
|
-
# covered by the IPAddr predicates in
|
|
65
|
-
# so the policy is complete and
|
|
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? ||
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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
|