gitlyx / webhook.v
324 lines · 300 sloc · 7.93 KB · 13ca859655a005b2a87a4935764694302b4f2fda
Raw
1// Copyright (c) 2019-2026 Alexander Medvednikov. All rights reserved.
2// Use of this source code is governed by a GPL license that can be found in the LICENSE file.
3module main
4
5import time
6import net
7import net.http
8import net.urllib
9import crypto.hmac
10import crypto.sha256
11import encoding.hex
12import x.json2 as json
13
14pub struct WebhookIssuePayload {
15 action string
16 repo string
17 title string
18 author string
19}
20
21pub struct WebhookPrPayload {
22 action string
23 repo string
24 number int
25 title string
26 author string
27 head string
28 base string
29}
30
31pub struct WebhookCommentPayload {
32 action string
33 repo string
34 target string // 'issue' or 'pr'
35 number int
36 author string
37 text string
38}
39
40pub struct WebhookReleasePayload {
41 action string
42 repo string
43 tag string
44 author string
45}
46
47pub struct WebhookPushPayload {
48 repo string
49 ref string
50 author string
51}
52
53struct Webhook {
54 id int @[primary; sql: serial]
55mut:
56 repo_id int
57 url string
58 secret string
59 events string // comma-separated: push,issue,pr,comment,release
60 is_active bool
61 created_at int
62 last_status int
63 last_delivery int
64}
65
66struct WebhookDelivery {
67 id int @[primary; sql: serial]
68mut:
69 webhook_id int
70 event string
71 status_code int
72 response_body string
73 created_at int
74}
75
76fn (w &Webhook) has_event(name string) bool {
77 if w.events == '' {
78 return true
79 }
80 for ev in w.events.split(',') {
81 if ev.trim_space() == name {
82 return true
83 }
84 }
85 return false
86}
87
88fn (w &Webhook) event_list() []string {
89 mut out := []string{}
90 for ev in w.events.split(',') {
91 t := ev.trim_space()
92 if t != '' {
93 out << t
94 }
95 }
96 return out
97}
98
99fn (mut app App) add_webhook(repo_id int, url string, secret string, events string) ! {
100 wh := Webhook{
101 repo_id: repo_id
102 url: url
103 secret: secret
104 events: events
105 is_active: true
106 created_at: int(time.now().unix())
107 }
108 sql app.db {
109 insert wh into Webhook
110 }!
111}
112
113fn (mut app App) list_repo_webhooks(repo_id int) []Webhook {
114 return sql app.db {
115 select from Webhook where repo_id == repo_id order by id desc
116 } or { []Webhook{} }
117}
118
119fn (mut app App) find_webhook_by_id(id int) ?Webhook {
120 rows := sql app.db {
121 select from Webhook where id == id limit 1
122 } or { []Webhook{} }
123 if rows.len == 0 {
124 return none
125 }
126 return rows.first()
127}
128
129fn (mut app App) delete_webhook(id int) ! {
130 sql app.db {
131 delete from Webhook where id == id
132 }!
133 sql app.db {
134 delete from WebhookDelivery where webhook_id == id
135 }!
136}
137
138fn (mut app App) delete_repo_webhooks(repo_id int) ! {
139 whs := app.list_repo_webhooks(repo_id)
140 for wh in whs {
141 app.delete_webhook(wh.id) or {}
142 }
143}
144
145fn (mut app App) toggle_webhook(id int, active bool) ! {
146 sql app.db {
147 update Webhook set is_active = active where id == id
148 }!
149}
150
151fn (mut app App) record_webhook_delivery(webhook_id int, event string, status int, body string) {
152 d := WebhookDelivery{
153 webhook_id: webhook_id
154 event: event
155 status_code: status
156 response_body: body
157 created_at: int(time.now().unix())
158 }
159 sql app.db {
160 insert d into WebhookDelivery
161 } or { return }
162 sql app.db {
163 update Webhook set last_status = status, last_delivery = d.created_at where id == webhook_id
164 } or { return }
165}
166
167fn (mut app App) recent_webhook_deliveries(webhook_id int, limit int) []WebhookDelivery {
168 return sql app.db {
169 select from WebhookDelivery where webhook_id == webhook_id order by id desc limit limit
170 } or { []WebhookDelivery{} }
171}
172
173// dispatch_webhook fires a webhook delivery in a background spawn.
174// payload is any serializable value; it's JSON-encoded with `json.encode`.
175fn (mut app App) dispatch_webhook[T](repo_id int, event string, payload T) {
176 body := json.encode(payload)
177 app.fan_out_webhook(repo_id, event, body)
178}
179
180fn (mut app App) fan_out_webhook(repo_id int, event string, body string) {
181 hooks := app.list_repo_webhooks(repo_id)
182 for wh in hooks {
183 if !wh.is_active {
184 continue
185 }
186 if !wh.has_event(event) {
187 continue
188 }
189 spawn app.deliver_webhook(wh, event, body)
190 }
191}
192
193// is_blocked_ipv4 reports whether the dotted-quad IPv4 string falls in a range
194// a webhook must never reach: unspecified (0/8), loopback (127/8), private
195// (10/8, 172.16/12, 192.168/16), CGNAT (100.64/10), link-local (169.254/16,
196// which includes the cloud metadata address 169.254.169.254), and
197// multicast/reserved (>=224). Unparseable input is treated as blocked.
198fn is_blocked_ipv4(ip string) bool {
199 parts := ip.split('.')
200 if parts.len != 4 {
201 return true
202 }
203 mut o := [4]int{}
204 for i in 0 .. 4 {
205 if parts[i] == '' {
206 return true
207 }
208 n := parts[i].int()
209 if n < 0 || n > 255 {
210 return true
211 }
212 o[i] = n
213 }
214 return o[0] == 0 || o[0] == 10 || o[0] == 127 || (o[0] == 169 && o[1] == 254)
215 || (o[0] == 172 && o[1] >= 16 && o[1] <= 31) || (o[0] == 192 && o[1] == 168)
216 || (o[0] == 100 && o[1] >= 64 && o[1] <= 127) || o[0] >= 224
217}
218
219// is_blocked_ipv6 reports whether the IPv6 text (no brackets) is loopback (::1),
220// unspecified (::), unique-local (fc00::/7), link-local (fe80::/10), or an
221// IPv4-mapped address (e.g. ::ffff:127.0.0.1) pointing at a blocked IPv4.
222fn is_blocked_ipv6(ip_in string) bool {
223 ip := ip_in.to_lower()
224 if ip == '::1' || ip == '::' {
225 return true
226 }
227 // IPv4-mapped/-compatible forms end in a dotted quad.
228 if ip.contains('.') {
229 tail := ip.all_after_last(':')
230 if tail.contains('.') {
231 return is_blocked_ipv4(tail)
232 }
233 }
234 if ip.starts_with('fc') || ip.starts_with('fd') {
235 return true
236 }
237 if ip.starts_with('fe8') || ip.starts_with('fe9') || ip.starts_with('fea')
238 || ip.starts_with('feb') {
239 return true
240 }
241 return false
242}
243
244// is_safe_webhook_url validates a webhook destination before any server-side
245// request is made: the scheme must be http(s), the host must resolve, and none
246// of the resolved addresses may be loopback/private/link-local. Resolving here
247// blocks hostnames that point at internal IPs; it cannot fully defeat DNS
248// rebinding between this check and delivery, but it closes the common SSRF
249// vectors (literal internal IPs, localhost, cloud metadata endpoints).
250fn is_safe_webhook_url(raw string) bool {
251 u := urllib.parse(raw) or { return false }
252 scheme := u.scheme.to_lower()
253 if scheme != 'http' && scheme != 'https' {
254 return false
255 }
256 host := u.hostname()
257 if host == '' {
258 return false
259 }
260 lhost := host.to_lower()
261 if lhost == 'localhost' || lhost.ends_with('.localhost') {
262 return false
263 }
264 port := if u.port() != '' {
265 u.port()
266 } else if scheme == 'https' {
267 '443'
268 } else {
269 '80'
270 }
271 addrs := net.resolve_addrs('${host}:${port}', .unspec, .tcp) or { return false }
272 if addrs.len == 0 {
273 return false
274 }
275 for a in addrs {
276 s := a.str()
277 match a.family() {
278 .ip {
279 if is_blocked_ipv4(s.all_before_last(':')) {
280 return false
281 }
282 }
283 .ip6 {
284 if is_blocked_ipv6(s.find_between('[', ']')) {
285 return false
286 }
287 }
288 else {
289 return false
290 }
291 }
292 }
293 return true
294}
295
296fn (mut app App) deliver_webhook(wh Webhook, event string, body string) {
297 // Re-validate at delivery time: this is the authoritative SSRF gate. It
298 // also protects webhooks created before this check existed and catches
299 // hosts whose DNS now points at an internal address.
300 if !is_safe_webhook_url(wh.url) {
301 app.record_webhook_delivery(wh.id, event, 0,
302 'blocked: destination resolves to a disallowed (internal/loopback) address')
303 return
304 }
305 mut signature := ''
306 if wh.secret != '' {
307 sig_bytes := hmac.new(wh.secret.bytes(), body.bytes(), sha256.sum, sha256.block_size)
308 signature = 'sha256=' + hex.encode(sig_bytes)
309 }
310 mut req := http.new_request(.post, wh.url, body)
311 req.header.add(.content_type, 'application/json')
312 req.header.add_custom('X-Gitly-Event', event) or {}
313 if signature != '' {
314 req.header.add_custom('X-Gitly-Signature', signature) or {}
315 }
316 req.read_timeout = 10 * time.second
317 req.write_timeout = 10 * time.second
318 resp := req.do() or {
319 app.record_webhook_delivery(wh.id, event, 0, err.str())
320 return
321 }
322 preview := if resp.body.len > 500 { resp.body[..500] } else { resp.body }
323 app.record_webhook_delivery(wh.id, event, resp.status_code, preview)
324}
325