internal/webhook/webhook.go
201 lines · 6173 bytes
10 symbols in this file
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}