// Package webhook is one HTTP POST, which is why barerepo can refuse every integration. 23. package webhook import ( "context" "fmt" "net" "net/url" ) // denied are the ranges a user-controlled URL may never reach. Chapter 23.3, and classic SSRF. var denied = mustParse( "127.0.0.0/8", // loopback "10.0.0.0/8", // private "172.16.0.0/12", // private "192.168.0.0/16", // private "169.254.0.0/16", // link local, and the cloud metadata service "100.64.0.0/10", // carrier grade nat "::1/128", // loopback "fc00::/7", // unique local "fe80::/10", // link local "0.0.0.0/8", // this network "::/128", // unspecified ) func mustParse(cidrs ...string) []*net.IPNet { out := make([]*net.IPNet, 0, len(cidrs)) for _, c := range cidrs { _, n, err := net.ParseCIDR(c) if err != nil { panic(err) } out = append(out, n) } return out } // Allowed checks every resolved address, not the string, since a public name can mean loopback. func Allowed(ctx context.Context, raw string) error { u, err := url.Parse(raw) if err != nil { return fmt.Errorf("that is not a url") } if u.Scheme != "https" { // Plain http would send the signature and the body in clear. return fmt.Errorf("a webhook url must be https") } host := u.Hostname() if host == "" { return fmt.Errorf("that url has no host") } addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) if err != nil { return fmt.Errorf("%s does not resolve", host) } if len(addrs) == 0 { return fmt.Errorf("%s resolves to nothing", host) } for _, a := range addrs { if err := allowedIP(a.IP); err != nil { return err } } return nil } // allowedIP checks one address, and every address must pass, or a mixed answer is the way through. func allowedIP(ip net.IP) error { if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() { return fmt.Errorf("%s is not a public address", ip) } for _, n := range denied { if n.Contains(ip) { return fmt.Errorf("%s is in %s, which webhooks may not reach", ip, n) } } return nil } // AllowedAddr runs at connect time too, closing the gap where a name changes what it resolves to. func AllowedAddr(addr string) error { host, _, err := net.SplitHostPort(addr) if err != nil { host = addr } ip := net.ParseIP(host) if ip == nil { return fmt.Errorf("%s is not an address", host) } return allowedIP(ip) } // DeniedRanges is what the deny list holds, for the config page to show. func DeniedRanges() []string { out := make([]string, 0, len(denied)) for _, n := range denied { out = append(out, n.String()) } return out }