| 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. |
| 3 | module main |
| 4 | |
| 5 | import time |
| 6 | import net |
| 7 | import net.http |
| 8 | import net.urllib |
| 9 | import crypto.hmac |
| 10 | import crypto.sha256 |
| 11 | import encoding.hex |
| 12 | import x.json2 as json |
| 13 | |
| 14 | pub struct WebhookIssuePayload { |
| 15 | action string |
| 16 | repo string |
| 17 | title string |
| 18 | author string |
| 19 | } |
| 20 | |
| 21 | pub 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 | |
| 31 | pub 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 | |
| 40 | pub struct WebhookReleasePayload { |
| 41 | action string |
| 42 | repo string |
| 43 | tag string |
| 44 | author string |
| 45 | } |
| 46 | |
| 47 | pub struct WebhookPushPayload { |
| 48 | repo string |
| 49 | ref string |
| 50 | author string |
| 51 | } |
| 52 | |
| 53 | struct Webhook { |
| 54 | id int @[primary; sql: serial] |
| 55 | mut: |
| 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 | |
| 66 | struct WebhookDelivery { |
| 67 | id int @[primary; sql: serial] |
| 68 | mut: |
| 69 | webhook_id int |
| 70 | event string |
| 71 | status_code int |
| 72 | response_body string |
| 73 | created_at int |
| 74 | } |
| 75 | |
| 76 | fn (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 | |
| 88 | fn (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 | |
| 99 | fn (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 | |
| 113 | fn (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 | |
| 119 | fn (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 | |
| 129 | fn (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 | |
| 138 | fn (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 | |
| 145 | fn (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 | |
| 151 | fn (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 | |
| 167 | fn (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`. |
| 175 | fn (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 | |
| 180 | fn (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. |
| 198 | fn 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. |
| 222 | fn 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). |
| 250 | fn 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 | |
| 296 | fn (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 | |