Add the SSRF guard: endpoint validation and a dial-time IP check
Two halves on purpose. Validation catches shape and literal IPs at subscribe time; the dialer's Control runs on the address actually connected to, so a hostname that resolves to loopback after validation (DNS rebinding) still fails. No redirects and no proxy for the same reason: both turn a validated URL into an unvalidated one. The reserved ranges net.IP's predicates do not know — this-network, the documentation and benchmarking nets, class E, NAT64 — are an explicit CIDR list, each named in the test.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3 files changed,
+212
−0
aviso.go+1 −0ssrf.go+125 −0ssrf_test.go+86 −0
diff --git a/aviso.go b/aviso.go| index de016f4..a819ae7 100644 |
| --- a/aviso.go |
| +++ b/aviso.go |
| @@ -105,6 +105,7 @@ func New(cfg Config) (*Service, error) { |
| pub: pub, |
| keyID: keyID, |
| wireContact: strings.TrimPrefix(cfg.Contact, "mailto:"), |
| + client: newClient(), |
| sem: make(chan struct{}, cfg.Concurrency), |
| now: time.Now, |
| }, nil |
diff --git a/ssrf.go b/ssrf.go| new file mode 100644 |
| index 0000000..32db1f9 |
| --- /dev/null |
| +++ b/ssrf.go |
| @@ -0,0 +1,125 @@ |
| +package aviso |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + "net" |
| + "net/http" |
| + "net/url" |
| + "syscall" |
| + "time" |
| +) |
| + |
| +// ErrBadEndpoint means a subscription endpoint was refused before any |
| +// request: wrong scheme, credentials, fragment, too long, or an |
| +// address no push service lives at. |
| +var ErrBadEndpoint = errors.New("aviso: endpoint refused") |
| + |
| +const maxEndpointLen = 2048 |
| + |
| +// validateEndpoint is the request-time half of the SSRF guard: shape |
| +// and literal-IP checks. The dial-time half (guardedIP in the dialer's |
| +// Control) catches what a hostname resolves to, which this cannot. |
| +func validateEndpoint(raw string) error { |
| + if raw == "" || len(raw) > maxEndpointLen { |
| + return fmt.Errorf("%w: empty or over %d bytes", ErrBadEndpoint, maxEndpointLen) |
| + } |
| + u, err := url.Parse(raw) |
| + if err != nil || u.Scheme != "https" || u.Host == "" || u.Hostname() == "" || |
| + u.User != nil || u.Fragment != "" || u.RawFragment != "" { |
| + return fmt.Errorf("%w: must be https, no credentials, no fragment", ErrBadEndpoint) |
| + } |
| + if ip := net.ParseIP(u.Hostname()); ip != nil { |
| + if err := guardedIP(ip); err != nil { |
| + return fmt.Errorf("%w: %v", ErrBadEndpoint, err) |
| + } |
| + } |
| + 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). |
| +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", |
| + } { |
| + _, n, err := net.ParseCIDR(c) |
| + if err != nil { |
| + panic(err) |
| + } |
| + out = append(out, n) |
| + } |
| + return out |
| +}() |
| + |
| +// guardedIP refuses every address a push service cannot legitimately |
| +// have: loopback, private, link-local, unspecified, multicast, CGNAT, |
| +// the reserved ranges above, and their IPv4-mapped forms. Applied at |
| +// connect time so DNS rebinding after validation still fails. |
| +func guardedIP(ip net.IP) error { |
| + if ip == nil { |
| + return errors.New("address is not an IP") |
| + } |
| + if ip4 := ip.To4(); ip4 != nil { |
| + ip = ip4 |
| + } |
| + refuse := func() error { return fmt.Errorf("address %s is not routable to a push service", ip) } |
| + switch { |
| + case ip.IsLoopback(), ip.IsPrivate(), ip.IsLinkLocalUnicast(), ip.IsLinkLocalMulticast(), |
| + ip.IsUnspecified(), ip.IsMulticast(), ip.IsInterfaceLocalMulticast(): |
| + return refuse() |
| + } |
| + if len(ip) == net.IPv4len && ip[0] == 100 && ip[1]&0xc0 == 64 { // 100.64.0.0/10, CGNAT |
| + return refuse() |
| + } |
| + for _, n := range reservedNets { |
| + if n.Contains(ip) { |
| + return refuse() |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// newClient is the only HTTP client that ever talks to a push |
| +// service: no redirects (a push service never redirects, and following |
| +// one is how a validated URL turns into an internal one), no proxy |
| +// from the environment (same reason), and the IP guard inside the |
| +// dialer's Control so it runs on the address actually connected to. |
| +func newClient() *http.Client { |
| + dialer := &net.Dialer{ |
| + Timeout: 10 * time.Second, |
| + Control: func(network, address string, _ syscall.RawConn) error { |
| + host, _, err := net.SplitHostPort(address) |
| + if err != nil { |
| + return fmt.Errorf("aviso: dial: %w", err) |
| + } |
| + ip := net.ParseIP(host) |
| + if ip == nil { |
| + return fmt.Errorf("aviso: dial: %q is not an IP", host) |
| + } |
| + if err := guardedIP(ip); err != nil { |
| + return fmt.Errorf("aviso: dial refused: %w", err) |
| + } |
| + return nil |
| + }, |
| + } |
| + return &http.Client{ |
| + Timeout: 30 * time.Second, |
| + Transport: &http.Transport{ |
| + Proxy: nil, |
| + DialContext: dialer.DialContext, |
| + TLSHandshakeTimeout: 10 * time.Second, |
| + MaxIdleConns: 64, |
| + IdleConnTimeout: 90 * time.Second, |
| + }, |
| + CheckRedirect: func(*http.Request, []*http.Request) error { |
| + return errors.New("aviso: push service redirected; refused") |
| + }, |
| + } |
| +} |
diff --git a/ssrf_test.go b/ssrf_test.go| new file mode 100644 |
| index 0000000..dff2520 |
| --- /dev/null |
| +++ b/ssrf_test.go |
| @@ -0,0 +1,86 @@ |
| +package aviso |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestValidateEndpoint(t *testing.T) { |
| + ok := "https://fcm.googleapis.com/fcm/send/abc" |
| + if err := validateEndpoint(ok); err != nil { |
| + t.Fatalf("good endpoint refused: %v", err) |
| + } |
| + bad := map[string]string{ |
| + "http": "http://fcm.googleapis.com/x", |
| + "userinfo": "https://user:pw@fcm.googleapis.com/x", |
| + "fragment": "https://fcm.googleapis.com/x#frag", |
| + "empty": "", |
| + "no host": "https:///x", |
| + "too long": "https://fcm.googleapis.com/" + strings.Repeat("a", 2048), |
| + "loopback": "https://127.0.0.1/x", |
| + "ip6 loop": "https://[::1]/x", |
| + "private": "https://10.0.0.5/x", |
| + "linklocal": "https://169.254.169.254/latest", |
| + "mapped": "https://[::ffff:10.0.0.5]/x", |
| + "testnet": "https://192.0.2.1/x", |
| + "bench": "https://198.18.0.1/x", |
| + } |
| + for name, in := range bad { |
| + if err := validateEndpoint(in); !errors.Is(err, ErrBadEndpoint) { |
| + t.Errorf("%s (%q): got %v, want ErrBadEndpoint", name, in, err) |
| + } |
| + } |
| +} |
| + |
| +func TestGuardedIP(t *testing.T) { |
| + refused := []string{ |
| + "127.0.0.1", "10.1.2.3", "172.16.0.1", "192.168.1.1", "169.254.1.1", |
| + "::1", "fe80::1", "fc00::1", "::ffff:192.168.1.1", "0.0.0.0", "100.64.0.1", |
| + // 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", |
| + "::", "224.0.0.1", "ff02::1", |
| + } |
| + for _, ip := range refused { |
| + if err := guardedIP(net.ParseIP(ip)); err == nil { |
| + t.Errorf("%s allowed", ip) |
| + } |
| + } |
| + for _, ip := range []string{"142.250.72.14", "2607:f8b0::1", "1.1.1.1"} { |
| + if err := guardedIP(net.ParseIP(ip)); err != nil { |
| + t.Errorf("%s refused: %v", ip, err) |
| + } |
| + } |
| +} |
| + |
| +// The guard is at connect time, so a hostname that resolves to a |
| +// loopback address — DNS rebinding's shape — fails even though the URL |
| +// looked fine. httptest's server IS loopback, which makes it the |
| +// perfect hostile target. |
| +func TestClientRefusesLoopbackAtDial(t *testing.T) { |
| + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) |
| + defer srv.Close() |
| + c := newClient() |
| + c.Transport.(*http.Transport).TLSClientConfig = srv.Client().Transport.(*http.Transport).TLSClientConfig.Clone() |
| + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, strings.Replace(srv.URL, "127.0.0.1", "localhost", 1), nil) |
| + _, err := c.Do(req) |
| + if err == nil || !strings.Contains(err.Error(), "aviso") { |
| + t.Fatalf("loopback dial allowed or wrong error: %v", err) |
| + } |
| +} |
| + |
| +func TestClientRefusesRedirects(t *testing.T) { |
| + c := newClient() |
| + req, _ := http.NewRequest(http.MethodGet, "https://example.invalid/", nil) |
| + if err := c.CheckRedirect(req, []*http.Request{req}); err == nil { |
| + t.Fatal("redirect followed") |
| + } |
| + if c.Transport.(*http.Transport).Proxy != nil { |
| + t.Fatal("client would honour an environment proxy") |
| + } |
| +} |