184 lines
5.8 KiB
Go
184 lines
5.8 KiB
Go
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
|
|
}
|