surfguard 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +120 -0
- data/SECURITY.md +40 -0
- data/lib/surfguard/version.rb +5 -0
- data/lib/surfguard.rb +244 -0
- metadata +52 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 743b735d0f0abcce2bcfa3660b7f658cceb1a760b90d970a86df76e91cb19622
|
|
4
|
+
data.tar.gz: 866a867fbde519a8512ae93c4fb6d8051d01b7de1591210e7f40f071376860a0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c3c1b340115fa7bc1190bec41e76ae2171ec23dfab10855122ddc91c3efb74864cdeed7960c4ab6c8d60770f04f520712e36118bf5c39750486b69a9f735f310
|
|
7
|
+
data.tar.gz: 587059deea1f701b3a8df14558a7b85c4dc24deb229a4fb61bdb02358c4120647c2e2624da498d4ce15658cbe5de77a1e394af50bf8acd8ac5f9ec080ee6cdef
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 37signals LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Surfguard
|
|
2
|
+
|
|
3
|
+
[](https://github.com/basecamp/surfguard/actions/workflows/ci.yml)
|
|
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
|
+
full IPv4 × IPv6 range matrix.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
Not yet published to RubyGems — `v0.1.0` will be the first packaged release. Until it ships,
|
|
15
|
+
install from source:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
gem "surfguard", github: "basecamp/surfguard"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Once `v0.1.0` is published:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
gem "surfguard"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## What it does
|
|
28
|
+
|
|
29
|
+
Resolve **and classify only**. It cannot stop DNS rebinding by itself — the caller owns the fetch
|
|
30
|
+
and must **pin** the connection to an address this returned.
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# Pinning caller (preferred): validate, then pin each address you try.
|
|
34
|
+
Surfguard.resolve_public_ips("feeds.example.com")
|
|
35
|
+
# => ["93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"] (IPv4 first, blocked removed)
|
|
36
|
+
# Iterate THIS list on failover; do not resolve again inside a retry loop.
|
|
37
|
+
|
|
38
|
+
# Non-pinning caller (hands the hostname straight to Net::HTTP, which resolves again):
|
|
39
|
+
Surfguard.resolvable_public_ip?("https://feeds.example.com/atom") # => true only if EVERY address is public
|
|
40
|
+
Surfguard.enforce_public_ip(url) # raises otherwise
|
|
41
|
+
|
|
42
|
+
# Single-address compatibility shim:
|
|
43
|
+
Surfguard.resolve_public_ip(url) # => "93.184.216.34" or nil
|
|
44
|
+
|
|
45
|
+
# The classification core, if you already hold an address:
|
|
46
|
+
Surfguard.blocked_address?(IPAddr.new("169.254.169.254")) # => true
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Refused vs. unresolvable
|
|
50
|
+
|
|
51
|
+
"We refuse that address" and "the host didn't answer" are different answers, and
|
|
52
|
+
collapsing them bites callers that treat a refusal as permanent. A webhook that
|
|
53
|
+
deactivates a customer's endpoint on `Violation` would retire it on one bad DNS
|
|
54
|
+
minute if a failed lookup arrived the same way.
|
|
55
|
+
|
|
56
|
+
| | resolves to something public | resolves, all blocked | resolves to nothing | malformed URL |
|
|
57
|
+
|---|---|---|---|---|
|
|
58
|
+
| `resolve_public_ips` (takes a host) | the public addresses | `[]` | raises `Unresolvable` | — |
|
|
59
|
+
| `resolve_public_ip` | first public address | `nil` | raises `Unresolvable` | `nil` |
|
|
60
|
+
| `enforce_public_ip` | returns | raises `Violation` | raises `Unresolvable` | raises `Violation` |
|
|
61
|
+
| `resolvable_public_ip?` | `true` | `false` | `false` | `false` |
|
|
62
|
+
|
|
63
|
+
`Unresolvable` is **not** a subclass of `Violation` — that's the whole point.
|
|
64
|
+
Rescue both where you don't care which it was. Note the predicate answers the
|
|
65
|
+
question it was asked and never raises; use `enforce_public_ip` or
|
|
66
|
+
`resolve_public_ip` when you need to tell the cases apart.
|
|
67
|
+
|
|
68
|
+
## The policy
|
|
69
|
+
|
|
70
|
+
| Range | Handling |
|
|
71
|
+
|---|---|
|
|
72
|
+
| IPv4 private (10/8, 172.16/12, 192.168/16), loopback (127/8), link-local (169.254/16) | refuse |
|
|
73
|
+
| 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 |
|
|
74
|
+
| IPv6 ULA (fc00::/7, incl. IMDSv6 `fd00:ec2::254`), loopback (::1), link-local (fe80::/10), site-local (fec0::/10), multicast (ff00::/8), unspecified (::), discard (100::/64), Teredo (2001::/32), docs (2001:db8::/32), benchmark (2001:2::/48) | refuse |
|
|
75
|
+
| IPv4-mapped `::ffff:0:0/96`, IPv4-compatible `::/96` | refuse outright |
|
|
76
|
+
| **SIIT `::ffff:0:0:0/96`** | decode embedded IPv4 (low 32 bits), re-check |
|
|
77
|
+
| NAT64 well-known `64:ff9b::/96` | decode embedded IPv4 (low 32 bits), re-check |
|
|
78
|
+
| **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 |
|
|
79
|
+
| 6to4 `2002::/16` | refuse (a 6to4 address is just an IPv4 address in disguise) |
|
|
80
|
+
|
|
81
|
+
## Two things worth knowing
|
|
82
|
+
|
|
83
|
+
**1. The resolver is part of the policy.** Surfguard resolves with `Resolv.getaddresses`, which
|
|
84
|
+
honours `/etc/hosts` and search domains **and** returns every address. The obvious alternatives each
|
|
85
|
+
drop something a guard can't afford to lose:
|
|
86
|
+
|
|
87
|
+
- `Resolv.getaddress` honours `/etc/hosts` but returns only the **first** address — so an AAAA-only
|
|
88
|
+
host deterministically takes the IPv6 path, and a multi-homed host is validated on one address
|
|
89
|
+
while the connection may use another.
|
|
90
|
+
- `Resolv::DNS.open` returns every address but **ignores** `/etc/hosts` and search domains — so it
|
|
91
|
+
validates a different set than `Net::HTTP` (which resolves through `getaddrinfo`) will actually
|
|
92
|
+
connect to. Demonstrable: `Resolv.getaddresses("localhost")` → `["::1","127.0.0.1"]` while
|
|
93
|
+
`Resolv::DNS.open`'s `each_address("localhost")` → `["::","0.0.0.0"]`.
|
|
94
|
+
|
|
95
|
+
`getaddresses` is both complete (every address) and faithful (the same chain the connection layer
|
|
96
|
+
uses).
|
|
97
|
+
|
|
98
|
+
**2. A resolver-level "no AAAA" switch is not a mitigation.** Disabling AAAA at the system resolver
|
|
99
|
+
(for example Kamal's `dns-opt: no-aaaa`) is a glibc `getaddrinfo` option. Surfguard resolves through
|
|
100
|
+
pure-Ruby `Resolv`, which requests AAAA regardless, so IPv6 answers still reach it. Don't treat that
|
|
101
|
+
deploy setting as if it narrowed Surfguard's input.
|
|
102
|
+
|
|
103
|
+
## Testing
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
ruby -Ilib test/surfguard_test.rb
|
|
107
|
+
# The full BLOCKED/ALLOWED matrix, checked as execution. Bare Ruby, no gems needed.
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Security
|
|
111
|
+
|
|
112
|
+
Surfguard is a security control, so classification bugs are vulnerabilities. Report them privately
|
|
113
|
+
per the [security policy](https://github.com/basecamp/surfguard/security/policy) — not the public
|
|
114
|
+
issue tracker.
|
|
115
|
+
|
|
116
|
+
## Status
|
|
117
|
+
|
|
118
|
+
Extracted and consolidated from several in-house SSRF guards, tested against the full
|
|
119
|
+
IPv4 × IPv6 special-use matrix. Resolve-and-classify only; callers pin. See the
|
|
120
|
+
[releases page](https://github.com/basecamp/surfguard/releases) for versions and changes.
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
Surfguard is a security control — an SSRF address guard — so classification bugs are security
|
|
4
|
+
vulnerabilities, not ordinary defects. Please report them privately.
|
|
5
|
+
|
|
6
|
+
## Reporting a vulnerability
|
|
7
|
+
|
|
8
|
+
Report privately via GitHub's vulnerability reporting:
|
|
9
|
+
[**Report a vulnerability**](https://github.com/basecamp/surfguard/security/advisories/new)
|
|
10
|
+
(the repo's **Security** tab → **Report a vulnerability**).
|
|
11
|
+
|
|
12
|
+
Open-source reports aren't bounty-eligible, but we also accept them via
|
|
13
|
+
[hackerone.com/basecamp](https://hackerone.com/basecamp) if you'd like the report on your
|
|
14
|
+
HackerOne record — see the
|
|
15
|
+
[37signals security response policy](https://37signals.com/policies/security/response/).
|
|
16
|
+
|
|
17
|
+
**Do not open a public issue for security bugs.**
|
|
18
|
+
|
|
19
|
+
## What qualifies
|
|
20
|
+
|
|
21
|
+
Anything that lets a blocked address through, for example:
|
|
22
|
+
|
|
23
|
+
- An address in a blocked range that Surfguard classifies as public.
|
|
24
|
+
- A resolution path (encoding, embedding, transition mechanism) that reaches a blocked address
|
|
25
|
+
despite validation.
|
|
26
|
+
- A discrepancy between what Surfguard validates and what a caller following the documented
|
|
27
|
+
pinning contract would connect to.
|
|
28
|
+
|
|
29
|
+
Non-security bugs and questions belong in the
|
|
30
|
+
[regular issue tracker](https://github.com/basecamp/surfguard/issues).
|
|
31
|
+
|
|
32
|
+
## Supported versions
|
|
33
|
+
|
|
34
|
+
No packaged release exists yet, so `main` is the supported version. Once releases ship, it
|
|
35
|
+
becomes the latest released version. Fixes ship as a new release, not backports.
|
|
36
|
+
|
|
37
|
+
## What to expect
|
|
38
|
+
|
|
39
|
+
We'll acknowledge your report, work with you on a fix, and coordinate disclosure through a
|
|
40
|
+
GitHub Security Advisory. Reporters are credited in the advisory unless they prefer otherwise.
|
data/lib/surfguard.rb
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "resolv"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "surfguard/version"
|
|
8
|
+
|
|
9
|
+
# One SSRF address policy for a Ruby app that fetches a URL someone else supplied.
|
|
10
|
+
# It consolidates several drifting in-house copies of this policy — copies that had
|
|
11
|
+
# grown four different ideas of what "internal" means, including one that decoded a
|
|
12
|
+
# NAT64 prefix whose length is not recoverable from the address. This is their
|
|
13
|
+
# union, decided once.
|
|
14
|
+
#
|
|
15
|
+
# Two policy decisions the copies drifted on, settled here and explained where
|
|
16
|
+
# they live in the code:
|
|
17
|
+
#
|
|
18
|
+
# * Resolver (see .resolve_public_ips): resolve with Resolv.getaddresses, which
|
|
19
|
+
# honours /etc/hosts and search domains AND returns every address. One copy
|
|
20
|
+
# used Resolv.getaddress (honours hosts, but only the FIRST address, so an
|
|
21
|
+
# AAAA-only host deterministically selected the IPv6 path); another used
|
|
22
|
+
# Resolv::DNS.open (all addresses, but ignores /etc/hosts, so it validated a
|
|
23
|
+
# different set than Net::HTTP would actually connect to). getaddresses is
|
|
24
|
+
# both complete and faithful to what the connection layer resolves.
|
|
25
|
+
#
|
|
26
|
+
# * `no-aaaa` in Kamal `dns-opt` is NOT a mitigation. It is a glibc
|
|
27
|
+
# getaddrinfo option; every guard here resolves through pure-Ruby Resolv,
|
|
28
|
+
# which asks for AAAA regardless. IPv6 answers reach this code in production.
|
|
29
|
+
# Do not treat the deploy config as if it narrowed the input.
|
|
30
|
+
#
|
|
31
|
+
# THREAT MODEL / caller contract: this only resolves and classifies. It cannot
|
|
32
|
+
# stop DNS rebinding on its own. A caller must PIN the connection to an address
|
|
33
|
+
# this returned (Net::HTTP#ipaddr=), because a second lookup at connect time can
|
|
34
|
+
# answer differently than the one that was validated. The plural API exists so
|
|
35
|
+
# that a caller failing over between addresses iterates the validated list rather
|
|
36
|
+
# than resolving again inside its retry loop. A non-pinning caller must instead
|
|
37
|
+
# use .resolvable_public_ip?/.enforce_public_ip, which refuse unless EVERY
|
|
38
|
+
# resolved address is public.
|
|
39
|
+
module Surfguard
|
|
40
|
+
class Violation < StandardError; end
|
|
41
|
+
|
|
42
|
+
# The host answered with no address at all. Deliberately NOT a Violation:
|
|
43
|
+
# nothing was refused here, the lookup simply came back empty, which is
|
|
44
|
+
# usually a transient upstream failure. A caller that gives up permanently on
|
|
45
|
+
# a Violation -- a webhook deactivating a customer's endpoint, say -- must not
|
|
46
|
+
# give up the same way on this, or one bad DNS minute retires the endpoint for
|
|
47
|
+
# good. Callers that don't care can rescue both.
|
|
48
|
+
class Unresolvable < StandardError; end
|
|
49
|
+
|
|
50
|
+
extend self
|
|
51
|
+
|
|
52
|
+
# IPv4 special-use ranges that must never be a fetch target (RFC 5735/6890,
|
|
53
|
+
# plus CGNAT and benchmarking). RFC1918 / loopback / link-local are also
|
|
54
|
+
# covered by the IPAddr predicates in #disallowed_ipv4?; they are restated here
|
|
55
|
+
# so the policy is complete and auditable in one place.
|
|
56
|
+
DISALLOWED_IPV4 = [
|
|
57
|
+
IPAddr.new("0.0.0.0/8"), # "This" network (RFC 1122)
|
|
58
|
+
IPAddr.new("10.0.0.0/8"), # Private (RFC 1918)
|
|
59
|
+
IPAddr.new("100.64.0.0/10"), # Carrier-grade NAT (RFC 6598)
|
|
60
|
+
IPAddr.new("127.0.0.0/8"), # Loopback (RFC 1122)
|
|
61
|
+
IPAddr.new("169.254.0.0/16"), # Link-local (RFC 3927) — includes the cloud metadata endpoint
|
|
62
|
+
IPAddr.new("172.16.0.0/12"), # Private (RFC 1918)
|
|
63
|
+
IPAddr.new("192.0.0.0/24"), # IETF protocol assignments (RFC 6890)
|
|
64
|
+
IPAddr.new("192.0.2.0/24"), # TEST-NET-1 (RFC 5737)
|
|
65
|
+
IPAddr.new("192.88.99.0/24"), # 6to4 relay anycast (RFC 7526)
|
|
66
|
+
IPAddr.new("192.168.0.0/16"), # Private (RFC 1918)
|
|
67
|
+
IPAddr.new("198.18.0.0/15"), # Benchmark testing (RFC 2544)
|
|
68
|
+
IPAddr.new("198.51.100.0/24"), # TEST-NET-2 (RFC 5737)
|
|
69
|
+
IPAddr.new("203.0.113.0/24"), # TEST-NET-3 (RFC 5737)
|
|
70
|
+
IPAddr.new("224.0.0.0/4"), # Multicast (RFC 5771)
|
|
71
|
+
IPAddr.new("240.0.0.0/4") # Reserved / future use (RFC 1112)
|
|
72
|
+
].freeze
|
|
73
|
+
|
|
74
|
+
# IPv6 special-use ranges beyond what private? (ULA fc00::/7, incl. the IMDSv6
|
|
75
|
+
# address fd00:ec2::254), loopback (::1) and link-local (fe80::/10) already
|
|
76
|
+
# cover. 6to4 and Teredo are deprecated transition mechanisms with no
|
|
77
|
+
# legitimate fetch target — 2002:7f00:1:: is just a 6to4 spelling of 127.0.0.1.
|
|
78
|
+
DISALLOWED_IPV6 = [
|
|
79
|
+
IPAddr.new("::/128"), # Unspecified (RFC 4291)
|
|
80
|
+
IPAddr.new("100::/64"), # Discard-only (RFC 6666)
|
|
81
|
+
IPAddr.new("2001::/32"), # Teredo (RFC 4380)
|
|
82
|
+
IPAddr.new("2001:2::/48"), # Benchmark testing (RFC 5180)
|
|
83
|
+
IPAddr.new("2001:db8::/32"), # Documentation (RFC 3849)
|
|
84
|
+
IPAddr.new("2002::/16"), # 6to4 (RFC 3056)
|
|
85
|
+
IPAddr.new("fec0::/10"), # Deprecated site-local (RFC 3879)
|
|
86
|
+
IPAddr.new("ff00::/8") # Multicast (RFC 4291)
|
|
87
|
+
].freeze
|
|
88
|
+
|
|
89
|
+
# NAT64 embeds an IPv4 target that must be re-checked as IPv4. The well-known
|
|
90
|
+
# prefix is a fixed /96, so the embedded octets are always the low 32 bits:
|
|
91
|
+
# decode and re-check them, and NAT64 to a public address still resolves.
|
|
92
|
+
NAT64_WELL_KNOWN = IPAddr.new("64:ff9b::/96") # RFC 6052
|
|
93
|
+
|
|
94
|
+
# The RFC 8215 local-use block is refused whole, not decoded. It can host a
|
|
95
|
+
# Pref64 of any length (/32…/96) whose embedded position is NOT recoverable
|
|
96
|
+
# from the address alone (RFC 6052 §2.2), so decoding the low 32 bits reads the
|
|
97
|
+
# wrong octets and can under-block. It is also never globally routed, so there
|
|
98
|
+
# is no legitimate feed behind it. This is the divergence some in-house copies
|
|
99
|
+
# got wrong by decoding both prefixes the same way.
|
|
100
|
+
NAT64_LOCAL_USE = IPAddr.new("64:ff9b:1::/48") # RFC 8215
|
|
101
|
+
|
|
102
|
+
# SIIT's IPv4-translated form is the third way an IPv4 address rides inside an
|
|
103
|
+
# IPv6 one, and the only one Ruby has no predicate for: ipv4_mapped?,
|
|
104
|
+
# ipv4_compat?, private?, loopback? and link_local? are all false for
|
|
105
|
+
# ::ffff:0:169.254.169.254, so it would reach the metadata address straight
|
|
106
|
+
# through the branches below. Note the extra group — ::ffff:0:0:0/96 is NOT the
|
|
107
|
+
# familiar IPv4-mapped ::ffff:0:0/96, and the two ranges do not overlap. Like
|
|
108
|
+
# the NAT64 well-known prefix it is a fixed /96, so decode the low 32 bits.
|
|
109
|
+
IPV4_TRANSLATABLE = IPAddr.new("::ffff:0:0:0/96") # RFC 2765
|
|
110
|
+
|
|
111
|
+
# Every PUBLIC address the host resolves to, IPv4 ahead of IPv6, DNS order
|
|
112
|
+
# preserved within each family so a provider's round-robin still spreads load.
|
|
113
|
+
# Empty when the host resolves but every address is blocked; raises
|
|
114
|
+
# Unresolvable when it resolves to nothing at all, so the caller can tell a
|
|
115
|
+
# refusal from a lookup failure. A caller that fails over MUST iterate this
|
|
116
|
+
# list and pin each address; resolving again reopens the rebinding window.
|
|
117
|
+
# Accepts a hostname or an IP-literal host.
|
|
118
|
+
def resolve_public_ips(host)
|
|
119
|
+
addresses = resolve(host)
|
|
120
|
+
raise Unresolvable, "No address for #{host}" if addresses.empty?
|
|
121
|
+
|
|
122
|
+
addresses.reject { |ip| blocked_address?(ip) }
|
|
123
|
+
.partition(&:ipv4?)
|
|
124
|
+
.flatten
|
|
125
|
+
.map(&:to_s)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# True only if the URL's host resolves to at least one address and NONE are
|
|
129
|
+
# blocked. For non-pinning callers (they hand the hostname straight to
|
|
130
|
+
# Net::HTTP, which resolves again), so anything short of "every address is
|
|
131
|
+
# public" is unsafe. A predicate answers the question it was asked and doesn't
|
|
132
|
+
# raise: false covers unresolvable and malformed alike. Reach for
|
|
133
|
+
# .enforce_public_ip or .resolve_public_ip when you need to tell those apart.
|
|
134
|
+
def resolvable_public_ip?(url)
|
|
135
|
+
addresses = resolve(host_of(url))
|
|
136
|
+
addresses.any? && addresses.none? { |ip| blocked_address?(ip) }
|
|
137
|
+
rescue URI::InvalidURIError, IPAddr::InvalidAddressError, ArgumentError
|
|
138
|
+
false
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Raise unless the URL's host is safe, for call sites that want a hard stop
|
|
142
|
+
# rather than a boolean: Unresolvable when it answers with nothing, Violation
|
|
143
|
+
# when it answers with something we refuse. A malformed URL is a Violation --
|
|
144
|
+
# there was never a lookup to fail.
|
|
145
|
+
def enforce_public_ip(url)
|
|
146
|
+
addresses = resolve(host_of(url))
|
|
147
|
+
raise Unresolvable, "No address for #{url}" if addresses.empty?
|
|
148
|
+
raise Violation, "Refusing to fetch private/internal address for #{url}" if addresses.any? { |ip| blocked_address?(ip) }
|
|
149
|
+
rescue URI::InvalidURIError, IPAddr::InvalidAddressError, ArgumentError
|
|
150
|
+
raise Violation, "Refusing to fetch malformed address for #{url}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The single-address compatibility shim for callers migrating from an older
|
|
154
|
+
# first-address-only guard. Returns the first public address as a String, nil
|
|
155
|
+
# if the host is malformed or resolves to anything blocked, and raises
|
|
156
|
+
# Unresolvable if it resolves to nothing -- which is where an older guard
|
|
157
|
+
# built on Resolv.getaddress raised Resolv::ResolvError, so callers that
|
|
158
|
+
# distinguished a lookup failure keep doing so. Prefer .resolve_public_ips.
|
|
159
|
+
def resolve_public_ip(url)
|
|
160
|
+
addresses = resolve(host_of(url))
|
|
161
|
+
raise Unresolvable, "No address for #{url}" if addresses.empty?
|
|
162
|
+
return nil if addresses.any? { |ip| blocked_address?(ip) }
|
|
163
|
+
|
|
164
|
+
addresses.first.to_s
|
|
165
|
+
rescue URI::InvalidURIError, IPAddr::InvalidAddressError, ArgumentError
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The classification core. True if this address must never be a fetch target.
|
|
170
|
+
# Accepts an IPAddr or anything IPAddr.new understands. Errs closed: an address
|
|
171
|
+
# it cannot parse is blocked.
|
|
172
|
+
def blocked_address?(ip)
|
|
173
|
+
ipaddr = ip.is_a?(IPAddr) ? ip : IPAddr.new(ip.to_s)
|
|
174
|
+
|
|
175
|
+
# DNS never legitimately returns an IPv4 address embedded these two ways, so
|
|
176
|
+
# refuse them regardless of the address they wrap.
|
|
177
|
+
if ipaddr.ipv4_mapped? || ipaddr.ipv4_compat?
|
|
178
|
+
true
|
|
179
|
+
elsif ipaddr.ipv4?
|
|
180
|
+
disallowed_ipv4?(ipaddr)
|
|
181
|
+
elsif NAT64_LOCAL_USE.include?(ipaddr)
|
|
182
|
+
true
|
|
183
|
+
elsif NAT64_WELL_KNOWN.include?(ipaddr) || IPV4_TRANSLATABLE.include?(ipaddr)
|
|
184
|
+
disallowed_ipv4?(embedded_ipv4(ipaddr))
|
|
185
|
+
else
|
|
186
|
+
disallowed_ipv6?(ipaddr)
|
|
187
|
+
end
|
|
188
|
+
rescue IPAddr::InvalidAddressError
|
|
189
|
+
true
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
private
|
|
193
|
+
|
|
194
|
+
# An IP-literal host skips DNS so a public literal URL resolves directly and an
|
|
195
|
+
# internal literal is still caught by blocked_address?. Otherwise resolve via
|
|
196
|
+
# Resolv.getaddresses — the Hosts+DNS chain, matching what the connection layer
|
|
197
|
+
# will use, and returning every address. Returns [IPAddr].
|
|
198
|
+
def resolve(host)
|
|
199
|
+
literal = ip_literal(host)
|
|
200
|
+
|
|
201
|
+
if literal
|
|
202
|
+
[ literal ]
|
|
203
|
+
else
|
|
204
|
+
Resolv.getaddresses(host).map { |a| IPAddr.new(a) }
|
|
205
|
+
end
|
|
206
|
+
rescue Resolv::ResolvError, Resolv::ResolvTimeout
|
|
207
|
+
[]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# nil for anything that isn't already an address. Kept separate so the DNS
|
|
211
|
+
# call above sits in the method body, where the ResolvError rescue can reach
|
|
212
|
+
# it — inside a sibling rescue clause it could not.
|
|
213
|
+
def ip_literal(host)
|
|
214
|
+
IPAddr.new(host)
|
|
215
|
+
rescue IPAddr::InvalidAddressError
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def host_of(url)
|
|
220
|
+
# URI#host is "" for "http://" and nil when there's no authority at all.
|
|
221
|
+
# Neither is a name to look up, so both are malformed rather than a lookup
|
|
222
|
+
# that failed. URI#host keeps IPv6 brackets ([::1]); IPAddr.new won't take them.
|
|
223
|
+
host = URI.parse(url).host
|
|
224
|
+
raise URI::InvalidURIError, "no host in #{url.inspect}" if host.nil? || host.empty?
|
|
225
|
+
|
|
226
|
+
host.delete_prefix("[").delete_suffix("]")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def disallowed_ipv4?(ipaddr)
|
|
230
|
+
ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? ||
|
|
231
|
+
DISALLOWED_IPV4.any? { |range| range.include?(ipaddr) }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def disallowed_ipv6?(ipaddr)
|
|
235
|
+
ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? ||
|
|
236
|
+
DISALLOWED_IPV6.any? { |range| range.include?(ipaddr) }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# RFC 6052 §2.2: a fixed /96 translation prefix carries the IPv4 target in the
|
|
240
|
+
# low 32 bits.
|
|
241
|
+
def embedded_ipv4(ipaddr)
|
|
242
|
+
IPAddr.new([ ipaddr.to_i & 0xffffffff ].pack("N").unpack("C4").join("."))
|
|
243
|
+
end
|
|
244
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: surfguard
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- 37signals
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-08-12 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: 'Surfguard resolves a hostname to the public IP addresses it points at
|
|
13
|
+
and refuses anything that would reach an internal network: private, loopback, link-local
|
|
14
|
+
and carrier-grade NAT space, plus the IPv6 transition ranges a naive guard misses
|
|
15
|
+
(IPv4-mapped, SIIT, NAT64, 6to4, Teredo). It resolves and classifies only; the caller
|
|
16
|
+
owns the fetch and pins the connection to a returned address so DNS rebinding cannot
|
|
17
|
+
swap in a blocked one. Standard library only, no runtime dependencies.'
|
|
18
|
+
executables: []
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- LICENSE
|
|
23
|
+
- README.md
|
|
24
|
+
- SECURITY.md
|
|
25
|
+
- lib/surfguard.rb
|
|
26
|
+
- lib/surfguard/version.rb
|
|
27
|
+
homepage: https://github.com/basecamp/surfguard
|
|
28
|
+
licenses:
|
|
29
|
+
- MIT
|
|
30
|
+
metadata:
|
|
31
|
+
bug_tracker_uri: https://github.com/basecamp/surfguard/issues
|
|
32
|
+
changelog_uri: https://github.com/basecamp/surfguard/releases
|
|
33
|
+
rubygems_mfa_required: 'true'
|
|
34
|
+
rdoc_options: []
|
|
35
|
+
require_paths:
|
|
36
|
+
- lib
|
|
37
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
38
|
+
requirements:
|
|
39
|
+
- - ">="
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '3.1'
|
|
42
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
requirements: []
|
|
48
|
+
rubygems_version: 4.0.18
|
|
49
|
+
specification_version: 4
|
|
50
|
+
summary: 'One SSRF address policy: resolve a host and classify special-use IPv4/IPv6
|
|
51
|
+
ranges'
|
|
52
|
+
test_files: []
|