steid

@jamesgill /

steid/src/domain/webhook.rs
Rust19.8 KBCode·Blame·History·Raw
1//! A webhook: where to POST when something happens in a repository, and what to sign
2//! the body with.
3//!
4//! Unlike a personal access token, **the secret is stored in the clear**. It has to be:
5//! the signature is an HMAC computed on every delivery, and a hash cannot produce one.
6//! It is still shown only once, so it does not sit on a page waiting to be
7//! shoulder-surfed, and it authenticates Steid *to the receiver* rather than anyone to
8//! Steid — a leaked hook secret lets someone forge a payload at that one endpoint, not
9//! act as the owner.
10
11use std::{fmt, net::IpAddr, time::SystemTime};
12
13use hmac::{Hmac, Mac};
14use sha2::Sha256;
15
16use super::{DomainError, RepoId, WebhookId, net::is_public};
17
18/// The prefix every hook secret carries, so a leaked one is recognisable as Steid's.
19const SECRET_PREFIX: &str = "steid_whs_";
20
21type HmacSha256 = Hmac<Sha256>;
22
23/// A hook's shared secret, in the clear.
24///
25/// Redacted in `Debug` for the same reason a token is: the value is the whole of the
26/// authentication, and a log line is a place it must not reach.
27#[derive(Clone, PartialEq, Eq)]
28pub struct WebhookSecret(String);
29
30impl WebhookSecret {
31 /// Bytes of entropy, matching [`TokenSecret`](super::TokenSecret).
32 const BYTES: usize = 32;
33
34 /// Mints a fresh secret from the OS random source.
35 pub fn generate() -> Self {
36 use rand::Rng;
37
38 let mut bytes = [0u8; Self::BYTES];
39 rand::rng().fill_bytes(&mut bytes);
40
41 let random: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
42
43 Self(format!("{SECRET_PREFIX}{random}"))
44 }
45
46 /// Wraps a stored secret without generating a new one.
47 pub fn from_trusted(value: impl Into<String>) -> Self {
48 Self(value.into())
49 }
50
51 /// The secret itself, to be shown exactly once and used to sign.
52 pub fn reveal(&self) -> &str {
53 &self.0
54 }
55
56 /// The `sha256=<hex>` signature of a body, as it goes on the wire.
57 ///
58 /// HMAC-SHA-256 over the exact bytes sent, which is what every other forge does and
59 /// what every receiving library already knows how to check. The `sha256=` prefix is
60 /// part of the value rather than implied, so a future second algorithm is a new
61 /// prefix rather than a silent reinterpretation of the same header.
62 pub fn sign(&self, body: &[u8]) -> String {
63 // Infallible for HMAC: the construction accepts a key of any length.
64 let mut mac = HmacSha256::new_from_slice(self.0.as_bytes()).expect("hmac takes any key");
65 mac.update(body);
66
67 let digest = mac.finalize().into_bytes();
68
69 format!(
70 "sha256={}",
71 digest
72 .iter()
73 .map(|byte| format!("{byte:02x}"))
74 .collect::<String>()
75 )
76 }
77}
78
79impl fmt::Debug for WebhookSecret {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 f.write_str("WebhookSecret(redacted)")
82 }
83}
84
85/// Where a delivery is POSTed.
86///
87/// Validated here rather than at the call site, because this is the one value in Steid
88/// that makes the server open a connection to an address a *user* chose. That is
89/// server-side request forgery, and the defence is a rule about what the value may name
90/// — not a check somewhere along the way that a later caller can forget.
91///
92/// What is refused: anything but `http`/`https`, credentials in the authority, and any
93/// host that names the machine Steid runs on or the network it sits in — loopback,
94/// private ranges, link-local (which includes the cloud metadata endpoint at
95/// `169.254.169.254`), and the names that conventionally resolve to them.
96///
97/// **This is a syntactic rule and cannot be the whole defence**: a public name can
98/// resolve to a private address. The sending adapter re-checks the resolved address, so
99/// this is the cheap half, made at the moment a person can be told why.
100/// Whether a hook may point inside this machine's own network.
101///
102/// `Refused` is the only value in production and the default everywhere. `Allowed` exists
103/// because **the end-to-end check cannot be run without it**: verifying that a real push
104/// signs and delivers a real payload needs a receiver, the obvious receiver is a script
105/// listening on `127.0.0.1`, and the SSRF rule refuses exactly that. Before this, the
106/// check needed a temporary patch to the source — which is a worse thing to have to do,
107/// because a patch is invisible and a flag is not.
108///
109/// Reached only through `STEID_ALLOW_PRIVATE_WEBHOOKS=true`, documented in
110/// `plans/runbook.md` beside `STEID_INSECURE_COOKIES` and never to be deployed: with it
111/// on, anyone who can add a hook can make the server dial its own network and read the
112/// response body off the deliveries page.
113#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
114pub enum PrivateTargets {
115 #[default]
116 Refused,
117 Allowed,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct WebhookUrl(String);
122
123impl WebhookUrl {
124 /// Well past any real endpoint, and a bound on what goes in a row.
125 pub const MAX_LEN: usize = 2000;
126
127 /// Host names that name this machine by convention rather than by address.
128 const BLOCKED_NAMES: [&'static str; 3] =
129 ["localhost", "localhost.localdomain", "ip6-localhost"];
130
131 /// Suffixes that resolve inside the local network by convention: mDNS, and the
132 /// spellings container and cloud platforms use for their own services.
133 const BLOCKED_SUFFIXES: [&'static str; 4] =
134 [".localhost", ".local", ".internal", ".localdomain"];
135
136 /// Validates a URL a person typed, refusing anything on this machine or its own
137 /// network.
138 pub fn new(value: impl AsRef<str>) -> Result<Self, DomainError> {
139 Self::new_with(value, PrivateTargets::Refused)
140 }
141
142 /// Validates a URL, allowing one that points inside this machine's own network.
143 ///
144 /// **Development only**, and reached only through
145 /// `STEID_ALLOW_PRIVATE_WEBHOOKS` — see the note on [`PrivateTargets`]. Everything
146 /// else still applies: the scheme, credentials in the authority, the port, the
147 /// length and the packed spellings of an address are refused exactly as before,
148 /// because none of those is about where the address points.
149 pub fn new_allowing_private(value: impl AsRef<str>) -> Result<Self, DomainError> {
150 Self::new_with(value, PrivateTargets::Allowed)
151 }
152
153 fn new_with(value: impl AsRef<str>, targets: PrivateTargets) -> Result<Self, DomainError> {
154 let value = value.as_ref().trim();
155
156 if value.is_empty() {
157 return Err(invalid("give the hook a URL to POST to"));
158 }
159
160 if value.len() > Self::MAX_LEN {
161 return Err(invalid(format!(
162 "a URL is at most {} characters",
163 Self::MAX_LEN
164 )));
165 }
166
167 if value.chars().any(|c| c.is_ascii_control() || c == ' ') {
168 return Err(invalid("a URL cannot contain spaces or control characters"));
169 }
170
171 let Some((scheme, rest)) = value.split_once("://") else {
172 return Err(invalid("a URL starts with http:// or https://"));
173 };
174
175 if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
176 return Err(invalid(
177 "only http:// and https:// URLs can be delivered to",
178 ));
179 }
180
181 let authority = rest
182 .split(['/', '?', '#'])
183 .next()
184 .expect("split always yields one part");
185
186 // A URL carrying credentials is refused rather than stripped: `user@host` is
187 // also how a parser can be confused into connecting somewhere other than where
188 // the value appears to name.
189 if authority.contains('@') {
190 return Err(invalid("a URL cannot carry credentials"));
191 }
192
193 let host = host_of(authority)?;
194
195 if host.is_empty() {
196 return Err(invalid("a URL needs a host"));
197 }
198
199 let local_allowed = targets == PrivateTargets::Allowed;
200
201 if let Ok(address) = host.parse::<IpAddr>() {
202 if !local_allowed && !is_public(address) {
203 return Err(invalid(
204 "that address is on this machine or its private network",
205 ));
206 }
207 } else {
208 let lowered = host.to_ascii_lowercase();
209
210 if !local_allowed
211 && (Self::BLOCKED_NAMES.contains(&lowered.as_str())
212 || Self::BLOCKED_SUFFIXES
213 .iter()
214 .any(|suffix| lowered.ends_with(suffix)))
215 {
216 return Err(invalid("that host names this machine or its own network"));
217 }
218
219 if !local_allowed && !lowered.contains('.') {
220 return Err(invalid(
221 "that host has no domain, so it can only name something local",
222 ));
223 }
224
225 // A real name's last label is a TLD, which is never numeric. This is what
226 // catches the short and packed spellings of an address that `IpAddr` will
227 // not parse but a resolver will happily accept: `127.1`, `2130706433`,
228 // `0177.0.0.1`, `0x7f.1`. Refusing by shape beats enumerating the
229 // encodings.
230 let last = lowered.rsplit('.').next().unwrap_or_default();
231
232 // Unconditional, escape hatch or not: `127.1` is not a host name under any
233 // configuration, and a value that reaches a resolver by accident of parsing
234 // is worth refusing on its own account.
235 if last.starts_with(|c: char| c.is_ascii_digit()) {
236 return Err(invalid(
237 "that is not a host name — write an address out in full",
238 ));
239 }
240 }
241
242 Ok(Self(value.to_owned()))
243 }
244
245 /// Wraps a URL already known to have passed the rules above.
246 pub fn from_trusted(value: impl Into<String>) -> Self {
247 Self(value.into())
248 }
249
250 pub fn as_str(&self) -> &str {
251 &self.0
252 }
253
254 /// The host, for the adapter's second check against what it resolves to.
255 pub fn host(&self) -> &str {
256 let rest = self.0.split_once("://").map(|(_, rest)| rest).unwrap_or("");
257 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
258
259 host_of(authority).unwrap_or_default()
260 }
261}
262
263impl fmt::Display for WebhookUrl {
264 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265 f.write_str(&self.0)
266 }
267}
268
269/// Pulls the host out of an authority, unwrapping an IPv6 literal's brackets.
270fn host_of(authority: &str) -> Result<&str, DomainError> {
271 if let Some(rest) = authority.strip_prefix('[') {
272 let Some((host, after)) = rest.split_once(']') else {
273 return Err(invalid("that IPv6 address is missing its closing bracket"));
274 };
275
276 check_port(after.strip_prefix(':'))?;
277
278 return Ok(host);
279 }
280
281 match authority.split_once(':') {
282 Some((host, port)) => {
283 check_port(Some(port))?;
284 Ok(host)
285 }
286 None => Ok(authority),
287 }
288}
289
290fn check_port(port: Option<&str>) -> Result<(), DomainError> {
291 let Some(port) = port else {
292 return Ok(());
293 };
294
295 match port.parse::<u16>() {
296 Ok(port) if port > 0 => Ok(()),
297 _ => Err(invalid("that is not a port number")),
298 }
299}
300
301fn invalid(reason: impl Into<String>) -> DomainError {
302 DomainError::validation("URL", reason)
303}
304
305/// A webhook, as recorded.
306///
307/// Belongs to a repository rather than to a user: it is the repository's pushes it
308/// reacts to, and it survives the person who added it changing role.
309#[derive(Debug, Clone, PartialEq, Eq)]
310pub struct Webhook {
311 pub id: WebhookId,
312 pub repo_id: RepoId,
313 pub url: WebhookUrl,
314 pub secret: WebhookSecret,
315 /// Whether deliveries are attempted. A hook that is off is kept rather than deleted
316 /// so its delivery history — the reason it was turned off — stays readable.
317 pub active: bool,
318 pub created_at: SystemTime,
319}
320
321impl Webhook {
322 /// Validates a new hook.
323 pub fn new(
324 id: WebhookId,
325 repo_id: RepoId,
326 url: impl AsRef<str>,
327 secret: WebhookSecret,
328 created_at: SystemTime,
329 // Whether a local target is allowed: `Refused` everywhere but a development
330 // instance that has opted in. Passed rather than defaulted, so every
331 // construction of a hook says which it is.
332 targets: PrivateTargets,
333 ) -> Result<Self, DomainError> {
334 Ok(Self {
335 id,
336 repo_id,
337 url: match targets {
338 PrivateTargets::Refused => WebhookUrl::new(url)?,
339 PrivateTargets::Allowed => WebhookUrl::new_allowing_private(url)?,
340 },
341 secret,
342 active: true,
343 created_at,
344 })
345 }
346
347 /// Rebuilds a row from storage without revalidating it.
348 ///
349 /// Deliberately does not re-run [`WebhookUrl::new`]: tightening the SSRF rules must
350 /// not make an existing row unreadable, which would hide the hook from the page
351 /// where it could be deleted. A row that no longer passes is still refused *at
352 /// delivery*, by the adapter's check on the resolved address.
353 pub fn from_trusted(
354 id: WebhookId,
355 repo_id: RepoId,
356 url: WebhookUrl,
357 secret: WebhookSecret,
358 active: bool,
359 created_at: SystemTime,
360 ) -> Self {
361 Self {
362 id,
363 repo_id,
364 url,
365 secret,
366 active,
367 created_at,
368 }
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn a_generated_secret_is_recognisable_and_full_width() {
378 let secret = WebhookSecret::generate();
379
380 assert!(secret.reveal().starts_with("steid_whs_"));
381 assert_eq!(
382 secret.reveal().len(),
383 "steid_whs_".len() + WebhookSecret::BYTES * 2
384 );
385 assert_ne!(
386 WebhookSecret::generate().reveal(),
387 WebhookSecret::generate().reveal()
388 );
389 }
390
391 #[test]
392 fn a_secret_never_reaches_debug_output() {
393 let secret = WebhookSecret::generate();
394
395 assert_eq!(format!("{secret:?}"), "WebhookSecret(redacted)");
396
397 let hook = Webhook::new(
398 WebhookId::generate(),
399 RepoId::generate(),
400 "https://example.com/hook",
401 secret.clone(),
402 SystemTime::UNIX_EPOCH,
403 PrivateTargets::Refused,
404 )
405 .expect("valid");
406
407 assert!(!format!("{hook:?}").contains(secret.reveal()));
408 }
409
410 #[test]
411 fn signing_matches_a_known_hmac() {
412 // The vector every HMAC-SHA-256 implementation agrees on, so a library swap
413 // that changes the answer fails here rather than at somebody's receiver.
414 let secret = WebhookSecret::from_trusted("key");
415
416 assert_eq!(
417 secret.sign(b"The quick brown fox jumps over the lazy dog"),
418 "sha256=f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"
419 );
420 }
421
422 #[test]
423 fn signing_is_stable_and_body_dependent() {
424 let secret = WebhookSecret::generate();
425
426 assert_eq!(secret.sign(b"{}"), secret.sign(b"{}"));
427 assert_ne!(secret.sign(b"{}"), secret.sign(b"{ }"));
428 assert_ne!(secret.sign(b"{}"), WebhookSecret::generate().sign(b"{}"));
429 }
430
431 #[test]
432 fn ordinary_public_urls_are_accepted() {
433 for url in [
434 "https://example.com/hook",
435 "http://example.com:8080/a/b?c=d",
436 "https://hooks.example.co.uk/services/T/B/x",
437 "https://93.184.216.34/hook",
438 "https://[2606:2800:220:1:248:1893:25c8:1946]/hook",
439 ] {
440 assert!(WebhookUrl::new(url).is_ok(), "{url} should be accepted");
441 }
442 }
443
444 #[test]
445 fn only_http_urls_can_be_delivered_to() {
446 for url in [
447 "file:///etc/passwd",
448 "gopher://example.com/",
449 "ftp://example.com/",
450 "example.com/hook",
451 "",
452 ] {
453 assert!(WebhookUrl::new(url).is_err(), "{url:?} should be refused");
454 }
455 }
456
457 #[test]
458 fn nothing_on_this_machine_or_its_network_can_be_delivered_to() {
459 // The whole SSRF surface, named case by case: a hook is a URL a user chose and
460 // the server dials it.
461 for url in [
462 "http://127.0.0.1:3000/hook",
463 "http://127.1/hook",
464 "http://2130706433/hook",
465 "http://0177.0.0.1/hook",
466 "http://localhost:3000/hook",
467 "http://LOCALHOST/hook",
468 "http://steid.local/hook",
469 "http://db.internal/hook",
470 "http://redis/hook",
471 "http://10.0.0.5/hook",
472 "http://192.168.1.1/hook",
473 "http://172.16.0.1/hook",
474 "http://100.64.0.1/hook",
475 "http://169.254.169.254/latest/meta-data/",
476 "http://0.0.0.0/hook",
477 "http://[::1]/hook",
478 "http://[fe80::1]/hook",
479 "http://[fd00::1]/hook",
480 "http://[::ffff:127.0.0.1]/hook",
481 ] {
482 assert!(WebhookUrl::new(url).is_err(), "{url} should be refused");
483 }
484 }
485
486 #[test]
487 fn the_development_escape_hatch_allows_a_local_receiver() {
488 // What the end-to-end check needs, and nothing more: a script listening on
489 // loopback.
490 for url in [
491 "http://127.0.0.1:9099/hook",
492 "http://localhost:9099/hook",
493 "http://[::1]:9099/hook",
494 "http://192.168.1.10/hook",
495 ] {
496 assert!(
497 WebhookUrl::new_allowing_private(url).is_ok(),
498 "{url} should be accepted with the escape hatch on"
499 );
500 assert!(
501 WebhookUrl::new(url).is_err(),
502 "{url} should still be refused without it"
503 );
504 }
505 }
506
507 #[test]
508 fn the_escape_hatch_relaxes_addresses_and_nothing_else() {
509 // It is a development convenience, not a way to store a URL that cannot be
510 // delivered to: everything that is wrong with these is wrong wherever they point.
511 for url in [
512 "ftp://127.0.0.1/hook",
513 "http://user:pass@127.0.0.1/hook",
514 "http://127.0.0.1:0/hook",
515 // Not a host name under any configuration.
516 "http://127.1/hook",
517 "http://2130706433/hook",
518 ] {
519 assert!(
520 WebhookUrl::new_allowing_private(url).is_err(),
521 "{url} should be refused even with the escape hatch on"
522 );
523 }
524 }
525
526 #[test]
527 fn credentials_in_the_authority_are_refused() {
528 assert!(WebhookUrl::new("https://user:pass@example.com/hook").is_err());
529 assert!(WebhookUrl::new("https://example.com@127.0.0.1/hook").is_err());
530 }
531
532 #[test]
533 fn a_url_that_is_not_addressable_is_refused() {
534 assert!(WebhookUrl::new("https://example.com:0/hook").is_err());
535 assert!(WebhookUrl::new("https://example.com:notaport/hook").is_err());
536 assert!(WebhookUrl::new("https://[::1/hook").is_err());
537 assert!(WebhookUrl::new("https:///hook").is_err());
538 assert!(WebhookUrl::new(format!("https://example.com/{}", "a".repeat(2000))).is_err());
539 }
540
541 #[test]
542 fn the_host_is_readable_back_for_the_adapters_second_check() {
543 assert_eq!(
544 WebhookUrl::new("https://example.com:8443/hook")
545 .expect("valid")
546 .host(),
547 "example.com"
548 );
549 assert_eq!(
550 WebhookUrl::new("https://[2606:2800:220:1:248:1893:25c8:1946]:8443/hook")
551 .expect("valid")
552 .host(),
553 "2606:2800:220:1:248:1893:25c8:1946"
554 );
555 }
556
557 #[test]
558 fn a_new_hook_is_active() {
559 let hook = Webhook::new(
560 WebhookId::generate(),
561 RepoId::generate(),
562 "https://example.com/hook",
563 WebhookSecret::generate(),
564 SystemTime::UNIX_EPOCH,
565 PrivateTargets::Refused,
566 )
567 .expect("valid");
568
569 assert!(hook.active);
570 }
571}