pr_01m47d15m3e54sn21z27rpy5n9/services/webhooks/src/deliver.rs
| 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 | |
| 4 | use g1t_contracts::events::Event; |
| 5 | use g1t_contracts::webhooks::EVENT_TYPES; |
| 6 | use 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. |
| 10 | pub 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. |
| 14 | pub 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`. |
| 19 | pub 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. |
| 25 | pub 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. |
| 41 | pub 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. |
| 54 | pub 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. |
| 64 | pub 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. |
| 70 | pub 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)] |
| 104 | mod 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 | } |