internal/webhook/webhook.go

bd5cf5d7d1f34fa780660fd7562b9ffd9746ee27
gitbay/internal/webhook/webhook.go history · blame · raw

201 lines · 6173 bytes

  1// Package webhook delivers events to registered endpoints: HMAC-signed
  2// JSON POSTs with bounded retries, exponential backoff, and dead-lettering.
  3package webhook
  4
  5import (
  6	"bytes"
  7	"context"
  8	"crypto/hmac"
  9	"crypto/sha256"
 10	"encoding/hex"
 11	"encoding/json"
 12	"fmt"
 13	"io"
 14	"log/slog"
 15	"net"
 16	"net/http"
 17	"net/url"
 18	"time"
 19
 20	"gitbay.org/gitbay/internal/store"
 21)
 22
 23// ValidateURL rejects URLs the server must not connect to (SSRF): non-HTTP
 24// schemes and, unless allowLocal, anything resolving to a loopback,
 25// private, shared (100.64.0.0/10), link-local, multicast or unspecified
 26// address. Webhooks, mirrors and issue import use it.
 27func ValidateURL(raw string, allowLocal bool) error {
 28	u, err := url.Parse(raw)
 29	if err != nil {
 30		return fmt.Errorf("invalid URL: %w", err)
 31	}
 32	if u.Scheme != "http" && u.Scheme != "https" {
 33		return fmt.Errorf("URLs must be http or https")
 34	}
 35	if u.Hostname() == "" {
 36		return fmt.Errorf("URL has no host")
 37	}
 38	if allowLocal {
 39		return nil
 40	}
 41	ips, err := net.LookupIP(u.Hostname())
 42	if err != nil {
 43		return fmt.Errorf("cannot resolve %s: %w", u.Hostname(), err)
 44	}
 45	if err := CheckAddrs(u.Hostname(), ips, allowLocal); err != nil {
 46		return fmt.Errorf("target %s resolves to a private or local address; refusing (SSRF)", u.Hostname())
 47	}
 48	return nil
 49}
 50
 51// CheckAddrs refuses host when any of its resolved addresses is
 52// loopback, private, shared (100.64.0.0/10), link-local, multicast or
 53// unspecified, unless allowLocal. A caller resolves immediately before
 54// connecting and connects only to the addresses it checked.
 55func CheckAddrs(host string, ips []net.IP, allowLocal bool) error {
 56	if allowLocal {
 57		return nil
 58	}
 59	for _, ip := range ips {
 60		if isForbidden(ip) {
 61			return fmt.Errorf("%s resolves to private or local address %s; refusing (SSRF)", host, ip)
 62		}
 63	}
 64	return nil
 65}
 66
 67// cgnat is the shared address space of RFC 6598, which carriers and
 68// overlay networks such as Tailscale use as private space.
 69var cgnat = &net.IPNet{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)}
 70
 71func isForbidden(ip net.IP) bool {
 72	return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
 73		ip.IsMulticast() || ip.IsUnspecified() || cgnat.Contains(ip)
 74}
 75
 76type Deliverer struct {
 77	St          *store.Store
 78	AllowLocal  bool
 79	RetryBase   time.Duration // first retry delay; doubles per attempt
 80	MaxAttempts int
 81	client      *http.Client
 82}
 83
 84// New builds a deliverer whose dialer re-checks resolved addresses at
 85// connect time, so a DNS answer that changes after ValidateURL still cannot
 86// reach private space.
 87func New(st *store.Store, allowLocal bool, retryBase time.Duration) *Deliverer {
 88	d := &Deliverer{St: st, AllowLocal: allowLocal, RetryBase: retryBase, MaxAttempts: 5}
 89	dialer := &net.Dialer{Timeout: 5 * time.Second}
 90	d.client = &http.Client{
 91		Timeout: 10 * time.Second,
 92		CheckRedirect: func(*http.Request, []*http.Request) error {
 93			return http.ErrUseLastResponse // never follow redirects
 94		},
 95		Transport: &http.Transport{
 96			DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
 97				host, port, err := net.SplitHostPort(addr)
 98				if err != nil {
 99					return nil, err
100				}
101				ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
102				if err != nil {
103					return nil, err
104				}
105				for _, ip := range ips {
106					if !allowLocal && isForbidden(ip) {
107						return nil, fmt.Errorf("refusing connection to private address %s", ip)
108					}
109				}
110				return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].String(), port))
111			},
112		},
113	}
114	return d
115}
116
117// Run polls for due deliveries until ctx is done.
118func (d *Deliverer) Run(ctx context.Context) {
119	tick := time.NewTicker(2 * time.Second)
120	defer tick.Stop()
121	for {
122		select {
123		case <-ctx.Done():
124			return
125		case <-tick.C:
126			due, err := d.St.DueDeliveries(20)
127			if err != nil {
128				slog.Error("webhook: listing due deliveries", "err", err)
129				continue
130			}
131			for _, dl := range due {
132				d.deliver(ctx, dl)
133			}
134		}
135	}
136}
137
138type payload struct {
139	Event     string          `json:"event"`
140	Repo      string          `json:"repo"`
141	Actor     string          `json:"actor,omitempty"`
142	CreatedAt string          `json:"created_at"`
143	Data      json.RawMessage `json:"data"`
144}
145
146func (d *Deliverer) deliver(ctx context.Context, dl store.Delivery) {
147	body, err := json.Marshal(payload{
148		Event: dl.EventKind, Repo: dl.RepoPath, Actor: dl.Actor,
149		CreatedAt: dl.EventAt, Data: json.RawMessage(dl.DataJSON),
150	})
151	if err != nil {
152		d.fail(dl, 0, "marshal: "+err.Error())
153		return
154	}
155	req, err := http.NewRequestWithContext(ctx, "POST", dl.URL, bytes.NewReader(body))
156	if err != nil {
157		d.fail(dl, 0, "request: "+err.Error())
158		return
159	}
160	req.Header.Set("Content-Type", "application/json")
161	req.Header.Set("User-Agent", "gitbay-webhook")
162	req.Header.Set("X-Gitbay-Event", dl.EventKind)
163	req.Header.Set("X-Gitbay-Delivery", fmt.Sprint(dl.ID))
164	if dl.Secret != "" {
165		mac := hmac.New(sha256.New, []byte(dl.Secret))
166		mac.Write(body)
167		req.Header.Set("X-Gitbay-Signature-256", "sha256="+hex.EncodeToString(mac.Sum(nil)))
168	}
169
170	resp, err := d.client.Do(req)
171	if err != nil {
172		d.fail(dl, 0, err.Error())
173		return
174	}
175	io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
176	resp.Body.Close()
177	if resp.StatusCode >= 200 && resp.StatusCode < 300 {
178		if err := d.St.MarkDelivered(dl.ID, resp.StatusCode); err != nil {
179			slog.Error("webhook: marking delivered", "err", err)
180		}
181		return
182	}
183	d.fail(dl, resp.StatusCode, fmt.Sprintf("endpoint returned %d", resp.StatusCode))
184}
185
186// fail schedules a retry with exponential backoff, dead-lettering after
187// MaxAttempts.
188func (d *Deliverer) fail(dl store.Delivery, status int, msg string) {
189	attempt := dl.Attempts + 1 // the one that just happened
190	if attempt >= d.MaxAttempts {
191		if err := d.St.MarkAttemptFailed(dl.ID, status, msg, nil); err != nil {
192			slog.Error("webhook: dead-lettering", "err", err)
193		}
194		slog.Warn("webhook dead-lettered", "delivery", dl.ID, "url", dl.URL, "err", msg)
195		return
196	}
197	next := time.Now().Add(d.RetryBase << (attempt - 1))
198	if err := d.St.MarkAttemptFailed(dl.ID, status, msg, &next); err != nil {
199		slog.Error("webhook: scheduling retry", "err", err)
200	}
201}