steid

@jamesgill /

Rust12.2 KBCode·Blame·History·Raw
1//! Delivering a webhook over HTTP.
2//!
3//! `ureq` rather than `reqwest`: it is already in the dependency tree — Topcoat pulls it
4//! in through its `tailwind` and `icon-iconify` features — and it already brings
5//! `rustls` with it, so this adds a feature flag rather than a TLS stack. It is a
6//! *blocking* client, which is why every call runs inside
7//! [`spawn_blocking`](tokio::task::spawn_blocking): a blocking socket read on a runtime
8//! worker stalls every other request the process is serving.
9//!
10//! **The second half of the SSRF defence lives here.**
11//! [`WebhookUrl`](crate::domain::WebhookUrl) refuses an address at the moment a person
12//! saves one, which is when they can be told why. That cannot cover a public name that
13//! resolves to a private address, so the host is resolved here and every address it
14//! answers with is checked before the request is made.
15//!
16//! The third piece is that **no redirect is followed** — see [`agent`]. Resolving the
17//! saved URL says nothing about where a `302` would send the next request, so a public
18//! receiver could otherwise walk a delivery into this machine's own network and hand the
19//! response body back through the deliveries page.
20//!
21//! What it does *not* close is the gap between that resolution and `ureq`'s own — a name
22//! whose answer changes in between (DNS rebinding) is still reachable. Closing it needs a
23//! custom resolver or connecting to a pinned address with the `Host` header set by hand,
24//! and neither is worth the machinery until a hook is something a *second* user can add.
25
26use std::{
27 io::Read,
28 net::ToSocketAddrs,
29 sync::{Arc, Mutex},
30 time::{Duration, Instant},
31};
32
33use crate::{
34 application::port::{WebhookOutcome, WebhookRequest, WebhookSender},
35 domain::{PrivateTargets, is_public},
36 infrastructure::metrics::{DeliveryOutcome, metrics},
37};
38
39/// How long one attempt may take, connection through body.
40///
41/// Whole-request rather than per-phase: a receiver that dribbles a response one byte at
42/// a time is the same problem as one that never answers, and the caller is a retry loop
43/// that has somewhere better to be.
44const TIMEOUT: Duration = Duration::from_secs(10);
45
46/// How much of a response body is read.
47///
48/// Read short rather than read-then-truncate: the domain caps what is *stored*, and
49/// without a cap here a hostile endpoint could stream forever inside the timeout.
50const MAX_RESPONSE_BYTES: u64 = 8 * 1024;
51
52/// Webhook delivery over real HTTP.
53#[derive(Debug, Clone, Default)]
54pub struct UreqWebhookSender {
55 /// Whether an address on this machine's own network is dialled.
56 ///
57 /// Carried rather than read from configuration here, because an adapter that reads
58 /// the environment is one that cannot be tested with the setting off. `Refused` is
59 /// the `Default`, so a sender constructed without thinking about it is the safe one.
60 targets: PrivateTargets,
61}
62
63impl UreqWebhookSender {
64 pub fn new(targets: PrivateTargets) -> Self {
65 Self { targets }
66 }
67}
68
69impl WebhookSender for UreqWebhookSender {
70 async fn send(&self, request: WebhookRequest) -> WebhookOutcome {
71 let started = Instant::now();
72
73 // A panic inside the blocking pool must not take the delivery with it silently;
74 // a join error is reported as an outcome like any other failure.
75 let targets = self.targets;
76
77 let outcome =
78 match tokio::task::spawn_blocking(move || send_blocking(request, targets)).await {
79 Ok(outcome) => outcome,
80 Err(error) => WebhookOutcome {
81 status: None,
82 body: format!("delivery task failed: {error}"),
83 duration: started.elapsed(),
84 },
85 };
86
87 // Counted in the adapter rather than in `application::webhook`, which is where the
88 // retry loop lives: the application layer knows nothing of infrastructure, and this
89 // is the one place every attempt — first try and retry alike — actually goes out.
90 metrics().record_delivery(DeliveryOutcome::of(outcome.status));
91
92 outcome
93 }
94}
95
96/// The blocking half, run off the runtime.
97fn send_blocking(request: WebhookRequest, targets: PrivateTargets) -> WebhookOutcome {
98 let started = Instant::now();
99
100 let outcome = |status, body: String| WebhookOutcome {
101 status,
102 body,
103 duration: started.elapsed(),
104 };
105
106 // Skipped only on an instance that has deliberately opted in, which is the half of
107 // the escape hatch the domain cannot provide: a URL that passed
108 // `WebhookUrl::new_allowing_private` would otherwise be refused here instead, one
109 // layer later and with no page to say so.
110 if targets == PrivateTargets::Refused
111 && let Err(reason) = check_resolves_publicly(&request.url)
112 {
113 return outcome(None, reason);
114 }
115
116 let mut send = agent().post(&request.url);
117
118 for (name, value) in &request.headers {
119 send = send.header(name.as_str(), value.as_str());
120 }
121
122 match send.send(&request.body[..]) {
123 Ok(mut response) => {
124 let status = response.status().as_u16();
125 let mut body = String::new();
126
127 // Ignoring the read error on purpose: the status is the answer, and a
128 // response whose body could not be read is still a delivery that happened.
129 let _ = response
130 .body_mut()
131 .as_reader()
132 .take(MAX_RESPONSE_BYTES)
133 .read_to_string(&mut body);
134
135 outcome(Some(status), body)
136 }
137 // No status at all: DNS, a refused connection, TLS, or the timeout. git's own
138 // words are carried for the same reason `run_git` carries them — "delivery
139 // failed" sends the next person to read this code instead of the error.
140 Err(error) => outcome(None, error.to_string()),
141 }
142}
143
144/// The client every delivery is made with.
145///
146/// A function rather than an inline builder so the redirect rule below is one a test can
147/// read back.
148fn agent() -> ureq::Agent {
149 ureq::Agent::config_builder()
150 .timeout_global(Some(TIMEOUT))
151 // A 4xx or 5xx is a *result* here, not an error: it is exactly what the delivery
152 // row exists to record, and the default would collapse it into the same variant
153 // as a refused connection.
154 .http_status_as_error(false)
155 // **No redirects, and this is the third half of the SSRF defence.** `ureq`
156 // follows ten by default, and `check_resolves_publicly` above only ever sees the
157 // URL that was saved — so a public host answering `302 Location:
158 // http://169.254.169.254/…` walked the delivery straight past both checks, and
159 // the response body came back onto the deliveries page for whoever asked. A
160 // webhook receiver has no business redirecting: with this at zero the 3xx is
161 // recorded as the delivery's own answer, which is the honest thing to show.
162 .max_redirects(0)
163 .build()
164 .into()
165}
166
167/// Refuses a URL whose host resolves to an address inside this machine or its network.
168///
169/// Every address the name answers with has to pass, not merely the first: a name with
170/// both a public and a private answer is the shape of the attack, and connecting to
171/// "whichever came back first" is not a decision worth leaving to a resolver.
172fn check_resolves_publicly(url: &str) -> Result<(), String> {
173 let (scheme, rest) = url.split_once("://").ok_or("that URL has no scheme")?;
174 let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
175
176 let default_port = if scheme.eq_ignore_ascii_case("https") {
177 443
178 } else {
179 80
180 };
181
182 // `to_socket_addrs` wants `host:port`, and an IPv6 literal must keep its brackets
183 // for that — which is exactly how the authority already spells it.
184 let target = if authority.contains(':') && !authority.ends_with(']') {
185 authority.to_owned()
186 } else {
187 format!("{authority}:{default_port}")
188 };
189
190 let resolved: Vec<_> = target
191 .to_socket_addrs()
192 .map_err(|error| format!("{authority} did not resolve: {error}"))?
193 .collect();
194
195 if resolved.is_empty() {
196 return Err(format!("{authority} resolved to no addresses"));
197 }
198
199 for address in resolved {
200 if !is_public(address.ip()) {
201 return Err(format!(
202 "{authority} resolves to {}, which is on this machine or its private network",
203 address.ip()
204 ));
205 }
206 }
207
208 Ok(())
209}
210
211/// A sender that records what it was asked to deliver and answers from a script.
212///
213/// The counterpart to [`UreqWebhookSender`], and what lets the delivery use case be
214/// tested without a listening socket. The recorded requests are what a test asserts the
215/// signature and headers on.
216#[derive(Debug, Default, Clone)]
217pub struct InMemoryWebhookSender {
218 sent: Arc<Mutex<Vec<WebhookRequest>>>,
219 /// One outcome per call, in order. The last one repeats once the script runs out, so
220 /// a test that wants "always fails" writes one entry rather than four.
221 script: Arc<Mutex<Vec<WebhookOutcome>>>,
222}
223
224impl InMemoryWebhookSender {
225 /// A sender that always succeeds.
226 pub fn new() -> Self {
227 Self::default()
228 }
229
230 /// A sender that answers with these outcomes in order, repeating the last.
231 pub fn scripted(outcomes: Vec<WebhookOutcome>) -> Self {
232 Self {
233 sent: Arc::default(),
234 script: Arc::new(Mutex::new(outcomes)),
235 }
236 }
237
238 /// Everything it was asked to send, in order.
239 pub fn sent(&self) -> Vec<WebhookRequest> {
240 self.sent.lock().expect("lock poisoned").clone()
241 }
242}
243
244impl WebhookSender for InMemoryWebhookSender {
245 async fn send(&self, request: WebhookRequest) -> WebhookOutcome {
246 let mut sent = self.sent.lock().expect("lock poisoned");
247 sent.push(request);
248
249 let script = self.script.lock().expect("lock poisoned");
250
251 match script.get(sent.len() - 1).or_else(|| script.last()) {
252 Some(outcome) => outcome.clone(),
253 None => WebhookOutcome {
254 status: Some(200),
255 body: String::new(),
256 duration: Duration::from_millis(1),
257 },
258 }
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 /// The redirect hole this closed. `ureq`'s default is ten, and the address check
265 /// only ever sees the URL that was saved — so a public receiver answering a 302 to
266 /// `169.254.169.254` reached it, and its body came back onto the deliveries page.
267 #[test]
268 fn a_delivery_follows_no_redirects() {
269 assert_eq!(agent().config().max_redirects(), 0);
270 }
271
272 use super::*;
273
274 #[test]
275 fn a_name_that_resolves_to_a_private_address_is_refused() {
276 // The half `WebhookUrl` cannot cover: the check is on what the name *resolves
277 // to*, not on how it is spelled. `localhost` stands in for a public name with a
278 // private answer because it needs no resolver to be reachable — the real case
279 // is a domain whose A record points inside the network.
280 assert!(check_resolves_publicly("http://localhost:8080/hook").is_err());
281 assert!(check_resolves_publicly("http://localhost/hook").is_err());
282 }
283
284 #[test]
285 fn a_url_with_no_scheme_never_reaches_a_socket() {
286 assert!(check_resolves_publicly("example.com/hook").is_err());
287 }
288
289 #[test]
290 fn a_name_that_resolves_to_nothing_is_refused_rather_than_dialled() {
291 assert!(check_resolves_publicly("https://this-name-does-not-exist.invalid/hook").is_err());
292 }
293
294 #[tokio::test]
295 async fn the_fake_answers_a_script_in_order_and_then_repeats_it() {
296 let outcome = |status| WebhookOutcome {
297 status,
298 body: String::new(),
299 duration: Duration::from_millis(1),
300 };
301
302 let sender = InMemoryWebhookSender::scripted(vec![outcome(None), outcome(Some(200))]);
303
304 let request = || WebhookRequest {
305 url: "https://example.com/hook".to_owned(),
306 headers: Vec::new(),
307 body: b"{}".to_vec(),
308 };
309
310 assert_eq!(sender.send(request()).await.status, None);
311 assert_eq!(sender.send(request()).await.status, Some(200));
312 assert_eq!(sender.send(request()).await.status, Some(200));
313 assert_eq!(sender.sent().len(), 3);
314 }
315}