Skip to main content

choir_node/
hooks.rs

1//! Outbound ref-landed webhooks (D32): "something moved, go run this".
2//!
3//! The node speaks to git and to its own clients; until this module it
4//! told nobody else when a ref landed. A subscription is a line in an
5//! operator-owned file — `<repo:refname pattern> <url> <secret>
6//! [allow-private]` — reloaded on mtime like the keys and ACL files, and
7//! deliberately *not* a `ViewOp`: who may be notified is configuration,
8//! not sequenced state, and putting it in the log would make it
9//! replayable and unforgettable.
10//!
11//! # Invariant 5 is the whole design
12//!
13//! A webhook is a request to an address someone else chose, so its
14//! latency is unbounded by construction. The sequencer's writer thread
15//! therefore does exactly one thing with an event: [`Hooks::offer`],
16//! which is a non-blocking `try_send` into a bounded queue. Reloading the
17//! subscription file, matching patterns, resolving and vetting the
18//! address, running `curl` and retrying all happen on the delivery
19//! thread this module spawns. When the queue is full the event is
20//! **dropped and counted**, never blocked on: a receiver that stops
21//! answering must not be able to stall op admission, and blocking the
22//! writer is the one failure this module exists to make impossible.
23//!
24//! # Best-effort, counted, never silent
25//!
26//! Delivery is best-effort, not at-least-once. At-least-once needs a
27//! durable spool with its own fsync discipline, and the only durable
28//! writer here is the sequencer — the thread the design keeps out of the
29//! delivery path. So every attempt, every refusal and every queue-full
30//! drop is appended to the delivery log (`<root>/.choir/hooks.jsonl`), a
31//! production observation beside `lag.jsonl`: not hashed, not replayed,
32//! never part of the ordered history. A receiver that may not miss a ref
33//! polls `GET /api/log?from=N`, which is what agents already do.
34//!
35//! # Talking to an address someone else chose
36//!
37//! This is the node's first outbound request to an operator-supplied
38//! target, so SSRF is in scope. Vetting refuses non-`http(s)` schemes,
39//! resolves the host and refuses loopback, private, carrier-NAT,
40//! link-local (which is what covers the cloud metadata service at
41//! `169.254.169.254`), unique-local, unspecified, broadcast and
42//! multicast addresses unless the subscription says `allow-private`; the
43//! vetted address is then pinned into `curl --resolve` so a second DNS
44//! answer cannot land somewhere else, redirects are refused outright
45//! because a redirect is the standard way past exactly this check, and a
46//! non-loopback target must be `https`, since a bearer secret in clear
47//! over the internet is the same as no secret.
48//!
49//! The operator's guide to configuring them:
50//!
51#![doc = include_str!("../../../docs/operating/webhooks.md")]
52
53use std::io::Write;
54use std::net::{IpAddr, ToSocketAddrs};
55use std::path::{Path, PathBuf};
56use std::sync::atomic::{AtomicU64, Ordering};
57use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, TrySendError};
58use std::sync::Arc;
59use std::time::{Duration, SystemTime};
60
61/// Events held in memory while the delivery thread works. Small on
62/// purpose: a full queue is a receiver problem the operator should see
63/// in the delivery log, not a backlog the node quietly grows a heap for.
64pub const QUEUE_CAPACITY: usize = 256;
65
66/// Attempts per matching subscription before a delivery is abandoned.
67const MAX_ATTEMPTS: u32 = 3;
68
69/// Wait between attempts. Multiplied by the attempt number, so the three
70/// attempts of one delivery span well under a second: this thread is the
71/// only one delivering, and a long backoff is paid for in dropped events.
72const RETRY_BACKOFF: Duration = Duration::from_millis(100);
73
74/// How long the worker waits for an event before looking around. Bounded
75/// so a queue-full drop reaches the delivery log even when the drop was
76/// the last thing that ever happened.
77const IDLE_POLL: Duration = Duration::from_millis(200);
78
79/// Per-attempt ceiling on `curl`. A receiver that never answers costs
80/// this much and no more.
81const REQUEST_TIMEOUT_SECS: u64 = 10;
82
83/// A ref that moved, as the writer thread saw it.
84///
85/// Built inside the sequencer's `accepted()` and immediately handed to
86/// [`Hooks::offer`]; every field is already in hand there, so building it
87/// costs no lookup.
88#[derive(Debug, Clone)]
89pub struct RefEvent {
90    /// The view's ref key, `<repo>:<refname>` for git-derived refs. This
91    /// is what subscription patterns match, so a pattern means the same
92    /// thing here as in `--protected-refs`.
93    pub key: String,
94    /// Old git oid in hex, `None` when the ref did not exist.
95    pub old: Option<String>,
96    /// New git oid in hex, `None` when the ref was deleted.
97    pub new: Option<String>,
98    /// Log position of the entry that moved it.
99    pub seq: u64,
100    /// Content hash of that entry, hex. Unique per event, so a receiver
101    /// can discard a delivery it has already acted on.
102    pub entry: String,
103    /// Attribution channel the op was signed on.
104    pub actor: String,
105    /// Signing key id, when the op carried an author signature.
106    pub key_id: Option<String>,
107}
108
109impl RefEvent {
110    /// Repository half of the ref key, when it has one. The platform API
111    /// can set a ref name with no `:`, in which case there is no repo.
112    fn repo(&self) -> Option<&str> {
113        self.key.split_once(':').map(|(repo, _)| repo)
114    }
115
116    /// Ref name half of the ref key, or the whole key when it has no
117    /// repository prefix.
118    fn refname(&self) -> &str {
119        self.key
120            .split_once(':')
121            .map_or(self.key.as_str(), |(_, r)| r)
122    }
123
124    fn body(&self) -> String {
125        serde_json::json!({
126            "format_version": 1,
127            "event": "ref-landed",
128            "repo": self.repo(),
129            "ref": self.refname(),
130            "ref_key": self.key,
131            "old": self.old,
132            "new": self.new,
133            "seq": self.seq,
134            "entry": self.entry,
135            "actor": self.actor,
136            "key_id": self.key_id,
137        })
138        .to_string()
139    }
140}
141
142/// One line of the subscription file.
143#[derive(Debug, Clone, PartialEq, Eq)]
144struct Subscription {
145    /// `<repo>:<refname>`, exact or with a single trailing `*`.
146    pattern: String,
147    url: String,
148    secret: String,
149    allow_private: bool,
150}
151
152impl Subscription {
153    /// The `--protected-refs` rule, copied rather than reinvented: a
154    /// trailing `*` is a prefix, anything else is exact.
155    fn matches(&self, key: &str) -> bool {
156        match self.pattern.strip_suffix('*') {
157            Some(prefix) => key.starts_with(prefix),
158            None => key == self.pattern,
159        }
160    }
161}
162
163/// Parses one non-comment line.
164fn parse_line(line: &str) -> Result<Subscription, String> {
165    let mut fields = line.split_whitespace();
166    let (Some(pattern), Some(url), Some(secret)) = (fields.next(), fields.next(), fields.next())
167    else {
168        return Err("each line is `<repo:refname> <url> <secret> [allow-private]`".to_string());
169    };
170    let mut allow_private = false;
171    for extra in fields {
172        match extra {
173            "allow-private" => allow_private = true,
174            other => return Err(format!("unknown option `{other}`")),
175        }
176    }
177    // The secret reaches curl through a quoted line of its stdin config,
178    // so a quote, a backslash or a newline in it could grow a second
179    // config directive. Refused here rather than escaped: an operator
180    // mints these with `openssl rand -hex 32`.
181    if secret.contains(['"', '\\']) || secret.chars().any(char::is_control) {
182        return Err("a secret may not contain a quote, a backslash or a control character".into());
183    }
184    if !url.starts_with("http://") && !url.starts_with("https://") {
185        return Err("a target url must start with http:// or https://".to_string());
186    }
187    Ok(Subscription {
188        pattern: pattern.to_string(),
189        url: url.to_string(),
190        secret: secret.to_string(),
191        allow_private,
192    })
193}
194
195/// Reads the whole subscription file, or refuses all of it.
196///
197/// Partial parsing is the failure mode to avoid: half a subscription
198/// file is a node that silently stops notifying somebody, which looks
199/// exactly like a receiver that stopped caring.
200fn load(path: &Path) -> Result<Vec<Subscription>, String> {
201    let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
202    let mut subscriptions = Vec::new();
203    for (number, line) in text.lines().enumerate() {
204        let line = line.trim();
205        if line.is_empty() || line.starts_with('#') {
206            continue;
207        }
208        subscriptions.push(parse_line(line).map_err(|e| format!("line {}: {e}", number + 1))?);
209    }
210    Ok(subscriptions)
211}
212
213/// A vetted target: where `curl` is allowed to connect, and to which
214/// address specifically.
215#[derive(Debug, PartialEq, Eq)]
216struct Target {
217    host: String,
218    port: u16,
219    addr: IpAddr,
220    https: bool,
221}
222
223/// Addresses a webhook may not reach without `allow-private`.
224///
225/// The list is explicit because `IpAddr::is_global` is unstable, and
226/// because each entry is a documented SSRF target rather than a tidy
227/// category: link-local is where cloud metadata services live, and
228/// carrier-grade NAT space is routable-looking but internal.
229fn is_private_address(addr: IpAddr) -> bool {
230    match addr {
231        IpAddr::V4(v4) => {
232            let octets = v4.octets();
233            v4.is_loopback()
234                || v4.is_private()
235                || v4.is_link_local()
236                || v4.is_unspecified()
237                || v4.is_broadcast()
238                || v4.is_multicast()
239                || v4.is_documentation()
240                // 100.64.0.0/10, carrier-grade NAT.
241                || (octets[0] == 100 && (64..128).contains(&octets[1]))
242                // 0.0.0.0/8, "this network".
243                || octets[0] == 0
244        }
245        IpAddr::V6(v6) => {
246            if let Some(v4) = v6.to_ipv4_mapped() {
247                return is_private_address(IpAddr::V4(v4));
248            }
249            let segments = v6.segments();
250            v6.is_loopback()
251                || v6.is_unspecified()
252                || v6.is_multicast()
253                // fc00::/7, unique local.
254                || segments[0] & 0xfe00 == 0xfc00
255                // fe80::/10, link local.
256                || segments[0] & 0xffc0 == 0xfe80
257        }
258    }
259}
260
261/// Resolves `url` and decides whether this node may talk to it.
262///
263/// Every resolved address must pass, not merely the one that gets used:
264/// a name that answers with one public and one internal address is the
265/// interesting case, and picking the acceptable half of that answer
266/// would be a check that congratulates itself.
267fn vet(url: &str, allow_private: bool) -> Result<Target, String> {
268    let (https, rest) = match url.split_once("://") {
269        Some(("https", rest)) => (true, rest),
270        Some(("http", rest)) => (false, rest),
271        _ => return Err("a target url must start with http:// or https://".to_string()),
272    };
273    let authority = rest
274        .split(['/', '?', '#'])
275        .next()
276        .unwrap_or_default()
277        .to_string();
278    if authority.is_empty() {
279        return Err("the target url names no host".to_string());
280    }
281    // Credentials in the url would have to be split back out before the
282    // host could be vetted, and there is nothing they buy that the
283    // subscription secret does not.
284    if authority.contains('@') {
285        return Err("a target url may not carry credentials".to_string());
286    }
287    let (host, port) = match authority.strip_prefix('[') {
288        // Bracketed IPv6 literal, with or without a port.
289        Some(bracketed) => {
290            let (address, tail) = bracketed
291                .split_once(']')
292                .ok_or_else(|| "unterminated [ipv6] host".to_string())?;
293            let port = match tail.strip_prefix(':') {
294                Some(port) => port
295                    .parse::<u16>()
296                    .map_err(|_| "the target url has a bad port".to_string())?,
297                None => default_port(https),
298            };
299            (address.to_string(), port)
300        }
301        None => match authority.rsplit_once(':') {
302            Some((host, port)) => (
303                host.to_string(),
304                port.parse::<u16>()
305                    .map_err(|_| "the target url has a bad port".to_string())?,
306            ),
307            None => (authority.clone(), default_port(https)),
308        },
309    };
310    if host.is_empty() {
311        return Err("the target url names no host".to_string());
312    }
313    let resolved: Vec<IpAddr> = (host.as_str(), port)
314        .to_socket_addrs()
315        .map_err(|e| format!("{host} does not resolve: {e}"))?
316        .map(|socket| socket.ip())
317        .collect();
318    let Some(addr) = resolved.first().copied() else {
319        return Err(format!("{host} resolves to no address"));
320    };
321    if !allow_private {
322        if let Some(refused) = resolved.iter().find(|ip| is_private_address(**ip)) {
323            return Err(format!(
324                "{host} resolves to {refused}, which is loopback, private, link-local or \
325                 otherwise internal; add `allow-private` to this subscription if that is \
326                 deliberate"
327            ));
328        }
329    }
330    // The secret is a bearer credential, so it may only cross a network
331    // it cannot be read on. Loopback is the exception, not https.
332    if !https && !addr.is_loopback() {
333        return Err(format!(
334            "{host} is not loopback, so this subscription must use https: an http delivery \
335             carries the subscription secret in clear"
336        ));
337    }
338    Ok(Target {
339        host,
340        port,
341        addr,
342        https,
343    })
344}
345
346/// The address as `--resolve` spells it: an IPv6 literal is bracketed
347/// there, an IPv4 one is bare.
348fn resolve_form(addr: IpAddr) -> String {
349    match addr {
350        IpAddr::V4(v4) => v4.to_string(),
351        IpAddr::V6(v6) => format!("[{v6}]"),
352    }
353}
354
355fn default_port(https: bool) -> u16 {
356    if https {
357        443
358    } else {
359        80
360    }
361}
362
363/// Handle held by the platform. Cloneable so the writer thread's copy
364/// costs nothing; the delivery thread lives as long as the process.
365#[derive(Clone)]
366pub struct Hooks {
367    tx: SyncSender<RefEvent>,
368    dropped: Arc<AtomicU64>,
369}
370
371impl Hooks {
372    /// Starts the delivery thread for the subscriptions in `config`,
373    /// writing delivery records to `log`.
374    ///
375    /// # Errors
376    ///
377    /// Returns a message when the subscription file cannot be read or
378    /// does not parse. Fatal at startup by design: an operator who asked
379    /// for webhooks and got a typo should be told now, not by silence
380    /// later.
381    pub fn start(config: PathBuf, log: PathBuf) -> Result<Self, String> {
382        let subscriptions = load(&config)?;
383        let count = subscriptions.len();
384        let (tx, rx) = std::sync::mpsc::sync_channel(QUEUE_CAPACITY);
385        let dropped = Arc::new(AtomicU64::new(0));
386        let worker = Worker {
387            rx,
388            mtime: std::fs::metadata(&config).and_then(|m| m.modified()).ok(),
389            config,
390            subscriptions,
391            log,
392            dropped: Arc::clone(&dropped),
393            reported_drops: 0,
394        };
395        std::thread::Builder::new()
396            .name("choir-hooks".to_string())
397            .spawn(move || worker.run())
398            .map_err(|e| format!("could not start the webhook delivery thread: {e}"))?;
399        eprintln!("webhooks enabled ({count} subscriptions)");
400        Ok(Self { tx, dropped })
401    }
402
403    /// Offers an event to the delivery thread. **Never blocks**: this
404    /// runs on the sequencer's writer thread, where waiting on a
405    /// receiver would put a stranger's latency in front of op admission
406    /// (invariant 5). A full queue drops the event and counts it.
407    pub fn offer(&self, event: RefEvent) {
408        match self.tx.try_send(event) {
409            Ok(()) => {}
410            Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => {
411                self.dropped.fetch_add(1, Ordering::Relaxed);
412            }
413        }
414    }
415
416    /// Events dropped because the queue was full (or the delivery thread
417    /// was gone). Also written to the delivery log; exposed for tests
418    /// and for anything that wants the count without parsing it back.
419    #[must_use]
420    pub fn dropped(&self) -> u64 {
421        self.dropped.load(Ordering::Relaxed)
422    }
423}
424
425struct Worker {
426    rx: Receiver<RefEvent>,
427    config: PathBuf,
428    mtime: Option<SystemTime>,
429    subscriptions: Vec<Subscription>,
430    log: PathBuf,
431    dropped: Arc<AtomicU64>,
432    reported_drops: u64,
433}
434
435impl Worker {
436    fn run(mut self) {
437        loop {
438            match self.rx.recv_timeout(IDLE_POLL) {
439                Ok(event) => {
440                    self.refresh();
441                    self.dispatch(&event);
442                    self.report_drops();
443                }
444                Err(RecvTimeoutError::Timeout) => self.report_drops(),
445                // Every sender is gone: the platform is shutting down.
446                Err(RecvTimeoutError::Disconnected) => {
447                    self.report_drops();
448                    return;
449                }
450            }
451        }
452    }
453
454    /// Rereads the subscription file when its mtime moved — the keys and
455    /// ACL discipline, on the delivery thread rather than the accept
456    /// loop, because that is where the file is used and nothing else may
457    /// pay for reading it.
458    ///
459    /// A malformed edit keeps the previous subscriptions and complains
460    /// once per edit, for the reason the ACL gives: half a policy file
461    /// silently changes who is notified.
462    fn refresh(&mut self) {
463        let mtime = std::fs::metadata(&self.config)
464            .and_then(|m| m.modified())
465            .ok();
466        if mtime.is_none() || mtime == self.mtime {
467            return;
468        }
469        self.mtime = mtime;
470        match load(&self.config) {
471            Ok(subscriptions) => {
472                eprintln!("webhooks: reloaded ({} subscriptions)", subscriptions.len());
473                self.subscriptions = subscriptions;
474            }
475            Err(e) => eprintln!("webhooks: file unusable, keeping previous: {e}"),
476        }
477    }
478
479    fn dispatch(&mut self, event: &RefEvent) {
480        let matched: Vec<Subscription> = self
481            .subscriptions
482            .iter()
483            .filter(|subscription| subscription.matches(&event.key))
484            .cloned()
485            .collect();
486        for subscription in matched {
487            self.deliver(event, &subscription);
488        }
489    }
490
491    fn deliver(&mut self, event: &RefEvent, subscription: &Subscription) {
492        let target = match vet(&subscription.url, subscription.allow_private) {
493            Ok(target) => target,
494            Err(reason) => {
495                self.record(serde_json::json!({
496                    "format_version": 1,
497                    "event": "refused",
498                    "seq": event.seq,
499                    "entry": event.entry,
500                    "ref_key": event.key,
501                    "url": subscription.url,
502                    "reason": reason,
503                }));
504                return;
505            }
506        };
507        let body = event.body();
508        for attempt in 1..=MAX_ATTEMPTS {
509            let outcome = post(&target, subscription, &body, &self.log);
510            let delivered = matches!(&outcome, Ok(status) if (200..300).contains(status));
511            let record = match &outcome {
512                Ok(status) if (200..300).contains(status) => serde_json::json!({
513                    "format_version": 1,
514                    "event": "delivered",
515                    "seq": event.seq,
516                    "entry": event.entry,
517                    "ref_key": event.key,
518                    "url": subscription.url,
519                    "attempt": attempt,
520                    "status": status,
521                }),
522                Ok(status) => serde_json::json!({
523                    "format_version": 1,
524                    "event": "failed",
525                    "seq": event.seq,
526                    "entry": event.entry,
527                    "ref_key": event.key,
528                    "url": subscription.url,
529                    "attempt": attempt,
530                    "status": status,
531                    "final": attempt == MAX_ATTEMPTS,
532                }),
533                Err(error) => serde_json::json!({
534                    "format_version": 1,
535                    "event": "failed",
536                    "seq": event.seq,
537                    "entry": event.entry,
538                    "ref_key": event.key,
539                    "url": subscription.url,
540                    "attempt": attempt,
541                    "error": error,
542                    "final": attempt == MAX_ATTEMPTS,
543                }),
544            };
545            self.record(record);
546            // Anything that fills the queue does it while this thread is
547            // inside an attempt, so the count is flushed here rather than
548            // only after the whole delivery: a receiver that never
549            // answers must not also delay the news that it is costing
550            // events.
551            self.report_drops();
552            if delivered {
553                return;
554            }
555            if attempt < MAX_ATTEMPTS {
556                std::thread::sleep(RETRY_BACKOFF * attempt);
557            }
558        }
559    }
560
561    /// Writes the queue-full count to the delivery log when it moves.
562    ///
563    /// The drop happens on the writer thread, which may not touch a
564    /// file, so it leaves an atomic counter behind and this thread turns
565    /// it into a record. Reported as a running total plus the increment,
566    /// so a truncated log still says how many were lost overall.
567    fn report_drops(&mut self) {
568        let dropped = self.dropped.load(Ordering::Relaxed);
569        if dropped == self.reported_drops {
570            return;
571        }
572        let since = dropped - self.reported_drops;
573        self.reported_drops = dropped;
574        self.record(serde_json::json!({
575            "format_version": 1,
576            "event": "dropped",
577            "dropped": since,
578            "dropped_total": dropped,
579            "queue_capacity": QUEUE_CAPACITY,
580        }));
581        eprintln!(
582            "webhooks: dropped {since} event(s) with a full queue ({dropped} total); a receiver \
583             is slower than this node produces refs"
584        );
585    }
586
587    fn record(&self, value: serde_json::Value) {
588        if let Some(parent) = self.log.parent() {
589            let _ = std::fs::create_dir_all(parent);
590        }
591        let appended = std::fs::OpenOptions::new()
592            .create(true)
593            .append(true)
594            .open(&self.log)
595            .and_then(|mut file| writeln!(file, "{value}"));
596        if let Err(e) = appended {
597            // Nowhere left to record it but the operator's console. A
598            // delivery record that cannot be written is itself the
599            // operational fact.
600            eprintln!("webhooks: could not write {}: {e}", self.log.display());
601        }
602    }
603}
604
605/// One `curl` POST to a vetted target. Returns the HTTP status, or a
606/// description of why there was none.
607///
608/// The secret goes in through curl's stdin config rather than argv,
609/// because argv is readable by every process on the host; the body goes
610/// through a 0600 temporary file for the same reason it does in the CLI:
611/// a long argument list is not a place for content.
612fn post(
613    target: &Target,
614    subscription: &Subscription,
615    body: &str,
616    log: &Path,
617) -> Result<u16, String> {
618    let scratch = log.parent().unwrap_or_else(|| Path::new("."));
619    let body_path = scratch.join(format!("hook-delivery-{}.json", std::process::id()));
620    write_private(&body_path, body).map_err(|e| format!("could not stage the payload: {e}"))?;
621    let mut command = std::process::Command::new("curl");
622    command
623        .args([
624            "-s",
625            "-w",
626            "\n%{http_code}",
627            "-o",
628            "/dev/null",
629            "-X",
630            "POST",
631            // A redirect to somewhere the vetting refused is the standard
632            // way past the vetting, so there are no redirects.
633            "--max-redirs",
634            "0",
635            "--proto",
636            "=http,https",
637            "--max-time",
638            &REQUEST_TIMEOUT_SECS.to_string(),
639            // Connect to the address that was vetted, so a second DNS
640            // answer cannot send this somewhere else.
641            "--resolve",
642            &format!(
643                "{}:{}:{}",
644                target.host,
645                target.port,
646                resolve_form(target.addr)
647            ),
648            "-H",
649            "Content-Type: application/json",
650            "--config",
651            "-",
652            "--data-binary",
653        ])
654        .arg(format!("@{}", body_path.display()))
655        .arg("--url")
656        .arg(&subscription.url)
657        .stdin(std::process::Stdio::piped())
658        .stdout(std::process::Stdio::piped())
659        // curl's diagnostics can name the operator's target host; the
660        // delivery record already says which subscription this was.
661        .stderr(std::process::Stdio::null());
662    let result = run(&mut command, &subscription.secret);
663    let _ = std::fs::remove_file(&body_path);
664    result
665}
666
667fn run(command: &mut std::process::Command, secret: &str) -> Result<u16, String> {
668    let mut child = command
669        .spawn()
670        .map_err(|_| "could not start curl".to_string())?;
671    // parse_line refused quotes and backslashes in a secret, so this
672    // quoted line cannot grow a second config directive.
673    let config = format!("header = \"X-Choir-Hook-Secret: {secret}\"\n");
674    child
675        .stdin
676        .take()
677        .expect("piped curl stdin")
678        .write_all(config.as_bytes())
679        .map_err(|_| "could not configure curl".to_string())?;
680    let output = child
681        .wait_with_output()
682        .map_err(|_| "could not wait for curl".to_string())?;
683    if !output.status.success() {
684        return Err(format!(
685            "curl exited {}",
686            output.status.code().unwrap_or(-1)
687        ));
688    }
689    let text = String::from_utf8_lossy(&output.stdout);
690    let (_, status) = text
691        .rsplit_once('\n')
692        .ok_or_else(|| "curl returned no HTTP status".to_string())?;
693    status
694        .trim()
695        .parse::<u16>()
696        .map_err(|_| "curl returned an invalid HTTP status".to_string())
697}
698
699/// Writes `contents` readable by this user only. The payload names refs
700/// and oids, and it sits beside the operator's state directory.
701fn write_private(path: &Path, contents: &str) -> std::io::Result<()> {
702    std::fs::write(path, contents)?;
703    #[cfg(unix)]
704    {
705        use std::os::unix::fs::PermissionsExt;
706        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
707    }
708    Ok(())
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    fn subscription(pattern: &str) -> Subscription {
716        Subscription {
717            pattern: pattern.to_string(),
718            url: "https://example.invalid/hook".to_string(),
719            secret: "s".to_string(),
720            allow_private: false,
721        }
722    }
723
724    #[test]
725    fn a_trailing_star_is_a_prefix_and_anything_else_is_exact() {
726        let exact = subscription("owner/repo:refs/heads/main");
727        assert!(exact.matches("owner/repo:refs/heads/main"));
728        assert!(!exact.matches("owner/repo:refs/heads/main-2"));
729        assert!(!exact.matches("other/repo:refs/heads/main"));
730
731        let prefix = subscription("owner/repo:refs/heads/*");
732        assert!(prefix.matches("owner/repo:refs/heads/main"));
733        assert!(prefix.matches("owner/repo:refs/heads/feature/x"));
734        assert!(!prefix.matches("owner/repo:refs/tags/v1"));
735        assert!(!prefix.matches("other/repo:refs/heads/main"));
736    }
737
738    #[test]
739    fn a_line_needs_a_pattern_a_url_and_a_secret() {
740        let parsed = parse_line("o/r:refs/heads/main https://example.invalid/h abc123").unwrap();
741        assert_eq!(parsed.url, "https://example.invalid/h");
742        assert_eq!(parsed.secret, "abc123");
743        assert!(!parsed.allow_private);
744
745        assert!(parse_line("o/r:refs/heads/main https://example.invalid/h").is_err());
746        assert!(parse_line("o/r:refs/heads/main ftp://example.invalid/h s").is_err());
747        assert!(parse_line("o/r:x https://example.invalid/h s allow-everything").is_err());
748        assert!(parse_line("o/r:x https://example.invalid/h se\"cret").is_err());
749    }
750
751    #[test]
752    fn allow_private_is_the_only_way_to_reach_an_internal_address() {
753        assert!(
754            parse_line("o/r:x http://localhost:1/h s allow-private")
755                .unwrap()
756                .allow_private
757        );
758        assert!(vet("http://127.0.0.1:9/hook", false).is_err());
759        assert!(vet("http://127.0.0.1:9/hook", true).is_ok());
760    }
761
762    #[test]
763    fn the_metadata_service_and_its_neighbours_are_internal() {
764        // The addresses an SSRF is usually aimed at.
765        for address in [
766            "169.254.169.254", // cloud metadata
767            "127.0.0.1",
768            "10.0.0.1",
769            "192.168.1.1",
770            "172.16.0.1",
771            "100.64.0.1", // carrier-grade NAT
772            "0.0.0.0",
773            "::1",
774            "fe80::1",
775            "fd00::1",
776            "::ffff:127.0.0.1", // loopback wearing an IPv6 hat
777        ] {
778            let addr: IpAddr = address.parse().expect("test address parses");
779            assert!(is_private_address(addr), "{address} should be internal");
780        }
781        for address in ["1.1.1.1", "93.184.216.34", "2606:4700:4700::1111"] {
782            let addr: IpAddr = address.parse().expect("test address parses");
783            assert!(!is_private_address(addr), "{address} should be reachable");
784        }
785    }
786
787    #[test]
788    fn a_non_loopback_target_may_not_carry_the_secret_in_clear() {
789        // Private, allowed by the subscription, and still refused over
790        // http because it is not loopback.
791        let refused = vet("http://10.0.0.1/hook", true).unwrap_err();
792        assert!(refused.contains("https"), "{refused}");
793    }
794
795    #[test]
796    fn a_url_is_split_into_a_host_and_a_port_before_it_is_vetted() {
797        let target = vet("http://127.0.0.1:8080/deep/path?query=1", true).unwrap();
798        assert_eq!(target.host, "127.0.0.1");
799        assert_eq!(target.port, 8080);
800        assert!(!target.https);
801
802        let bracketed = vet("http://[::1]:9000/hook", true).unwrap();
803        assert_eq!(bracketed.host, "::1");
804        assert_eq!(bracketed.port, 9000);
805
806        assert!(vet("https://user:pass@example.invalid/h", false).is_err());
807        assert!(vet("gopher://example.invalid/h", false).is_err());
808    }
809}