pr_01m47d15m3e54sn21z27rpy5n9/services/webhooks/src/deliver.rs

154 lines6,214 bytesCodeBlame
1//! What a delivery is made of, apart from sending it: the payload, the
2//! signature, when to try again, and which addresses may be sent to.
3
4use g1t_contracts::events::Event;
5use g1t_contracts::webhooks::EVENT_TYPES;
6use serde_json::{Value, json};
7
8/// How long to wait after each failed attempt before the next, so a
9/// delivery gets six attempts over about seven and a half hours.
10pub const RETRY_WAITS_SECONDS: [u64; 5] = [60, 5 * 60, 30 * 60, 2 * 60 * 60, 5 * 60 * 60];
11
12/// When to try again after `attempts` attempts, in seconds from now, or
13/// `None` when it has had them all.
14pub fn retry_after(attempts: u32) -> Option<u64> {
15 RETRY_WAITS_SECONDS.get(attempts.checked_sub(1)? as usize).copied()
16}
17
18/// Whether a webhook that wants `wanted` is sent an event of type `kind`.
19pub fn wants(wanted: &[String], kind: &str) -> bool {
20 wanted.iter().any(|event| event == "*" || event == kind)
21}
22
23/// Event types as given, checked and in catalogue order. `["*"]` when none
24/// or all are given. `Err` names the first that is not one.
25pub fn tidy_events(given: &[String]) -> Result<Vec<String>, String> {
26 if given.is_empty() || given.iter().any(|event| event == "*") {
27 return Ok(vec!["*".to_owned()]);
28 }
29 if let Some(unknown) = given.iter().find(|event| !EVENT_TYPES.contains(&event.as_str())) {
30 return Err(format!("There is no event called {unknown}."));
31 }
32 Ok(EVENT_TYPES
33 .iter()
34 .filter(|kind| given.iter().any(|event| event == *kind))
35 .map(|kind| (*kind).to_owned())
36 .collect())
37}
38
39/// What is sent for an event: the event as the bus has it, with the
40/// repository, the workspace and whoever caused it named.
41pub fn payload(event: &Event, workspace: &str, repo: Option<(&str, &str)>, actor_name: Option<&str>) -> Value {
42 json!({
43 "id": event.id,
44 "type": event.kind,
45 "time": event.time,
46 "workspace": workspace,
47 "repository": repo.map(|(id, full_name)| json!({ "id": id, "fullName": full_name })),
48 "actor": event.actor.as_ref().map(|id| json!({ "id": id, "username": actor_name })),
49 "data": event.data,
50 })
51}
52
53/// What is sent to check a webhook works.
54pub fn ping(hook_id: &str, url: &str, events: &[String], time: &str) -> Value {
55 json!({
56 "type": "ping",
57 "time": time,
58 "hook": { "id": hook_id, "url": url, "events": events },
59 "message": "g1t will send this webhook's events here.",
60 })
61}
62
63/// The signature header's value: the body's HMAC-SHA256 under the secret.
64pub fn signature(secret: &str, body: &str) -> String {
65 format!("sha256={}", g1t_secrets::hmac_sha256_hex(secret, body))
66}
67
68/// Whether g1t may send to `url`: HTTPS, to a public host. `Err` says why
69/// not.
70pub fn check_url(url: &str) -> Result<(), String> {
71 let Some(rest) = url.strip_prefix("https://") else {
72 return Err("Webhooks are sent over HTTPS: the address must start with https://.".to_owned());
73 };
74 if url.len() > 2000 {
75 return Err("That address is too long.".to_owned());
76 }
77 let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
78 let host = authority.rsplit('@').next().unwrap_or_default();
79 let host = host.strip_prefix('[').map_or_else(
80 || host.split(':').next().unwrap_or_default(),
81 |v6| v6.split(']').next().unwrap_or_default(),
82 );
83 let host = host.to_ascii_lowercase();
84 if host.is_empty() || !host.contains(['.', ':']) {
85 return Err("The address needs a host g1t can reach on the internet.".to_owned());
86 }
87 let private_name = host == "localhost"
88 || [".localhost", ".local", ".internal", ".lan", ".home.arpa"]
89 .iter()
90 .any(|suffix| host.ends_with(suffix));
91 let private_ip = host.parse::<std::net::IpAddr>().is_ok_and(|ip| match ip {
92 std::net::IpAddr::V4(v4) => {
93 v4.is_private() || v4.is_loopback() || v4.is_link_local() || v4.is_unspecified() || v4.is_broadcast() || v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1])
94 }
95 std::net::IpAddr::V6(v6) => v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xfe00) == 0xfc00 || (v6.segments()[0] & 0xffc0) == 0xfe80,
96 });
97 if private_name || private_ip {
98 return Err("Webhooks go to public addresses, not private or local ones.".to_owned());
99 }
100 Ok(())
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 #[test]
108 fn retries_wait_longer_each_time_then_stop() {
109 assert_eq!(retry_after(1), Some(60));
110 assert_eq!(retry_after(2), Some(300));
111 assert_eq!(retry_after(5), Some(18_000));
112 assert_eq!(retry_after(6), None);
113 assert_eq!(retry_after(0), None);
114 }
115
116 #[test]
117 fn events_are_checked_and_ordered() {
118 let given = vec!["pull.merged".to_owned(), "git.push".to_owned()];
119 assert_eq!(tidy_events(&given).unwrap(), vec!["git.push", "pull.merged"]);
120 assert_eq!(tidy_events(&[]).unwrap(), vec!["*"]);
121 assert!(tidy_events(&["pull.exploded".to_owned()]).is_err());
122 assert!(wants(&["*".to_owned()], "issue.opened"));
123 assert!(!wants(&["git.push".to_owned()], "issue.opened"));
124 }
125
126 #[test]
127 fn only_public_https_addresses_are_allowed() {
128 assert!(check_url("https://hooks.example.com/g1t").is_ok());
129 assert!(check_url("https://example.com:8443/hook?x=1").is_ok());
130 for url in [
131 "http://example.com/hook",
132 "https://localhost/hook",
133 "https://127.0.0.1/hook",
134 "https://10.0.0.5/hook",
135 "https://192.168.1.2:8080/hook",
136 "https://169.254.169.254/latest",
137 "https://100.64.0.1/hook",
138 "https://[::1]/hook",
139 "https://[fd00::1]/hook",
140 "https://printer.local/hook",
141 "https://intranet/hook",
142 "https://user@10.0.0.1/hook",
143 ] {
144 assert!(check_url(url).is_err(), "{url}");
145 }
146 }
147
148 #[test]
149 fn the_signature_is_the_bodys_hmac() {
150 let signature = signature("shh", "{\"a\":1}");
151 assert!(signature.starts_with("sha256="));
152 assert!(g1t_secrets::signed("shh", "{\"a\":1}", &signature));
153 }
154}