rastrillo / aviso Public

Guard the remaining IPv6 special ranges; test refusal at the dialer

Codex's review of the guard: RFC 8215's local-use NAT64 prefix
(64:ff9b:1::/48) was missing, and a local translator may point it at
private IPv4 space; the discard, benchmarking and second
documentation prefixes were missing too. All are in the CIDR list now.
The transport test only ever dialled localhost by name; the new test
calls the transport's own DialContext for private, mapped, NAT64 and
reserved literals and requires the guard's refusal — which comes from
Dialer.Control, before connect(2), so no packet leaves and no timeout
is waited for.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev c21664472918c4bbb928b2163e0b98cf0de840c6 parent e6070f0
2 files changed, +35 −6
  • ssrf.go +10 −6
  • ssrf_test.go +25 −0
diff --git a/ssrf.go b/ssrf.go
index 32db1f9..7fb1620 100644
--- a/ssrf.go
+++ b/ssrf.go
@@ -37,17 +37,21 @@ func validateEndpoint(raw string) error {
return nil
}
-// reservedNets are the ranges net.IP's own predicates do not cover:
-// "this network", IETF protocol assignments, the documentation and
-// benchmarking nets, class E, IPv6 documentation, and the NAT64
-// prefix (whose low 32 bits are an IPv4 address the IPv4 rules would
-// otherwise never see; no push service lives behind it).
+// reservedNets are the ranges net.IP's own predicates do not cover,
+// from IANA's special-purpose registries: "this network", IETF
+// protocol assignments, the documentation and benchmarking nets,
+// class E; and for IPv6 the discard prefix, benchmarking, both
+// documentation prefixes, and the NAT64 prefixes — the well-known one
+// and RFC 8215's local-use one, whose low 32 bits are an IPv4 address
+// the IPv4 rules would otherwise never see, and which a local
+// translator may point at private space.
var reservedNets = func() []*net.IPNet {
var out []*net.IPNet
for _, c := range []string{
"0.0.0.0/8", "192.0.0.0/24", "192.0.2.0/24", "198.18.0.0/15",
"198.51.100.0/24", "203.0.113.0/24", "240.0.0.0/4",
- "2001:db8::/32", "64:ff9b::/96",
+ "64:ff9b::/96", "64:ff9b:1::/48", "100::/64", "2001:2::/48",
+ "2001:db8::/32", "3fff::/20",
} {
_, n, err := net.ParseCIDR(c)
if err != nil {
diff --git a/ssrf_test.go b/ssrf_test.go
index dff2520..ecebfe7 100644
--- a/ssrf_test.go
+++ b/ssrf_test.go
@@ -8,6 +8,7 @@ import (
"net/http/httptest"
"strings"
"testing"
+ "time"
)
func TestValidateEndpoint(t *testing.T) {
@@ -44,6 +45,7 @@ func TestGuardedIP(t *testing.T) {
// Reserved ranges the net.IP predicates do not cover.
"0.1.2.3", "192.0.0.1", "192.0.2.1", "198.18.0.1", "198.19.255.255", "198.51.100.1",
"203.0.113.1", "240.0.0.1", "255.255.255.255", "2001:db8::1", "64:ff9b::a00:1",
+ "64:ff9b:1::a00:1", "100::1", "2001:2::1", "3fff::1",
"::", "224.0.0.1", "ff02::1",
}
for _, ip := range refused {
@@ -74,6 +76,29 @@ func TestClientRefusesLoopbackAtDial(t *testing.T) {
}
}
+// The transport's own dialer must refuse before any packet leaves: the
+// guard runs in Dialer.Control, which precedes connect(2), so each of
+// these fails instantly with the guard's error rather than a timeout.
+// Ports 9 (discard) would otherwise sit there.
+func TestClientDialerRefusesPrivateAddressesBeforeConnecting(t *testing.T) {
+ tr := newClient().Transport.(*http.Transport)
+ for _, addr := range []string{
+ "10.0.0.1:443", "172.16.5.5:443", "192.168.1.1:443", "169.254.169.254:80",
+ "[::ffff:10.0.0.1]:443", "[64:ff9b::a00:1]:443", "[64:ff9b:1::a00:1]:443",
+ "[fc00::1]:443", "[2001:db8::1]:443", "192.0.2.1:443", "127.0.0.1:9",
+ } {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ conn, err := tr.DialContext(ctx, "tcp", addr)
+ cancel()
+ if conn != nil {
+ conn.Close()
+ }
+ if err == nil || !strings.Contains(err.Error(), "aviso: dial refused") {
+ t.Errorf("%s: got %v, want the guard's refusal", addr, err)
+ }
+ }
+}
+
func TestClientRefusesRedirects(t *testing.T) {
c := newClient()
req, _ := http.NewRequest(http.MethodGet, "https://example.invalid/", nil)