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, // 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", "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 { 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") }, } }