krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

main: internal/webhook/webhook.go · raw

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