Proof of concept mechanical port of ircnet/ircd to Rust as part of a bit about C being insecure for network services
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}