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 +4 -4
- data/README.md +156 -126
- data/SECURITY.md +19 -0
- data/lib/surfguard/version.rb +1 -1
- data/lib/surfguard.rb +344 -309
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 74ab7507d8f5742bb87c321888a3118a5ed37531a7ac6f38ea8ef749d9426064
|
|
4
|
+
data.tar.gz: 1c41a7dfd92082bf33beb86aae3fbebfa159b374c7b55dec96c6a07089888774
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d7f6c61c6adcb3af2356763b49f462759672363768ad47bf25d3ecea838c42b1fff6b5a20a56392862f3dfd94df56da8d15b43fb62f3446621c4817411e11f9f
|
|
7
|
+
data.tar.gz: a0187fecbf3ce069a285cdf055c0f223e8f5056e319c1de3148bef3e26fd96eabd128c9ed1177fc948ba8300a15eae2b654c269368ddec33a0b34e9782c6bdd6
|
data/README.md
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
# Surfguard
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
+
Surfguard 0.2 requires Ruby 3.4.5 or newer and has zero runtime dependencies.
|
|
19
14
|
|
|
20
|
-
|
|
21
|
-
and
|
|
15
|
+
To verify a downloaded release artifact against its repository, normal release
|
|
16
|
+
workflow, and immutable tag:
|
|
22
17
|
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
Surfguard.resolve_public_ip(url) # => "93.184.216.34" or nil
|
|
32
|
+
## APIs and policies
|
|
35
33
|
|
|
36
|
-
|
|
37
|
-
Surfguard.
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/surfguard/version.rb
CHANGED
data/lib/surfguard.rb
CHANGED
|
@@ -7,358 +7,393 @@ require "uri"
|
|
|
7
7
|
|
|
8
8
|
require_relative "surfguard/version"
|
|
9
9
|
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
DISALLOWED_IPV6 = [
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
#
|
|
106
|
-
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
#
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
rescue
|
|
186
|
-
raise Violation,
|
|
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
|
-
#
|
|
190
|
-
#
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
addresses
|
|
197
|
-
|
|
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
|
-
#
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
return true
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
286
|
-
|
|
221
|
+
integer = IPAddr.instance_method(:to_i).bind_call(ip)
|
|
222
|
+
raise InvalidInput, cause: nil unless Integer === integer
|
|
287
223
|
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
303
|
-
|
|
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
|
-
|
|
307
|
-
|
|
236
|
+
IPAddr.new(integer, canonical_family).mask(prefix)
|
|
237
|
+
end
|
|
308
238
|
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
end
|
|
262
|
+
def normalize_answers(raw)
|
|
263
|
+
raise InvalidResolverResult, cause: nil unless Array === raw
|
|
335
264
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
344
|
-
|
|
272
|
+
ip = normalize_resolver_answer(answer)
|
|
273
|
+
key = [ ip.family, ip.to_i ]
|
|
274
|
+
next if seen[key]
|
|
345
275
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
276
|
+
seen[key] = true
|
|
277
|
+
answers << ip
|
|
278
|
+
end
|
|
279
|
+
raise Unresolvable, UNRESOLVABLE_MESSAGE, cause: nil if answers.empty?
|
|
350
280
|
|
|
351
|
-
|
|
352
|
-
|
|
281
|
+
answers.freeze
|
|
282
|
+
end
|
|
353
283
|
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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.
|
|
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-
|
|
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:
|
|
41
|
+
version: 3.4.5
|
|
42
42
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
43
43
|
requirements:
|
|
44
44
|
- - ">="
|