Proof of concept mechanical port of ircnet/ircd to Rust as part of a bit about C being insecure for network services
0

Configure Feed

Select the types of activity you want to include in your feed.

ircd.rs / leveva-iauth / src / pipe.rs
9.8 kB 251 lines
1//! The `pipe` authentication module — idiomatic rewrite of the faithful `iauth-rs` 2//! port of `mod_pipe.c`. The **last** denying module. 3//! 4//! Spawns an operator-configured external program, passing the connecting client's IP and 5//! source port as the final two argv entries, and reads a single verdict byte from its 6//! stdout: 7//! 8//! * `'Y'` — pass (no opinion: leveva has no "explicitly approve" outcome, so this leaves 9//! the running verdict untouched, which means *allow*); 10//! * `'N'` — refuse the connection (`Deny`), folded into the same `465` admission path the 11//! dnsbl/socks/webproxy modules use; 12//! * anything else (an unexpected byte, no output, or a spawn/read failure) — no opinion, 13//! matching the C module, which only ever denies on an explicit `N`. 14//! 15//! ## Security 16//! 17//! Spawning a subprocess is the one genuinely dangerous thing an auth module does, so the 18//! handling is deliberately conservative: 19//! 20//! - **No shell.** The program is run via [`tokio::process::Command`] with an explicit 21//! argv; `itsip`/`itsport` are passed as *separate arguments*, never interpolated into a 22//! shell string — there is no command-injection vector. The two client-derived values 23//! are a validated IP string and a `u16`. 24//! - The command line comes **only from the trusted server config** (operator-controlled), 25//! never from any client. 26//! - `stdin` is closed and `kill_on_drop(true)` is set: when a probe exceeds the 27//! pipeline's per-module timeout its future is dropped, and the child is reaped rather 28//! than leaked (an improvement over the C module, which had no such bound). 29//! 30//! ## Divergence vs [`crate::socks`] / [`crate::webproxy`] 31//! 32//! `pipe` has **no probe-only mode** in C either — its whole purpose is the Y/N verdict — 33//! so unlike the proxy probes there is no `reject` flag. The required config field is the 34//! `program` to run (mirroring the C `init` "nothing to be done" error when `prog=` is 35//! absent). 36 37use async_trait::async_trait; 38use std::process::Stdio; 39use tokio::io::AsyncReadExt; 40use tokio::process::Command; 41 42use crate::module::{Module, ModuleCtx, ModuleOutcome}; 43 44/// The default refusal reason when the config gives none (matches the `465` gate default). 45const DEFAULT_REASON: &str = "Denied access"; 46 47/// The verdict a single byte of program output maps to. 48#[derive(Clone, Copy, PartialEq, Eq, Debug)] 49pub enum Verdict { 50 /// `'Y'` — let the connection through. 51 Allow, 52 /// `'N'` — refuse the connection. 53 Deny, 54 /// Any other byte, no output, or a spawn/read failure — no opinion. 55 Unknown, 56} 57 58/// Map one byte of program output (or its absence) to a [`Verdict`]. The whole decision 59/// surface, kept pure so it is exhaustively unit/fuzz-testable without spawning anything. 60/// Byte-for-byte the C `mod_pipe` rule (`'Y'` pass / `'N'` kill / else nothing). 61pub fn classify(byte: Option<u8>) -> Verdict { 62 match byte { 63 Some(b'Y') => Verdict::Allow, 64 Some(b'N') => Verdict::Deny, 65 _ => Verdict::Unknown, 66 } 67} 68 69/// Split a `program` command line into `(executable, args)` with the client's `itsip` 70/// and `itsport` appended as the final two arguments, in that order. Returns `None` for an 71/// empty/whitespace-only program (nothing to run). The whole argv is built here — kept 72/// pure so the security-relevant claim (the client facts are *separate arguments*, never 73/// interpolated into a shell line) is exhaustively testable without spawning. The 74/// whitespace split matches the C `execlp` handling (a `program` may carry fixed leading 75/// args). 76pub fn build_argv(program: &str, itsip: &str, itsport: u16) -> Option<(String, Vec<String>)> { 77 let mut parts = program.split_whitespace(); 78 let exe = parts.next()?; 79 let mut args: Vec<String> = parts.map(str::to_string).collect(); 80 args.push(itsip.to_string()); 81 args.push(itsport.to_string()); 82 Some((exe.to_string(), args)) 83} 84 85/// One external-program authentication check. 86pub struct Pipe { 87 /// The program command line (config `program`). The first whitespace token is the 88 /// executable; any remaining tokens are fixed leading args (the C `execlp` splits the 89 /// same way). `itsip` and `itsport` are appended as the final two args. 90 program: String, 91 /// The refusal reason rendered to a denied client (empty → [`DEFAULT_REASON`]). 92 reason: String, 93} 94 95impl Pipe { 96 /// Build a pipe module from its resolved config. 97 pub fn new(program: String, reason: String) -> Self { 98 Pipe { program, reason } 99 } 100 101 /// The deny outcome with the configured reason (defaulting like the C module). 102 fn deny(&self) -> ModuleOutcome { 103 let reason = if self.reason.is_empty() { 104 DEFAULT_REASON.to_string() 105 } else { 106 self.reason.clone() 107 }; 108 ModuleOutcome::Deny { reason } 109 } 110 111 /// Spawn the program, append `itsip`/`itsport`, and classify the first stdout byte. 112 /// Any failure (empty `program`, spawn error, EOF) resolves to [`Verdict::Unknown`] — 113 /// the C `-1`/no-deny path. 114 async fn probe(&self, itsip: &str, itsport: u16) -> Verdict { 115 let (exe, args) = match build_argv(&self.program, itsip, itsport) { 116 Some(parsed) => parsed, 117 None => return Verdict::Unknown, // empty program → nothing to run 118 }; 119 let mut cmd = Command::new(exe); 120 cmd.args(args); 121 cmd.stdin(Stdio::null()); 122 cmd.stdout(Stdio::piped()); 123 cmd.kill_on_drop(true); // a timed-out probe's child is reaped, not leaked 124 125 let mut child = match cmd.spawn() { 126 Ok(c) => c, 127 Err(_) => return Verdict::Unknown, // C: pipe/fork failure ⇒ -1, no deny 128 }; 129 let mut first = [0u8; 1]; 130 let n = match child.stdout.as_mut() { 131 Some(out) => out.read(&mut first).await.unwrap_or(0), 132 None => 0, 133 }; 134 let _ = child.wait().await; 135 classify((n == 1).then_some(first[0])) 136 } 137} 138 139#[async_trait] 140impl Module for Pipe { 141 fn name(&self) -> &'static str { 142 "pipe" 143 } 144 145 async fn run(&self, ctx: &ModuleCtx) -> ModuleOutcome { 146 match self.probe(&ctx.itsip, ctx.itsport).await { 147 Verdict::Deny => self.deny(), 148 Verdict::Allow | Verdict::Unknown => ModuleOutcome::NoOpinion, 149 } 150 } 151} 152 153#[cfg(test)] 154mod tests { 155 use super::*; 156 157 fn ctx() -> ModuleCtx { 158 ModuleCtx::new("1.2.3.4", 6, "9.9.9.9", 6667) 159 } 160 161 // ---- pure verdict map -------------------------------------------------- 162 163 #[test] 164 fn classify_matrix() { 165 assert_eq!(classify(Some(b'Y')), Verdict::Allow); 166 assert_eq!(classify(Some(b'N')), Verdict::Deny); 167 assert_eq!(classify(Some(b'y')), Verdict::Unknown); // case-sensitive, like C 168 assert_eq!(classify(Some(b'Z')), Verdict::Unknown); 169 assert_eq!(classify(Some(0)), Verdict::Unknown); 170 assert_eq!(classify(None), Verdict::Unknown); // no output 171 } 172 173 // ---- spawning ---------------------------------------------------------- 174 175 #[tokio::test] 176 async fn yes_passes_with_no_opinion() { 177 let m = Pipe::new("/bin/echo Y".into(), "go away".into()); 178 assert_eq!(m.run(&ctx()).await, ModuleOutcome::NoOpinion); 179 } 180 181 #[tokio::test] 182 async fn no_denies_with_the_reason() { 183 let m = Pipe::new("/bin/echo N".into(), "go away".into()); 184 assert_eq!( 185 m.run(&ctx()).await, 186 ModuleOutcome::Deny { 187 reason: "go away".into() 188 } 189 ); 190 } 191 192 #[tokio::test] 193 async fn unexpected_byte_is_no_opinion() { 194 let m = Pipe::new("/bin/echo Z".into(), "go away".into()); 195 assert_eq!(m.run(&ctx()).await, ModuleOutcome::NoOpinion); 196 } 197 198 #[tokio::test] 199 async fn no_output_is_no_opinion() { 200 // /bin/true exits 0 with no stdout → EOF before a byte → Unknown. 201 let m = Pipe::new("/bin/true".into(), "go away".into()); 202 assert_eq!(m.run(&ctx()).await, ModuleOutcome::NoOpinion); 203 } 204 205 #[tokio::test] 206 async fn spawn_failure_is_no_opinion() { 207 let m = Pipe::new("/no/such/program/leveva".into(), "go away".into()); 208 assert_eq!(m.run(&ctx()).await, ModuleOutcome::NoOpinion); 209 } 210 211 #[tokio::test] 212 async fn empty_program_is_no_opinion() { 213 let m = Pipe::new(" ".into(), "go away".into()); 214 assert_eq!(m.run(&ctx()).await, ModuleOutcome::NoOpinion); 215 } 216 217 #[tokio::test] 218 async fn empty_reason_falls_back_to_the_default() { 219 let m = Pipe::new("/bin/echo N".into(), String::new()); 220 assert_eq!( 221 m.run(&ctx()).await, 222 ModuleOutcome::Deny { 223 reason: DEFAULT_REASON.into() 224 } 225 ); 226 } 227 228 // ---- argv composition (the load-bearing security claim) ---------------- 229 230 /// `itsip` and `itsport` are appended as the final two argv entries, in order, after 231 /// any fixed leading args — and they are *separate arguments*, never a shell line. 232 #[test] 233 fn build_argv_appends_itsip_then_port() { 234 let (exe, args) = build_argv("/usr/local/bin/check --flag --x", "1.2.3.4", 6).unwrap(); 235 assert_eq!(exe, "/usr/local/bin/check"); 236 assert_eq!(args, vec!["--flag", "--x", "1.2.3.4", "6"]); 237 } 238 239 #[test] 240 fn build_argv_bare_program_just_gets_the_two_facts() { 241 let (exe, args) = build_argv("/bin/checkproxy", "5.6.7.8", 6667).unwrap(); 242 assert_eq!(exe, "/bin/checkproxy"); 243 assert_eq!(args, vec!["5.6.7.8", "6667"]); 244 } 245 246 #[test] 247 fn build_argv_empty_program_is_none() { 248 assert!(build_argv("", "1.2.3.4", 6).is_none()); 249 assert!(build_argv(" \t ", "1.2.3.4", 6).is_none()); 250 } 251}