krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
repo-descriptions: 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}