rastrillo / aviso Public

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>
Paul Campbell pushed by paul@keymail.dev e6070f01e2a8e52a260e1d6a5a6d771a3fa3a0de parent de0ae52
3 files changed, +212 −0
  • aviso.go +1 −0
  • ssrf.go +125 −0
  • ssrf_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")
+ }
+}