surfguard 0.1.3 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 33a528c627b74ee9bcd275aed8487baa4e357203bb70054dc809d30bba392b90
4
- data.tar.gz: 50367ef6f086af1bcecf4da7ea6af2154be1d1f681f20677976f3a6a39dc7fca
3
+ metadata.gz: 74ab7507d8f5742bb87c321888a3118a5ed37531a7ac6f38ea8ef749d9426064
4
+ data.tar.gz: 1c41a7dfd92082bf33beb86aae3fbebfa159b374c7b55dec96c6a07089888774
5
5
  SHA512:
6
- metadata.gz: ac217d4ef2ff066da26d12b2457d93e639faedde02261dcb7bd23a7c587ea875b3eb2a97044ec110cf40deca66e6a71934b1698c3ee7de331893080aeed4beb4
7
- data.tar.gz: 946be8043a78695c841c9f694b4910d4300a6876836affbfc150b914d6e3cb1175500981a9a8afc8bdf1dc83c34a5988199dc198eee35423760843ec6046acba
6
+ metadata.gz: d7f6c61c6adcb3af2356763b49f462759672363768ad47bf25d3ecea838c42b1fff6b5a20a56392862f3dfd94df56da8d15b43fb62f3446621c4817411e11f9f
7
+ data.tar.gz: a0187fecbf3ce069a285cdf055c0f223e8f5056e319c1de3148bef3e26fd96eabd128c9ed1177fc948ba8300a15eae2b654c269368ddec33a0b34e9782c6bdd6
data/README.md CHANGED
@@ -1,13 +1,8 @@
1
1
  # Surfguard
2
2
 
