package notify import ( "bytes" "context" "encoding/json" "errors" "fmt" "net" "net/http" "net/url" "syscall" "time" ) // Webhook delivers notifications as a JSON POST of the Event to a // project-configured URL. Config is {"url": "..."}. secret is currently // unused (reserved for future request signing) and is never logged. // // The destination URL is project-controlled (any project owner can set it), // so it is treated as untrusted input. Two layers guard against SSRF: // // 1. isAllowedURL is a pre-request fast-fail check on the URL's scheme and // (resolved) hostname. // 2. HTTP's Transport, when built via newWebhookTransport, wires a // net.Dialer.Control that re-checks the actual "ip:port" being dialed for // every connection net/http opens — including the DNS resolution // http.Client.Do performs internally, independent of (1). // // Layer (2) is the source of truth: DNS answers are attacker-influenceable // (an attacker with authoritative DNS and a low TTL can answer a public IP to // a pre-request lookup and a private/loopback IP to the actual connection — // DNS rebinding). Relying on (1) alone leaves that TOCTOU window open; (2) // closes it because it inspects the address the connection is actually made // to, not a name. Redirects are not followed, since a redirect response // could otherwise be used to bypass the destination checks. type Webhook struct { HTTP *http.Client // allowPrivate disables the isAllowedURL pre-check. It exists only so // tests can exercise Send happy-paths against httptest servers, which // listen on loopback. Production Dispatchers (NewDispatcher) must never // set this; they also wire a Transport whose Control func enforces the // same guard at dial time regardless of this flag. allowPrivate bool } // isAllowedURL rejects any URL that is not a plain http/https request to a // public, resolvable address. It resolves hostnames and checks every // returned address — a hostname that resolves to even one // private/loopback/link-local/unspecified address is rejected, since DNS // answers are attacker-influenceable (rebinding) and partial trust is not // safe. func isAllowedURL(rawurl string) error { u, err := url.Parse(rawurl) if err != nil { return fmt.Errorf("webhook: invalid url: %w", err) } if u.Scheme != "http" && u.Scheme != "https" { return errors.New("webhook: destination not allowed") } host := u.Hostname() if host == "" { return errors.New("webhook: destination not allowed") } var ips []net.IP if ip := net.ParseIP(host); ip != nil { ips = []net.IP{ip} } else { resolved, err := net.LookupIP(host) if err != nil { return errors.New("webhook: destination not allowed") } ips = resolved } for _, ip := range ips { if isBlockedIP(ip) { return errors.New("webhook: destination not allowed") } } return nil } // isBlockedIP reports whether ip must never be connected to: loopback, // private (RFC1918 etc.), link-local, unspecified, or multicast. Used both // by isAllowedURL's pre-request check and by dialControl's per-connection // check. func isBlockedIP(ip net.IP) bool { return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() } // dialControl returns a net.Dialer.Control function enforcing the SSRF guard // on the literal address ("ip:port") that net/http is about to connect to. // It runs after any DNS resolution net/http performs internally — including // resolution done independently of, and possibly later than, isAllowedURL's // own lookup — so it sees the real connecting IP and closes the DNS-rebinding // TOCTOU window described on Webhook. // // allowPrivate disables the check entirely; it exists so tests can dial // httptest servers, which listen on loopback. func dialControl(allowPrivate bool) func(network, address string, c syscall.RawConn) error { return func(network, address string, c syscall.RawConn) error { if allowPrivate { return nil } host, _, err := net.SplitHostPort(address) if err != nil { return errors.New("webhook: destination not allowed") } ip := net.ParseIP(host) if ip == nil { return errors.New("webhook: destination not allowed") } if isBlockedIP(ip) { return errors.New("webhook: destination not allowed") } return nil } } // newWebhookTransport builds an http.Transport whose dialer enforces the // SSRF guard on the actual address being connected to, for every connection // it opens (see dialControl). This is the guard of record; isAllowedURL is // only a fast pre-request rejection layered in front of it. func newWebhookTransport(allowPrivate bool) *http.Transport { dialer := &net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, Control: dialControl(allowPrivate), } t := http.DefaultTransport.(*http.Transport).Clone() t.DialContext = dialer.DialContext return t } func (w *Webhook) Send(ctx context.Context, cfg json.RawMessage, secret string, ev Event) error { var c struct { URL string `json:"url"` } if err := json.Unmarshal(cfg, &c); err != nil { return err } if !w.allowPrivate { if err := isAllowedURL(c.URL); err != nil { return err } } body, err := json.Marshal(ev) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") client := w.HTTP if client.CheckRedirect == nil { clientCopy := *client clientCopy.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } client = &clientCopy } resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("webhook: status %d", resp.StatusCode) } return nil }