Skip to main content

prick_exec/
guard.rs

1//! Refusing to inject variables that grant code execution in the child.
2//!
3//! `prk run -- <cmd>` puts secret values into a child's environment. A handful
4//! of variable names are read by the dynamic loader or a language runtime
5//! *before* the program's own first instruction, so whoever controls their
6//! value controls what the program does. `LD_PRELOAD` is the canonical example.
7//!
8//! That turns a compromised or hostile server into arbitrary code execution on
9//! every machine that runs `prk run`. The server is not in the trust boundary
10//! for this: it stores secrets, it does not get to choose what code runs.
11//!
12//! So those names are **refused by default** and require `--allow-unsafe-env`.
13//! The classification itself lives in [`prick_core::keyname`]; this module is
14//! the policy that consumes it.
15
16use prick_core::keyname;
17
18/// A rejected injection.
19#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
20#[non_exhaustive]
21pub enum GuardError {
22    /// A secret's name is one the loader or a runtime interprets.
23    #[error(
24        "refusing to set `{name}` in the child environment: it is interpreted before the \
25         program starts, so its value controls what code runs. Pass --allow-unsafe-env to \
26         override."
27    )]
28    LoaderControlled {
29        /// The refused variable name.
30        name: String,
31    },
32}
33
34/// The policy applied to a set of secrets before they reach a child.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub struct EnvGuard {
37    allow_unsafe: bool,
38}
39
40impl EnvGuard {
41    /// The default policy: loader-controlling names are refused.
42    pub fn strict() -> Self {
43        Self { allow_unsafe: false }
44    }
45
46    /// The policy `--allow-unsafe-env` selects.
47    pub fn permissive() -> Self {
48        Self { allow_unsafe: true }
49    }
50
51    /// Checks a single name against the policy.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`GuardError::LoaderControlled`] for a name the loader or a
56    /// language runtime interprets, unless the guard is permissive.
57    pub fn check(self, name: &str) -> Result<(), GuardError> {
58        if !self.allow_unsafe && keyname::is_loader_controlled(name) {
59            return Err(GuardError::LoaderControlled { name: name.to_owned() });
60        }
61        Ok(())
62    }
63
64    /// Checks every name, failing on the first refusal.
65    ///
66    /// Fails the whole launch rather than dropping the offending variable: a
67    /// child started with a silently missing variable is a debugging problem,
68    /// and a child started with a silently *present* one is a breach.
69    ///
70    /// # Errors
71    ///
72    /// See [`EnvGuard::check`].
73    pub fn check_all<'a>(self, names: impl IntoIterator<Item = &'a str>) -> Result<(), GuardError> {
74        for name in names {
75            self.check(name)?;
76        }
77        Ok(())
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn the_default_policy_is_strict() {
87        assert_eq!(EnvGuard::default(), EnvGuard::strict());
88    }
89
90    #[test]
91    fn loader_controlled_names_are_refused_by_default() {
92        let guard = EnvGuard::strict();
93        for name in ["LD_PRELOAD", "DYLD_INSERT_LIBRARIES", "PATH", "NODE_OPTIONS", "BASH_ENV"] {
94            assert_eq!(
95                guard.check(name),
96                Err(GuardError::LoaderControlled { name: name.to_owned() }),
97                "{name} was not refused"
98            );
99        }
100    }
101
102    #[test]
103    fn ordinary_names_pass() {
104        let guard = EnvGuard::strict();
105        for name in ["DATABASE_URL", "API_KEY", "STRIPE_SECRET"] {
106            assert_eq!(guard.check(name), Ok(()), "{name} was wrongly refused");
107        }
108    }
109
110    #[test]
111    fn the_opt_in_allows_everything() {
112        let guard = EnvGuard::permissive();
113        assert_eq!(guard.check("LD_PRELOAD"), Ok(()));
114        assert_eq!(guard.check("DATABASE_URL"), Ok(()));
115    }
116
117    #[test]
118    fn a_single_refusal_fails_the_whole_set() {
119        let guard = EnvGuard::strict();
120        let names = ["SAFE_ONE", "LD_PRELOAD", "SAFE_TWO"];
121        assert_eq!(
122            guard.check_all(names),
123            Err(GuardError::LoaderControlled { name: "LD_PRELOAD".to_owned() })
124        );
125        assert_eq!(guard.check_all(["SAFE_ONE", "SAFE_TWO"]), Ok(()));
126    }
127
128    #[test]
129    fn the_refusal_message_names_the_override() {
130        let err = EnvGuard::strict().check("LD_PRELOAD").unwrap_err();
131        let message = err.to_string();
132        assert!(message.contains("LD_PRELOAD"));
133        assert!(message.contains("--allow-unsafe-env"));
134    }
135}