3
- [![CI](https://github.com/basecamp/surfguard/actions/workflows/ci.yml/badge.svg)](https://github.com/basecamp/surfguard/actions/workflows/ci.yml)
3
+ Surfguard resolves and classifies addresses for Ruby applications that fetch a URL supplied by someone else. It is deliberately not an HTTP client: the caller owns scheme restrictions, redirects, connection pinning, response limits, retries, and request deadlines.
4
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
- IPv4 × IPv6 range matrix it enforces.
5
+ A Go implementation with the same classification guarantees plus dial-time enforcement and a hardened `*http.Client`, which are idiomatic and cheap in Go — lives under [`go/`](go/README.md). Both implementations generate their policy tables from the same checked-in IANA snapshots (`script/iana/`) and assert identical verdicts against the shared corpus in [`conformance/`](conformance/README.md).
11
6
 
12
7
  ## Installation
13
8
 
@@ -15,134 +10,169 @@ IPv4 × IPv6 range matrix it enforces.
15
10
  gem "surfguard"
16
11
  ```
17
12
 
18
- ## What it does
13
+ Surfguard 0.2 requires Ruby 3.4.5 or newer and has zero runtime dependencies.
19
14
 
20
- Resolve **and classify only**. It cannot stop DNS rebinding by itself — the caller owns the fetch
21
- and must **pin** the connection to an address this returned.
15
+ To verify a downloaded release artifact against its repository, normal release
16
+ workflow, and immutable tag:
22
17
 
23
- ```ruby
24
- # Pinning caller (preferred): validate, then pin each address you try.
25
- Surfguard.resolve_public_ips("feeds.example.com")
26
- # => ["93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"] (IPv4 first, blocked removed)
27
- # Iterate THIS list on failover; do not resolve again inside a retry loop.
18
+ ```sh
19
+ gh attestation verify surfguard-X.Y.Z.gem \
20
+ --repo basecamp/surfguard \
21
+ --signer-workflow basecamp/surfguard/.github/workflows/release.yml \
22
+ --source-ref refs/tags/vX.Y.Z
23
+ ```
28
24
 
29
- # Non-pinning preflight (hands the hostname to Net::HTTP, which resolves again):
30
- Surfguard.resolvable_public_ip?("https://feeds.example.com/atom") # => true only if EVERY address is public
31
- Surfguard.enforce_public_ip(url) # raises otherwise
25
+ If the release was completed through the documented recovery path, substitute
26
+ `release-recovery.yml` as the signer workflow. Recovery dispatched on `main`
27
+ has `refs/heads/main` provenance; confirm its logged tag/rebuild equality as
28
+ described in the
29
+ [release guide](https://github.com/basecamp/surfguard/blob/main/RELEASING.md)
30
+ before accepting that residual case.
32
31
 
33
- # Single-address compatibility shim:
34
- Surfguard.resolve_public_ip(url) # => "93.184.216.34" or nil
32
+ ## APIs and policies
35
33
 
36
- # The classification core, if you already hold an address:
37
- Surfguard.blocked_address?(IPAddr.new("169.254.169.254")) # => true
34
+ ```ruby
35
+ Surfguard.resolve_public_ips(host, policy: :default)
36
+ Surfguard.resolvable_public_ip?(url, policy: :default)
37
+ Surfguard.enforce_public_ip(url, policy: :default)
38
+ Surfguard.resolve_public_ip(url, policy: :default)
39
+ Surfguard.blocked_address?(ip, policy: :default)
38
40
  ```
39
41
 
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
-
55
- ## Refused vs. unresolvable
56
-
57
- "We refuse that address" and "the host didn't answer" are different answers, and
58
- collapsing them bites callers that treat a refusal as permanent. A webhook that
59
- deactivates a customer's endpoint on `Violation` would retire it on one bad DNS
60
- minute if a failed lookup arrived the same way.
61
-
62
- | | resolves to something public | resolves, all blocked | resolves to nothing | malformed URL |
63
- |---|---|---|---|---|
64
- | `resolve_public_ips` (takes a host) | the public addresses | `[]` | raises `Unresolvable` | — |
65
- | `resolve_public_ip` | first public address | `nil` | raises `Unresolvable` | `nil` |
66
- | `enforce_public_ip` | returns | raises `Violation` | raises `Unresolvable` | raises `Violation` |
67
- | `resolvable_public_ip?` | `true` | `false` | `false` | `false` |
68
-
69
- `Unresolvable` is **not** a subclass of `Violation` — that's the whole point.
70
- Rescue both where you don't care which it was. Note the predicate answers the
71
- question it was asked and never raises; use `enforce_public_ip` or
72
- `resolve_public_ip` when you need to tell the cases apart.
73
-
74
- ## The policy
75
-
76
- | Range | Handling |
77
- |---|---|
78
- | IPv4 private (10/8, 172.16/12, 192.168/16), loopback (127/8), link-local (169.254/16) | refuse |
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 |
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 |
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) |
83
- | IPv4-mapped `::ffff:0:0/96`, IPv4-compatible `::/96` | refuse outright |
84
- | **SIIT `::ffff:0:0:0/96`** | decode embedded IPv4 (low 32 bits), re-check |
85
- | NAT64 well-known `64:ff9b::/96` | decode embedded IPv4 (low 32 bits), re-check |
86
- | **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 |
87
- | 6to4 `2002::/16` | refuse (a 6to4 address is just an IPv4 address in disguise) |
88
-
89
- This is an SSRF address policy, not a label-only copy of the IANA special-purpose registry. It
90
- refuses internal, non-destination, non-global domain, and overlay-identifier ranges plus transition
91
- encodings that can conceal a blocked target. Actual globally reachable IP-layer services such as
92
- AMT and AS112 remain valid; ORCHID and DET identifiers do not, because they can be interpreted by a
93
- local overlay rather than routed as ordinary public IP destinations.
94
-
95
- ## Three things worth knowing
96
-
97
- **1. Numeric parsing and name resolution are both part of the policy.** Before asking DNS,
98
- Surfguard asks the system numeric-host parser used by `Socket`/`Net::HTTP` whether the token is an
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. Malformed
101
- IPv4-shaped variants with empty dot labels or invalid zone/prefix suffixes are refused rather than
102
- reinterpreted as DNS names.
103
-
104
- Names resolve with `Resolv.getaddresses`, which uses Ruby's usual hosts-plus-DNS chain, honours
105
- search domains, and returns every address. The obvious alternatives each drop something a guard
106
- can't afford to lose:
107
-
108
- - `Resolv.getaddress` honours `/etc/hosts` but returns only the **first** address — so an AAAA-only
109
- host deterministically takes the IPv6 path, and a multi-homed host is validated on one address
110
- while the connection may use another.
111
- - `Resolv::DNS.open` returns every DNS address but **ignores** `/etc/hosts` and the other parts of
112
- the default resolver chain.
113
-
114
- Ruby `Resolv` is not a universal substitute for the system `getaddrinfo` name-service chain. A host
115
- with custom NSS sources such as mDNS or LDAP can produce a different answer at connection time.
116
- Pinning a returned address avoids that second name lookup; non-pinning deployments need a resolver
117
- configuration in which Ruby's hosts-plus-DNS answers match the connection layer, or an independent
118
- egress control. Resolver-chain equivalence does not eliminate the separate DNS-rebinding window.
119
-
120
- **2. A resolver-level "no AAAA" switch is not a mitigation.** Disabling AAAA at the system resolver
121
- (for example Kamal's `dns-opt: no-aaaa`) is a glibc `getaddrinfo` option. Surfguard resolves through
122
- pure-Ruby `Resolv`, which requests AAAA regardless, so IPv6 answers still reach it. Don't treat that
123
- deploy setting as if it narrowed Surfguard's input.
124
-
125
- **3. Custom DNS64 prefixes need their own enforcement.** Surfguard can decode and re-check the
126
- fixed NAT64 well-known prefix `64:ff9b::/96`, and it refuses the RFC 8215 local-use block outright.
127
- It cannot infer the embedded IPv4 address in an arbitrary network-specific Pref64 from the
128
- synthesized IPv6 address alone. A deployment using DNS64 with another Pref64 must enforce the same
129
- blocked-address policy at its DNS64/NAT64 or egress layer.
130
-
131
- ## Testing
132
-
133
- ```bash
134
- ruby -Ilib test/surfguard_test.rb
135
- # The full BLOCKED/ALLOWED matrix, checked as execution. Bare Ruby, no gems needed.
42
+ `:default` is reachability-oriented. IPv4 retains the documented SSRF deny ranges. IPv6 is admitted only when it is in a checked-in IANA `Status=ALLOCATED` unicast prefix, subject to the explicit special/transition denies. Public NAT64/SIIT translations and globally reachable AMT/AS112 services remain admitted. This is intentionally not the much broader `2000::/3`.
43
+
44
+ `:iana_special_use` additionally blocks every prefix in the checked-in IANA IPv4 and IPv6 special-purpose registries, including AMT, AS112, and the whole NAT64 well-known prefix. Use it for advertised or discovered infrastructure values. A target must never choose its own policy; trusted consumer code chooses it.
45
+
46
+ For CIMD and similar discovery, strict address policy is only one layer. Consumers must still enforce HTTPS, reject userinfo, revalidate and pin redirects, limit response bytes, and apply egress controls.
47
+
48
+ The host API accepts `String` or `IPAddr`; URL APIs accept `String`. Network prefixes are not endpoints. Invalid encodings, NUL, non-ASCII host syntax, unstable coercion, malformed resolver answers, and oversized answer sets fail closed. Unknown policies raise `ArgumentError` from every API.
49
+
50
+ | Condition | plural | predicate | enforce | single | classifier |
51
+ |---|---|---|---|---|---|
52
+ | malformed direct input | `[]` | `false` | malformed `Violation` | `nil` | `true` |
53
+ | empty, operational, or malformed resolver result | `Unresolvable` | `false` | `Unresolvable` | `Unresolvable` | n/a |
54
+ | mixed public and blocked answers | public subset | `false` | `Violation` | `nil` | n/a |
55
+ | unexpected programmer failure | escapes | escapes | escapes | escapes | escapes |
56
+
57
+ Messages are fixed and contain no input: `Host could not be resolved`, `Refusing blocked address`, and `Refusing malformed address`.
58
+
59
+ The plural result is IPv4-first while preserving resolver order within each family. The single result preserves resolver order exactly. Answers are deduplicated in order; more than 256 raw or unique answers invalidates the lookup.
60
+
61
+ Only the returned plural or single address can be pinned. The predicate and enforcement helpers are conservative preflights; they do not bind a later connection, so resolving the hostname again after either helper reopens DNS-rebinding and resolver-divergence risk.
62
+
63
+ ## Pinning with Net::HTTP
64
+
65
+ This recipe disables environment proxies, retains the original hostname for HTTP Host, TLS SNI, and certificate verification, and pins the validated address. Retry only addresses already returned by the first lookup. Every redirect starts this process again with the redirect's own URL.
66
+
67
+ <!-- net-http-recipe:start -->
68
+ ```ruby
69
+ require "net/http"
70
+ require "openssl"
71
+ require "surfguard"
72
+ require "uri"
73
+
74
+ PINNED_MAX_REDIRECTS = 10
75
+ PINNED_MAX_BYTES = 16 * 1024 * 1024
76
+
77
+ class PinnedResponseTooLarge < StandardError; end
78
+ class PinnedRedirectError < StandardError; end
79
+
80
+ def checked_https_uri(value, base: nil)
81
+ uri = base ? URI.join(base, value) : URI(value)
82
+ raise ArgumentError, "HTTPS required", cause: nil unless uri.is_a?(URI::HTTPS)
83
+ raise ArgumentError, "userinfo forbidden", cause: nil if uri.userinfo
84
+ raise ArgumentError, "host required", cause: nil if uri.hostname.nil? || uri.hostname.empty?
85
+
86
+ uri
87
+ rescue URI::InvalidURIError
88
+ raise ArgumentError, "malformed URL", cause: nil
89
+ end
90
+
91
+ def pinned_get_hop(uri, policy:, max_bytes:)
92
+ hostname = uri.hostname # strips IPv6 URI brackets; preserves DNS identity
93
+ addresses = Surfguard.resolve_public_ips(hostname, policy: policy)
94
+ raise Surfguard::Violation, Surfguard::BLOCKED_MESSAGE, cause: nil if addresses.empty?
95
+
96
+ addresses.each do |address|
97
+ http = Net::HTTP.new(hostname, uri.port, nil) # nil disables environment proxies
98
+ http.use_ssl = true
99
+ http.ipaddr = address # connect only to this validated address
100
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
101
+ http.verify_hostname = true
102
+ http.open_timeout = 5
103
+ http.read_timeout = 15
104
+ http.write_timeout = 15
105
+ begin
106
+ body = String.new(encoding: Encoding::BINARY)
107
+ request = Net::HTTP::Get.new(uri.request_uri, "Accept-Encoding" => "identity")
108
+ response = http.request(request) do |candidate|
109
+ candidate.read_body do |chunk|
110
+ raise PinnedResponseTooLarge, "response body exceeds limit", cause: nil if
111
+ chunk.bytesize > max_bytes - body.bytesize
112
+
113
+ body << chunk
114
+ end
115
+ end
116
+ return [ response, body.freeze ]
117
+ rescue IOError, SystemCallError, Timeout::Error
118
+ # Try the next address from the original validated list. Never resolve again here.
119
+ end
120
+ end
121
+ raise Surfguard::Unresolvable, Surfguard::UNRESOLVABLE_MESSAGE, cause: nil
122
+ end
123
+
124
+ def pinned_get(url, policy: :default, max_redirects: 5, max_bytes: 1024 * 1024)
125
+ unless max_redirects.is_a?(Integer) && (0..PINNED_MAX_REDIRECTS).cover?(max_redirects)
126
+ raise ArgumentError, "invalid redirect limit", cause: nil
127
+ end
128
+ unless max_bytes.is_a?(Integer) && (1..PINNED_MAX_BYTES).cover?(max_bytes)
129
+ raise ArgumentError, "invalid response limit", cause: nil
130
+ end
131
+
132
+ uri = checked_https_uri(url)
133
+ redirects = 0
134
+ loop do
135
+ response, body = pinned_get_hop(uri, policy: policy, max_bytes: max_bytes)
136
+ return [ response, body ] unless response.is_a?(Net::HTTPRedirection)
137
+
138
+ raise PinnedRedirectError, "redirect limit exceeded", cause: nil if redirects >= max_redirects
139
+
140
+ location = response["location"]
141
+ raise PinnedRedirectError, "redirect location missing", cause: nil if location.nil? || location.empty?
142
+
143
+ uri = checked_https_uri(location, base: uri)
144
+ redirects += 1
145
+ end
146
+ end
136
147
  ```
148
+ <!-- net-http-recipe:end -->
137
149
 
138
- ## Security
150
+ The recipe returns `[response, body]`. It streams and caps each response body, caps redirect count, rejects non-HTTPS or userinfo-bearing redirects, and performs a fresh validation and pin for every redirect target. It never follows a redirect on the previous connection. The hard ceilings prevent a caller from accidentally configuring either bound away; choose smaller trusted values when appropriate.
151
+
152
+ Surfguard's synchronous resolver has no independent cancellation deadline. The caller must apply a request or worker deadline around the whole operation. Surfguard intentionally does not use `Timeout.timeout`, which can interrupt code at unsafe points.
153
+
154
+ ## Policy boundaries and residual topology risk
155
+
156
+ Surfguard blocks private, loopback, link-local, CGNAT, documentation, benchmark, multicast, reserved, IETF protocol, deprecated transition, ULA, and other explicitly listed address space. It also blocks Azure WireServer `168.63.129.16`; other provider/platform aliases need equivalent consumer egress policy when they are not represented by a standard address range.
157
+
158
+ Address labels cannot prove reachability. DNS rebinding is prevented only when the caller pins. Ruby `Resolv` can differ from NSS sources such as mDNS or LDAP; platform and version configuration can also change AAAA behavior. A glibc `no-aaaa` setting does not control pure-Ruby `Resolv`.
159
+
160
+ Custom DNS64 prefixes cannot be decoded from an address without deployment configuration. ISATAP and 6rd can give deployment-specific meaning to otherwise public-looking IPv6 addresses. Publicly addressed space may be routed internally. Platform aliases can also live in globally addressed space. Surfguard does not add a hostname blocklist or attempt to infer those topologies. Enforce outbound firewall/egress ACLs so the fetch worker cannot reach internal services even when addressing is unusual.
139
161
 
140
- Surfguard is a security control, so classification bugs are vulnerabilities. Report them privately
141
- per the [security policy](https://github.com/basecamp/surfguard/security/policy) — not the public
142
- issue tracker.
162
+ The default policy intentionally admits public NAT64/SIIT destinations and globally reachable AMT/AS112 services. The strict policy intentionally overblocks all registered special-purpose prefixes. Choose based on the trusted source of the target, not target-supplied data.
143
163
 
144
- ## Status
164
+ Operator-named development fixtures are outside Surfguard policy. If a consumer needs one, make it an exact consumer-owned exception gated by trusted configuration and restricted to an approved scheme, literal loopback address, and exact port. Do not expose a general loopback mode or let request data activate it.
165
+
166
+ Environment proxies can cause a validated URL to connect somewhere else; construct `Net::HTTP` with the explicit `nil` proxy argument as above. Restrict supported schemes, userinfo, redirects, response bytes, decompression, and request duration in the consumer. Egress ACLs remain the final boundary.
167
+
168
+ ## Registry data
169
+
170
+ Runtime classification never fetches registry data. Normalized IANA snapshots, source URLs, registry update dates, and raw SHA-256 digests live under `script/iana`. Generated runtime constants are deeply frozen. Scheduled and release checks report drift and require a reviewed human update; they never rewrite policy automatically.
171
+
172
+ ## Dependency residual risk
173
+
174
+ Ruby 3.4.5 is the minimum supported interpreter. CI rejects effective `resolv` versions `<0.2.3`, `>=0.3.0,<0.3.1`, and `>=0.4.0,<0.6.2`. Explicitly replacing the patched standard-library gem with an affected version is unsupported residual risk. Surfguard will not add a runtime dependency or runtime version guard for that operator override.
175
+
176
+ ## Security
145
177
 
146
- Extracted and consolidated from several in-house SSRF guards, tested against the full
147
- policy matrix. Resolve-and-classify only; callers pin. See the
148
- [releases page](https://github.com/basecamp/surfguard/releases) for versions and changes.
178
+ Report classification vulnerabilities privately using the [security policy](https://github.com/basecamp/surfguard/security/policy). Do not open a public issue.
data/SECURITY.md CHANGED
@@ -31,6 +31,25 @@ pinning, custom DNS64 prefixes, and nonstandard NSS sources have caller or deplo
31
31
  documented in the README. Those boundaries by themselves are not classification bugs, but a bypass
32
32
  when the documented contract is followed qualifies.
33
33
 
34
+ Synchronous name resolution has no independent cancellation deadline; callers own request and
35
+ worker deadlines. Public-addressed internal routes, custom DNS64, ISATAP/6rd, provider aliases,
36
+ proxy configuration, and resolver/NSS divergence remain deployment risks requiring egress controls.
37
+
38
+ ## Accepted governance risk
39
+
40
+ SG-02 is accepted as a **High residual risk**: one principal can initiate and approve a release,
41
+ self-review remains enabled, and the current admin/write radius is external trust. Until a second
42
+ authorized principal is available, the compensating controls are immutable release tags, no admin
43
+ environment bypass, protected workflows, least-privilege jobs, reproducible package comparison,
44
+ canonical-registry-byte confirmation, and read-only control-state checks. This decision is revisited
45
+ only when a second authorized principal becomes available.
46
+
47
+ Recovery dispatched from `main` retains `main`-bound provenance, and release tags remain unsigned;
48
+ both are documented accepted residuals. GitHub-hosted runners, the Actions artifact service, and
49
+ GitHub's control plane remain external trust; fresh-runner verification, reproducible rebuilding,
50
+ and canonical-registry digest equality reduce but cannot eliminate that exposure. Explicitly
51
+ overriding a supported Ruby with a vulnerable `resolv` gem is unsupported residual risk.
52
+
34
53
  Non-security bugs and questions belong in the
35
54
  [regular issue tracker](https://github.com/basecamp/surfguard/issues).
36
55
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Surfguard
4
- VERSION = "0.1.3"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/surfguard.rb CHANGED
@@ -7,358 +7,393 @@ require "uri"
7
7
 
8
8
  require_relative "surfguard/version"
9
9
 
10
- # One SSRF address policy for a Ruby app that fetches a URL someone else supplied.
11
- # It consolidates several drifting in-house copies of this policy — copies that had
12
- # grown four different ideas of what "internal" means, including one that decoded a
13
- # NAT64 prefix whose length is not recoverable from the address. This is their
14
- # union, decided once.
15
- #
16
- # Two policy decisions the copies drifted on, settled here and explained where
17
- # they live in the code:
18
- #
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.
29
- #
30
- # * `no-aaaa` in Kamal `dns-opt` is NOT a mitigation. It is a glibc
31
- # getaddrinfo option; every guard here resolves through pure-Ruby Resolv,
32
- # which asks for AAAA regardless. IPv6 answers reach this code in production.
33
- # Do not treat the deploy config as if it narrowed the input.
34
- #
35
- # THREAT MODEL / caller contract: this only resolves and classifies. It cannot
36
- # stop DNS rebinding on its own. A caller must PIN the connection to an address
37
- # this returned (Net::HTTP#ipaddr=), because a second lookup at connect time can
38
- # answer differently than the one that was validated. The plural API exists so
39
- # that a caller failing over between addresses iterates the validated list rather
40
- # than resolving again inside its retry loop. A non-pinning caller must instead
41
- # use .resolvable_public_ip?/.enforce_public_ip, which refuse unless EVERY
42
- # resolved address is public.
10
+ # Resolve and classify addresses for callers that pin the selected address at
11
+ # connection time. Surfguard deliberately does not perform HTTP requests.
43
12
  module Surfguard
44
13
  class Violation < StandardError; end
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
-
52
- # The host answered with no address at all. Deliberately NOT a Violation:
53
- # nothing was refused here, the lookup simply came back empty, which is
54
- # usually a transient upstream failure. A caller that gives up permanently on
55
- # a Violation -- a webhook deactivating a customer's endpoint, say -- must not
56
- # give up the same way on this, or one bad DNS minute retires the endpoint for
57
- # good. Callers that don't care can rescue both.
58
14
  class Unresolvable < StandardError; end
59
15
 
16
+ class InvalidInput < ArgumentError; end
17
+ class InvalidResolverResult < StandardError; end
18
+ private_constant :InvalidInput, :InvalidResolverResult
19
+
60
20
  extend self
61
21
 
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.
67
- DISALLOWED_IPV4 = [
68
- IPAddr.new("0.0.0.0/8"), # "This" network (RFC 1122)
69
- IPAddr.new("10.0.0.0/8"), # Private (RFC 1918)
70
- IPAddr.new("100.64.0.0/10"), # Carrier-grade NAT (RFC 6598)
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
73
- IPAddr.new("169.254.0.0/16"), # Link-local (RFC 3927) — includes the cloud metadata endpoint
74
- IPAddr.new("172.16.0.0/12"), # Private (RFC 1918)
75
- IPAddr.new("192.0.0.0/24"), # IETF protocol assignments (RFC 6890)
76
- IPAddr.new("192.0.2.0/24"), # TEST-NET-1 (RFC 5737)
77
- IPAddr.new("192.88.99.0/24"), # 6to4 relay anycast (RFC 7526)
78
- IPAddr.new("192.168.0.0/16"), # Private (RFC 1918)
79
- IPAddr.new("198.18.0.0/15"), # Benchmark testing (RFC 2544)
80
- IPAddr.new("198.51.100.0/24"), # TEST-NET-2 (RFC 5737)
81
- IPAddr.new("203.0.113.0/24"), # TEST-NET-3 (RFC 5737)
82
- IPAddr.new("224.0.0.0/4"), # Multicast (RFC 5771)
83
- IPAddr.new("240.0.0.0/4") # Reserved / future use (RFC 1112)
84
- ].freeze
85
-
86
- # IPv6 special-use ranges beyond what private? (ULA fc00::/7, incl. the IMDSv6
87
- # address fd00:ec2::254), loopback (::1) and link-local (fe80::/10) already
88
- # cover. 6to4 and Teredo are deprecated transition mechanisms with no
89
- # legitimate fetch target — 2002:7f00:1:: is just a 6to4 spelling of 127.0.0.1.
90
- DISALLOWED_IPV6 = [
91
- IPAddr.new("::/128"), # Unspecified (RFC 4291)
92
- IPAddr.new("100::/64"), # Discard-only (RFC 6666)
93
- IPAddr.new("100:0:0:1::/64"), # Dummy IPv6 destination (RFC 9780)
94
- IPAddr.new("2001::/32"), # Teredo (RFC 4380)
95
- IPAddr.new("2001:2::/48"), # Benchmark testing (RFC 5180)
96
- IPAddr.new("2001:db8::/32"), # Documentation (RFC 3849)
97
- IPAddr.new("2002::/16"), # 6to4 (RFC 3056)
98
- IPAddr.new("3fff::/20"), # Documentation (RFC 9637)
99
- IPAddr.new("5f00::/16"), # SRv6 SIDs, confined to SR domains (RFC 9602)
100
- IPAddr.new("fec0::/10"), # Deprecated site-local (RFC 3879)
101
- IPAddr.new("ff00::/8") # Multicast (RFC 4291)
102
- ].freeze
103
-
104
- # The IANA parent assignment is non-source, non-destination, non-forwardable,
105
- # and non-global unless a more-specific allocation says otherwise. Refuse it
106
- # by default so unallocated/future-special space cannot become an internal
107
- # fetch path. These are the current, narrowly scoped IP-layer services whose
108
- # registrations explicitly permit globally reachable destinations without
109
- # selecting nearby infrastructure. PCP, TURN, and DNS-SD SRP anycast remain
110
- # refused because they deliberately target on-path or local services. ORCHID,
111
- # ORCHIDv2, and DET are overlay identifiers, not ordinary IP-layer locators.
112
- IETF_PROTOCOL_ASSIGNMENTS = IPAddr.new("2001::/23")
113
- GLOBALLY_REACHABLE_IETF_ASSIGNMENTS = [
114
- IPAddr.new("2001:3::/32"), # AMT (RFC 7450)
115
- IPAddr.new("2001:4:112::/48") # AS112-v6 (RFC 7535)
116
- ].freeze
117
-
118
- # NAT64 embeds an IPv4 target that must be re-checked as IPv4. The well-known
119
- # prefix is a fixed /96, so the embedded octets are always the low 32 bits:
120
- # decode and re-check them, and NAT64 to a public address still resolves.
121
- NAT64_WELL_KNOWN = IPAddr.new("64:ff9b::/96") # RFC 6052
122
-
123
- # The RFC 8215 local-use block is refused whole, not decoded. It can host a
124
- # Pref64 of any length (/32…/96) whose embedded position is NOT recoverable
125
- # from the address alone (RFC 6052 §2.2), so decoding the low 32 bits reads the
126
- # wrong octets and can under-block. It is also never globally routed, so there
127
- # is no legitimate feed behind it. This is the divergence some in-house copies
128
- # got wrong by decoding both prefixes the same way.
129
- NAT64_LOCAL_USE = IPAddr.new("64:ff9b:1::/48") # RFC 8215
130
-
131
- # SIIT's IPv4-translated form is the third way an IPv4 address rides inside an
132
- # IPv6 one, and the only one Ruby has no predicate for: ipv4_mapped?,
133
- # ipv4_compat?, private?, loopback? and link_local? are all false for
134
- # ::ffff:0:169.254.169.254, so it would reach the metadata address straight
135
- # through the branches below. Note the extra group — ::ffff:0:0:0/96 is NOT the
136
- # familiar IPv4-mapped ::ffff:0:0/96, and the two ranges do not overlap. Like
137
- # the NAT64 well-known prefix it is a fixed /96, so decode the low 32 bits.
138
- IPV4_TRANSLATABLE = IPAddr.new("::ffff:0:0:0/96") # RFC 2765
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
-
145
- # Every PUBLIC address the host resolves to, IPv4 ahead of IPv6, DNS order
146
- # preserved within each family so a provider's round-robin still spreads load.
147
- # Empty when the host resolves but every address is blocked; raises
148
- # Unresolvable when it resolves to nothing at all, so the caller can tell a
149
- # refusal from a lookup failure. A caller that fails over MUST iterate this
150
- # list and pin each address; resolving again reopens the rebinding window.
151
- # Accepts a hostname or an IP-literal host.
152
- def resolve_public_ips(host)
153
- addresses = resolve(host)
154
- raise Unresolvable, "No address for #{host}" if addresses.empty?
155
-
156
- addresses.reject { |ip| blocked_address?(ip) }
157
- .partition(&:ipv4?)
158
- .flatten
159
- .map(&:to_s)
160
- rescue InvalidHost
161
- []
22
+ UNRESOLVABLE_MESSAGE = "Host could not be resolved"
23
+ BLOCKED_MESSAGE = "Refusing blocked address"
24
+ MALFORMED_MESSAGE = "Refusing malformed address"
25
+ MAX_HOST_BYTES = 255
26
+ MAX_ADDRESSES = 256
27
+ POLICIES = %i[default iana_special_use].freeze
28
+
29
+ # iana-generator:begin IANA_ALLOCATED_IPV6_UNICAST
30
+ # Generated from IANA IPv6 Global Unicast Status=ALLOCATED rows.
31
+ # Source provenance is checked in under script/iana.
32
+ IANA_ALLOCATED_IPV6_UNICAST = %w[
33
+ 2001::/23 2001:200::/23 2001:400::/23 2001:600::/23 2001:800::/22
34
+ 2001:c00::/23 2001:e00::/23 2001:1200::/23 2001:1400::/22 2001:1800::/23
35
+ 2001:1a00::/23 2001:1c00::/22 2001:2000::/19 2001:4000::/23 2001:4200::/23
36
+ 2001:4400::/23 2001:4600::/23 2001:4800::/23 2001:4a00::/23 2001:4c00::/23
37
+ 2001:5000::/20 2001:8000::/19 2001:a000::/20 2001:b000::/20 2002::/16
38
+ 2003::/18 2400::/12 2410::/12 2600::/12 2610::/23 2620::/23 2630::/12
39
+ 2800::/12 2a00::/12 2a10::/12 2c00::/12
40
+ ].map { |cidr| IPAddr.new(cidr).freeze }.freeze
41
+ # iana-generator:end IANA_ALLOCATED_IPV6_UNICAST
42
+
43
+ DISALLOWED_IPV4 = %w[
44
+ 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8
45
+ 168.63.129.16/32 169.254.0.0/16 172.16.0.0/12 192.0.0.0/24
46
+ 192.0.2.0/24 192.88.99.0/24 192.168.0.0/16 198.18.0.0/15
47
+ 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4
48
+ ].map { |cidr| IPAddr.new(cidr).freeze }.freeze
49
+
50
+ DISALLOWED_IPV6 = %w[
51
+ ::/128 100::/64 100:0:0:1::/64 2001::/32 2001:2::/48
52
+ 2001:db8::/32 2002::/16 3fff::/20 5f00::/16 fec0::/10 ff00::/8
53
+ ].map { |cidr| IPAddr.new(cidr).freeze }.freeze
54
+
55
+ # iana-generator:begin IANA_SPECIAL_USE_IPV4
56
+ # Every prefix in the checked-in IANA IPv4 special-purpose snapshot.
57
+ IANA_SPECIAL_USE_IPV4 = %w[
58
+ 0.0.0.0/8 0.0.0.0/32 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16
59
+ 172.16.0.0/12 192.0.0.0/24 192.0.0.0/29 192.0.0.8/32 192.0.0.9/32
60
+ 192.0.0.10/32 192.0.0.170/32 192.0.0.171/32 192.0.2.0/24 192.31.196.0/24
61
+ 192.52.193.0/24 192.88.99.0/24 192.88.99.2/32 192.168.0.0/16
62
+ 192.175.48.0/24 198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 240.0.0.0/4
63
+ 255.255.255.255/32
64
+ ].map { |cidr| IPAddr.new(cidr).freeze }.freeze
65
+ # iana-generator:end IANA_SPECIAL_USE_IPV4
66
+
67
+ # iana-generator:begin IANA_SPECIAL_USE_IPV6
68
+ # Every prefix in the checked-in IANA IPv6 special-purpose snapshot.
69
+ IANA_SPECIAL_USE_IPV6 = %w[
70
+ ::1/128 ::/128 ::ffff:0:0/96 64:ff9b::/96 64:ff9b:1::/48 100::/64
71
+ 100:0:0:1::/64 2001::/23 2001::/32 2001:1::1/128 2001:1::2/128
72
+ 2001:1::3/128 2001:2::/48 2001:3::/32 2001:4:112::/48 2001:10::/28
73
+ 2001:20::/28 2001:30::/28 2001:db8::/32 2002::/16 2620:4f:8000::/48
74
+ 3fff::/20 5f00::/16 fc00::/7 fe80::/10
75
+ ].map { |cidr| IPAddr.new(cidr).freeze }.freeze
76
+ # iana-generator:end IANA_SPECIAL_USE_IPV6
77
+
78
+ IETF_PROTOCOL_ASSIGNMENTS = IPAddr.new("2001::/23").freeze
79
+ GLOBALLY_REACHABLE_IETF_ASSIGNMENTS = %w[
80
+ 2001:3::/32 2001:4:112::/48
81
+ ].map { |cidr| IPAddr.new(cidr).freeze }.freeze
82
+ NAT64_WELL_KNOWN = IPAddr.new("64:ff9b::/96").freeze
83
+ NAT64_LOCAL_USE = IPAddr.new("64:ff9b:1::/48").freeze
84
+ IPV4_TRANSLATABLE = IPAddr.new("::ffff:0:0:0/96").freeze
85
+ IPV4_COMPATIBLE = IPAddr.new("::/96").freeze
86
+
87
+ POLICY_RANGES = {
88
+ default: {
89
+ allocated_ipv6: IANA_ALLOCATED_IPV6_UNICAST,
90
+ disallowed_ipv4: DISALLOWED_IPV4,
91
+ disallowed_ipv6: DISALLOWED_IPV6
92
+ }.freeze,
93
+ iana_special_use: {
94
+ ipv4: IANA_SPECIAL_USE_IPV4,
95
+ ipv6: IANA_SPECIAL_USE_IPV6
96
+ }.freeze
97
+ }.freeze
98
+
99
+ # Return every admitted address, with IPv4 before IPv6 and resolver order
100
+ # retained within each family. Malformed direct input returns [].
101
+ def resolve_public_ips(host, policy: :default)
102
+ validate_policy!(policy)
103
+ addresses = resolve(normalize_host(host))
104
+ public_addresses = addresses.reject { |ip| blocked_address?(ip, policy: policy) }
105
+ ipv4, ipv6 = public_addresses.partition(&:ipv4?)
106
+ (ipv4 + ipv6).map { |ip| ip.to_s.freeze }.freeze
107
+ rescue InvalidInput
108
+ [].freeze
162
109
  end
163
110
 
164
- # True only if the URL's host resolves to at least one address and NONE are
165
- # blocked. For non-pinning callers (they hand the hostname straight to
166
- # Net::HTTP, which resolves again), so anything short of "every address is
167
- # public" is unsafe. A predicate answers the question it was asked and doesn't
168
- # raise: false covers unresolvable and malformed alike. Reach for
169
- # .enforce_public_ip or .resolve_public_ip when you need to tell those apart.
170
- def resolvable_public_ip?(url)
171
- addresses = resolve(host_of(url))
172
- addresses.any? && addresses.none? { |ip| blocked_address?(ip) }
173
- rescue URI::InvalidURIError, IPAddr::InvalidAddressError, ArgumentError
111
+ # True only when the URL resolves and every answer is admitted.
112
+ def resolvable_public_ip?(url, policy: :default)
113
+ validate_policy!(policy)
114
+ addresses = resolve(host_of(normalize_url(url)))
115
+ addresses.none? { |ip| blocked_address?(ip, policy: policy) }
116
+ rescue InvalidInput, Unresolvable
174
117
  false
175
118
  end
176
119
 
177
- # Raise unless the URL's host is safe, for call sites that want a hard stop
178
- # rather than a boolean: Unresolvable when it answers with nothing, Violation
179
- # when it answers with something we refuse. A malformed URL is a Violation --
180
- # there was never a lookup to fail.
181
- def enforce_public_ip(url)
182
- addresses = resolve(host_of(url))
183
- raise Unresolvable, "No address for #{url}" if addresses.empty?
184
- raise Violation, "Refusing to fetch private/internal address for #{url}" if addresses.any? { |ip| blocked_address?(ip) }
185
- rescue URI::InvalidURIError, IPAddr::InvalidAddressError, ArgumentError
186
- raise Violation, "Refusing to fetch malformed address for #{url}"
120
+ def enforce_public_ip(url, policy: :default)
121
+ validate_policy!(policy)
122
+ addresses = resolve(host_of(normalize_url(url)))
123
+ if addresses.any? { |ip| blocked_address?(ip, policy: policy) }
124
+ raise Violation, BLOCKED_MESSAGE, cause: nil
125
+ end
126
+
127
+ nil
128
+ rescue InvalidInput
129
+ raise Violation, MALFORMED_MESSAGE, cause: nil
187
130
  end
188
131
 
189
- # The single-address compatibility shim for callers migrating from an older
190
- # first-address-only guard. Returns the first public address as a String, nil
191
- # if the host is malformed or resolves to anything blocked, and raises
192
- # Unresolvable if it resolves to nothing -- which is where an older guard
193
- # built on Resolv.getaddress raised Resolv::ResolvError, so callers that
194
- # distinguished a lookup failure keep doing so. Prefer .resolve_public_ips.
195
- def resolve_public_ip(url)
196
- addresses = resolve(host_of(url))
197
- raise Unresolvable, "No address for #{url}" if addresses.empty?
198
- return nil if addresses.any? { |ip| blocked_address?(ip) }
199
-
200
- addresses.first.to_s
201
- rescue URI::InvalidURIError, IPAddr::InvalidAddressError, ArgumentError
132
+ # Preserve resolver order here; unlike the plural API this method does not
133
+ # reorder address families.
134
+ def resolve_public_ip(url, policy: :default)
135
+ validate_policy!(policy)
136
+ addresses = resolve(host_of(normalize_url(url)))
137
+ return nil if addresses.any? { |ip| blocked_address?(ip, policy: policy) }
138
+
139
+ addresses.first.to_s.freeze
140
+ rescue InvalidInput
202
141
  nil
203
142
  end
204
143
 
205
- # The classification core. True if this address must never be a fetch target.
206
- # Accepts an IPAddr or anything IPAddr.new understands. Errs closed: an address
207
- # it cannot parse is blocked.
208
- def blocked_address?(ip)
209
- ipaddr = ip.is_a?(IPAddr) ? ip : IPAddr.new(ip.to_s)
210
- return true unless host_address?(ipaddr)
211
-
212
- # DNS never legitimately returns an IPv4 address embedded these two ways, so
213
- # refuse them regardless of the address they wrap.
214
- if ipaddr.ipv4_mapped? || IPV4_COMPATIBLE.include?(ipaddr)
215
- true
216
- elsif ipaddr.ipv4?
217
- disallowed_ipv4?(ipaddr)
218
- elsif NAT64_LOCAL_USE.include?(ipaddr)
219
- true
220
- elsif NAT64_WELL_KNOWN.include?(ipaddr) || IPV4_TRANSLATABLE.include?(ipaddr)
221
- disallowed_ipv4?(embedded_ipv4(ipaddr))
222
- else
223
- disallowed_ipv6?(ipaddr)
144
+ # Classify one endpoint. Malformed inputs and networks fail closed.
145
+ def blocked_address?(ip, policy: :default)
146
+ validate_policy!(policy)
147
+ ipaddr = normalize_ip(ip)
148
+
149
+ return true if policy == :iana_special_use && iana_special_use?(ipaddr)
150
+ return true if ipaddr.ipv4_mapped? || IPV4_COMPATIBLE.include?(ipaddr)
151
+ return disallowed_ipv4?(ipaddr) if ipaddr.ipv4?
152
+ return true if NAT64_LOCAL_USE.include?(ipaddr)
153
+
154
+ if NAT64_WELL_KNOWN.include?(ipaddr) || IPV4_TRANSLATABLE.include?(ipaddr)
155
+ return disallowed_ipv4?(embedded_ipv4(ipaddr), policy: policy)
224
156
  end
225
- rescue IPAddr::InvalidAddressError
157
+
158
+ disallowed_ipv6?(ipaddr)
159
+ rescue InvalidInput
226
160
  true
227
161
  end
228
162
 
229
163
  private
164
+ def validate_policy!(policy)
165
+ return policy if POLICIES.include?(policy)
230
166
 
231
- # A numeric host skips DNS so a public literal URL resolves directly and an
232
- # internal literal is still caught by blocked_address?. Socket's numeric-only
233
- # parser comes first because it accepts every legacy IPv4 spelling the
234
- # connection layer does (decimal, hex, octal, and shortened forms), while
235
- # IPAddr does not. Otherwise resolve via Resolv.getaddresses, returning every
236
- # address from Ruby's hosts-plus-DNS chain. System getaddrinfo may consult
237
- # additional NSS sources; pinning an address returned here avoids that second
238
- # resolution. Returns [IPAddr].
239
- def resolve(host)
240
- literals = numeric_literals(host)
241
-
242
- if literals
243
- literals
244
- else
245
- Resolv.getaddresses(host).map { |a| IPAddr.new(a) }
167
+ raise ArgumentError, "unknown policy", cause: nil
246
168
  end
247
- rescue Resolv::ResolvError, Resolv::ResolvTimeout
248
- []
249
- end
250
169
 
251
- # Every address Socket's numeric-only parser finds. AI_NUMERICHOST guarantees
252
- # this call never falls through to DNS. Its success is authoritative: these
253
- # tokens must not be reinterpreted as names by Resolv, because Net::HTTP's
254
- # getaddrinfo call will reinterpret them as the numeric addresses returned
255
- # here. IPAddr remains as a fallback for syntax it alone accepts, notably a
256
- # full-width /32 or /128 host prefix. nil means the token is a name.
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}"
170
+ def normalize_url(url)
171
+ raise InvalidInput, cause: nil unless String === url
172
+
173
+ owned_string(url)
263
174
  end
264
175
 
265
- Socket.getaddrinfo(
266
- host, nil, Socket::AF_UNSPEC, Socket::SOCK_STREAM, 0, Socket::AI_NUMERICHOST
267
- ).map { |address| IPAddr.new(address[3]) }.uniq
268
- rescue SocketError
269
- literal = ip_literal(host)
270
- return [ literal ] if literal
176
+ def normalize_host(host)
177
+ if IPAddr === host
178
+ ip = copy_ipaddr(host)
179
+ raise InvalidInput, cause: nil unless host_address?(ip)
180
+
181
+ ip
182
+ elsif String === host
183
+ text = owned_string(host)
184
+ raise InvalidInput, cause: nil if text.empty? || text.bytesize > MAX_HOST_BYTES
185
+ raise InvalidInput, cause: nil if text.include?("%")
186
+
187
+ text
188
+ else
189
+ raise InvalidInput, cause: nil
190
+ end
191
+ end
271
192
 
272
- # An ordinary name may proceed to Resolv after AI_NUMERICHOST says it is
273
- # not numeric. A token shaped like a legacy IPv4 spelling may not: if the
274
- # system numeric parser itself failed abnormally, asking DNS would recreate
275
- # the parser/resolver identity gap this method exists to close.
276
- raise InvalidHost, "numeric-looking host #{host.inspect} could not be classified" if numeric_host_candidate?(host)
193
+ def normalize_ip(value)
194
+ ip = if IPAddr === value
195
+ copy_ipaddr(value)
196
+ elsif String === value
197
+ copy_ipaddr(IPAddr.new(owned_string(value)))
198
+ else
199
+ raise InvalidInput, cause: nil
200
+ end
201
+ raise InvalidInput, cause: nil unless host_address?(ip)
202
+
203
+ ip.freeze
204
+ rescue IPAddr::Error
205
+ raise InvalidInput, cause: nil
206
+ end
277
207
 
278
- nil
279
- end
208
+ def copy_ipaddr(ip)
209
+ family = IPAddr.instance_method(:family).bind_call(ip)
210
+ raise InvalidInput, cause: nil unless Integer === family
280
211
 
281
- def numeric_host_candidate?(host)
282
- text = host.to_s
283
- return true if text.include?(":") # Invalid IPv6 syntax is never a DNS name.
212
+ canonical_family = case family
213
+ when Socket::AF_INET
214
+ Socket::AF_INET
215
+ when Socket::AF_INET6
216
+ Socket::AF_INET6
217
+ else
218
+ raise InvalidInput, cause: nil
219
+ end
284
220
 
285
- legacy_ipv4_shape?(text)
286
- end
221
+ integer = IPAddr.instance_method(:to_i).bind_call(ip)
222
+ raise InvalidInput, cause: nil unless Integer === integer
287
223
 
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.
224
+ mask = Object.instance_method(:instance_variable_get).bind_call(ip, :@mask_addr)
225
+ raise InvalidInput, cause: nil unless Integer === mask
291
226
 
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)
227
+ prefix = IPAddr.instance_method(:prefix).bind_call(ip)
228
+ bits = canonical_family == Socket::AF_INET ? 32 : 128
229
+ raise InvalidInput, cause: nil unless (0..bits).cover?(prefix)
230
+ raise InvalidInput, cause: nil unless (0...(1 << bits)).cover?(integer)
231
+ raise InvalidInput, cause: nil unless mask == ((1 << prefix) - 1) << (bits - prefix)
301
232
 
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)
233
+ zone = Object.instance_method(:instance_variable_get).bind_call(ip, :@zone_id)
234
+ raise InvalidInput, cause: nil unless NilClass === zone
305
235
 
306
- true
307
- end
236
+ IPAddr.new(integer, canonical_family).mask(prefix)
237
+ end
308
238
 
309
- def legacy_ipv4_shape?(text)
310
- parts = text.split(".", -1).reject(&:empty?)
311
- (1..4).cover?(parts.length) &&
312
- parts.all? { |part| part.match?(/\A(?:0[xX][0-9A-Fa-f]+|[0-9]+)\z/) }
313
- end
239
+ def owned_string(value)
240
+ raise InvalidInput, cause: nil unless String === value
314
241
 
315
- def full_width_host_literal?(text)
316
- host_address?(IPAddr.new(text))
317
- rescue IPAddr::InvalidAddressError
318
- false
319
- end
242
+ text = String.new(value)
243
+ raise InvalidInput, cause: nil unless text.valid_encoding? && text.ascii_only?
244
+ raise InvalidInput, cause: nil if text.include?("\0")
320
245
 
321
- # nil for anything IPAddr cannot parse. A shortened prefix is not a host and
322
- # is rejected rather than normalized to its network address or sent to DNS.
323
- def ip_literal(host)
324
- ipaddr = IPAddr.new(host)
325
- raise InvalidHost, "#{host.inspect} is a network, not a host" unless host_address?(ipaddr)
246
+ text.freeze
247
+ end
326
248
 
327
- ipaddr
328
- rescue IPAddr::InvalidAddressError
329
- nil
330
- end
249
+ def resolve(host)
250
+ literals = if IPAddr === host
251
+ [ host ]
252
+ else
253
+ numeric_literals(host)
254
+ end
255
+ raw = literals || Resolv.getaddresses(host)
256
+ normalize_answers(raw)
257
+ rescue Resolv::ResolvError, Resolv::ResolvTimeout, InvalidResolverResult,
258
+ SocketError, SystemCallError, IOError
259
+ raise Unresolvable, UNRESOLVABLE_MESSAGE, cause: nil
260
+ end
331
261
 
332
- def host_address?(ipaddr)
333
- ipaddr.prefix == (ipaddr.ipv4? ? 32 : 128)
334
- end
262
+ def normalize_answers(raw)
263
+ raise InvalidResolverResult, cause: nil unless Array === raw
335
264
 
336
- def host_of(url)
337
- # URI#host is "" for "http://" and nil when there's no authority at all.
338
- # Neither is a name to look up, so both are malformed rather than a lookup
339
- # that failed. URI#host keeps IPv6 brackets ([::1]); IPAddr.new won't take them.
340
- host = URI.parse(url).host
341
- raise URI::InvalidURIError, "no host in #{url.inspect}" if host.nil? || host.empty?
265
+ answers = []
266
+ seen = {}
267
+ raw_count = 0
268
+ Array.instance_method(:each).bind_call(raw) do |answer|
269
+ raw_count += 1
270
+ raise InvalidResolverResult, cause: nil if raw_count > MAX_ADDRESSES
342
271
 
343
- host.delete_prefix("[").delete_suffix("]")
344
- end
272
+ ip = normalize_resolver_answer(answer)
273
+ key = [ ip.family, ip.to_i ]
274
+ next if seen[key]
345
275
 
346
- def disallowed_ipv4?(ipaddr)
347
- ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? ||
348
- DISALLOWED_IPV4.any? { |range| range.include?(ipaddr) }
349
- end
276
+ seen[key] = true
277
+ answers << ip
278
+ end
279
+ raise Unresolvable, UNRESOLVABLE_MESSAGE, cause: nil if answers.empty?
350
280
 
351
- def disallowed_ipv6?(ipaddr)
352
- return false if GLOBALLY_REACHABLE_IETF_ASSIGNMENTS.any? { |range| range.include?(ipaddr) }
281
+ answers.freeze
282
+ end
353
283
 
354
- ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? ||
355
- IETF_PROTOCOL_ASSIGNMENTS.include?(ipaddr) ||
356
- DISALLOWED_IPV6.any? { |range| range.include?(ipaddr) }
357
- end
284
+ def normalize_resolver_answer(answer)
285
+ raise InvalidResolverResult, cause: nil unless String === answer || IPAddr === answer
358
286
 
359
- # RFC 6052 §2.2: a fixed /96 translation prefix carries the IPv4 target in the
360
- # low 32 bits.
361
- def embedded_ipv4(ipaddr)
362
- IPAddr.new([ ipaddr.to_i & 0xffffffff ].pack("N").unpack("C4").join("."))
363
- end
287
+ normalize_ip(answer)
288
+ rescue InvalidInput
289
+ raise InvalidResolverResult, cause: nil
290
+ end
291
+
292
+ def numeric_literals(host)
293
+ raise InvalidInput, cause: nil unless valid_host_syntax?(host)
294
+ raise InvalidInput, cause: nil if malformed_numeric_host_candidate?(host)
295
+
296
+ raw = Socket.getaddrinfo(
297
+ host, nil, Socket::AF_UNSPEC, Socket::SOCK_STREAM, 0, Socket::AI_NUMERICHOST
298
+ )
299
+ raise InvalidResolverResult, cause: nil unless Array === raw
300
+
301
+ Array.instance_method(:map).bind_call(raw) do |answer|
302
+ raise InvalidResolverResult, cause: nil unless Array === answer
303
+
304
+ address = Array.instance_method(:[]).bind_call(answer, 3)
305
+ raise InvalidResolverResult, cause: nil unless String === address
306
+
307
+ address
308
+ end
309
+ rescue SocketError
310
+ literal = ip_literal(host)
311
+ return [ literal.freeze ] if literal
312
+
313
+ raise InvalidInput, cause: nil if numeric_host_candidate?(host)
314
+
315
+ nil
316
+ end
317
+
318
+ def valid_host_syntax?(host)
319
+ return true if host.include?(":") || legacy_ipv4_shape?(host) || full_width_host_literal?(host)
320
+
321
+ absolute = host.end_with?(".")
322
+ labels = (absolute ? host[0...-1] : host).split(".", -1)
323
+ return false if labels.empty? || labels.any?(&:empty?)
324
+
325
+ labels.all? do |label|
326
+ label.bytesize <= 63 && label.match?(/\A[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\z/)
327
+ end
328
+ end
329
+
330
+ def numeric_host_candidate?(host)
331
+ host.include?(":") || legacy_ipv4_shape?(host)
332
+ end
333
+
334
+ def malformed_numeric_host_candidate?(host)
335
+ return false if host.include?(":")
336
+
337
+ core = host.sub(%r{\A[%/]+}, "")[%r{\A[^%/]*}]
338
+ labels = core.split(".", -1)
339
+ malformed = core != host || labels.any?(&:empty?)
340
+ malformed && legacy_ipv4_shape?(core) && !full_width_host_literal?(host)
341
+ end
342
+
343
+ def legacy_ipv4_shape?(text)
344
+ parts = text.split(".", -1).reject(&:empty?)
345
+ (1..4).cover?(parts.length) &&
346
+ parts.all? { |part| part.match?(/\A(?:0[xX][0-9A-Fa-f]+|[0-9]+)\z/) }
347
+ end
348
+
349
+ def full_width_host_literal?(text)
350
+ host_address?(IPAddr.new(text))
351
+ rescue IPAddr::InvalidAddressError
352
+ false
353
+ end
354
+
355
+ def ip_literal(host)
356
+ normalize_ip(host)
357
+ rescue InvalidInput
358
+ nil
359
+ end
360
+
361
+ def host_address?(ip)
362
+ ip.prefix == (ip.ipv4? ? 32 : 128)
363
+ end
364
+
365
+ def host_of(url)
366
+ uri = URI.parse(url)
367
+ host = uri.host
368
+ raise InvalidInput, cause: nil if host.nil? || host.empty?
369
+ raise InvalidInput, cause: nil if host.match?(/\A\[v[0-9A-F]+\./i)
370
+
371
+ normalize_host(host.delete_prefix("[").delete_suffix("]"))
372
+ rescue URI::InvalidURIError
373
+ raise InvalidInput, cause: nil
374
+ end
375
+
376
+ def iana_special_use?(ip)
377
+ ranges = ip.ipv4? ? IANA_SPECIAL_USE_IPV4 : IANA_SPECIAL_USE_IPV6
378
+ ranges.any? { |range| range.include?(ip) }
379
+ end
380
+
381
+ def disallowed_ipv4?(ip, policy: :default)
382
+ (policy == :iana_special_use && iana_special_use?(ip)) ||
383
+ ip.private? || ip.loopback? || ip.link_local? ||
384
+ DISALLOWED_IPV4.any? { |range| range.include?(ip) }
385
+ end
386
+
387
+ def disallowed_ipv6?(ip)
388
+ return false if GLOBALLY_REACHABLE_IETF_ASSIGNMENTS.any? { |range| range.include?(ip) }
389
+ return true if ip.private? || ip.loopback? || ip.link_local?
390
+ return true if IETF_PROTOCOL_ASSIGNMENTS.include?(ip)
391
+ return true if DISALLOWED_IPV6.any? { |range| range.include?(ip) }
392
+
393
+ IANA_ALLOCATED_IPV6_UNICAST.none? { |range| range.include?(ip) }
394
+ end
395
+
396
+ def embedded_ipv4(ip)
397
+ IPAddr.new([ ip.to_i & 0xffffffff ].pack("N").unpack("C4").join("."))
398
+ end
364
399
  end
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.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - 37signals
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-08-13 00:00:00.000000000 Z
10
+ date: 2026-08-24 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
@@ -38,7 +38,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
38
38
  requirements:
39
39
  - - ">="
40
40
  - !ruby/object:Gem::Version
41
- version: '3.1'
41
+ version: 3.4.5
42
42
  required_rubygems_version: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - ">="