Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ida98135cf868db0f5a46a64b8ac562366a6a6964
473 lines
14 KiB
Rust
473 lines
14 KiB
Rust
//! Capability-based security for plugins
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{Result, anyhow};
|
|
use pinakes_plugin_api::Capabilities;
|
|
|
|
/// Capability enforcer validates and enforces plugin capabilities
|
|
pub struct CapabilityEnforcer {
|
|
/// Maximum allowed memory per plugin (bytes)
|
|
max_memory_limit: usize,
|
|
|
|
/// Maximum allowed CPU time per plugin (milliseconds)
|
|
max_cpu_time_limit: u64,
|
|
|
|
/// Allowed filesystem read paths (system-wide)
|
|
allowed_read_paths: Vec<PathBuf>,
|
|
|
|
/// Allowed filesystem write paths (system-wide)
|
|
allowed_write_paths: Vec<PathBuf>,
|
|
|
|
/// Whether to allow network access by default
|
|
allow_network_default: bool,
|
|
}
|
|
|
|
impl CapabilityEnforcer {
|
|
/// Create a new capability enforcer with default limits
|
|
#[must_use]
|
|
pub const fn new() -> Self {
|
|
Self {
|
|
max_memory_limit: 512 * 1024 * 1024, // 512 MB
|
|
max_cpu_time_limit: 60 * 1000, // 60 seconds
|
|
allowed_read_paths: vec![],
|
|
allowed_write_paths: vec![],
|
|
allow_network_default: false,
|
|
}
|
|
}
|
|
|
|
/// Set maximum memory limit
|
|
#[must_use]
|
|
pub const fn with_max_memory(mut self, bytes: usize) -> Self {
|
|
self.max_memory_limit = bytes;
|
|
self
|
|
}
|
|
|
|
/// Set maximum CPU time limit
|
|
#[must_use]
|
|
pub const fn with_max_cpu_time(mut self, milliseconds: u64) -> Self {
|
|
self.max_cpu_time_limit = milliseconds;
|
|
self
|
|
}
|
|
|
|
/// Add allowed read path
|
|
#[must_use]
|
|
pub fn allow_read_path(mut self, path: PathBuf) -> Self {
|
|
self.allowed_read_paths.push(path);
|
|
self
|
|
}
|
|
|
|
/// Add allowed write path
|
|
#[must_use]
|
|
pub fn allow_write_path(mut self, path: PathBuf) -> Self {
|
|
self.allowed_write_paths.push(path);
|
|
self
|
|
}
|
|
|
|
/// Set default network access policy
|
|
#[must_use]
|
|
pub const fn with_network_default(mut self, allow: bool) -> Self {
|
|
self.allow_network_default = allow;
|
|
self
|
|
}
|
|
|
|
/// Validate capabilities requested by a plugin
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if the plugin requests capabilities that exceed the
|
|
/// configured system limits, such as memory, CPU time, filesystem paths, or
|
|
/// network access.
|
|
pub fn validate_capabilities(
|
|
&self,
|
|
capabilities: &Capabilities,
|
|
) -> Result<()> {
|
|
// Validate memory limit
|
|
if let Some(memory) = capabilities.max_memory_bytes
|
|
&& memory > self.max_memory_limit
|
|
{
|
|
return Err(anyhow!(
|
|
"Requested memory ({} bytes) exceeds limit ({} bytes)",
|
|
memory,
|
|
self.max_memory_limit
|
|
));
|
|
}
|
|
|
|
// Validate CPU time limit
|
|
if let Some(cpu_time) = capabilities.max_cpu_time_ms
|
|
&& cpu_time > self.max_cpu_time_limit
|
|
{
|
|
return Err(anyhow!(
|
|
"Requested CPU time ({} ms) exceeds limit ({} ms)",
|
|
cpu_time,
|
|
self.max_cpu_time_limit
|
|
));
|
|
}
|
|
|
|
// Validate filesystem access
|
|
self.validate_filesystem_access(capabilities)?;
|
|
|
|
// Validate network access
|
|
if capabilities.network.enabled && !self.allow_network_default {
|
|
return Err(anyhow!(
|
|
"Plugin requests network access, but network access is disabled by \
|
|
policy"
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate filesystem access capabilities
|
|
fn validate_filesystem_access(
|
|
&self,
|
|
capabilities: &Capabilities,
|
|
) -> Result<()> {
|
|
// Check read paths
|
|
for path in &capabilities.filesystem.read {
|
|
if !self.is_read_allowed(path) {
|
|
return Err(anyhow!(
|
|
"Plugin requests read access to {} which is not in allowed paths",
|
|
path.display()
|
|
));
|
|
}
|
|
}
|
|
|
|
// Check write paths
|
|
for path in &capabilities.filesystem.write {
|
|
if !self.is_write_allowed(path) {
|
|
return Err(anyhow!(
|
|
"Plugin requests write access to {} which is not in allowed paths",
|
|
path.display()
|
|
));
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Check if a path is allowed for reading
|
|
#[must_use]
|
|
pub fn is_read_allowed(&self, path: &Path) -> bool {
|
|
if self.allowed_read_paths.is_empty() {
|
|
return false; // deny-all when unconfigured
|
|
}
|
|
let Ok(canonical) = path.canonicalize() else {
|
|
return false;
|
|
};
|
|
self.allowed_read_paths.iter().any(|allowed| {
|
|
allowed
|
|
.canonicalize()
|
|
.is_ok_and(|a| canonical.starts_with(a))
|
|
})
|
|
}
|
|
|
|
/// Check if a path is allowed for writing
|
|
#[must_use]
|
|
pub fn is_write_allowed(&self, path: &Path) -> bool {
|
|
if self.allowed_write_paths.is_empty() {
|
|
return false; // deny-all when unconfigured
|
|
}
|
|
let canonical = if path.exists() {
|
|
path.canonicalize().ok()
|
|
} else {
|
|
path
|
|
.parent()
|
|
.and_then(|p| p.canonicalize().ok())
|
|
.map(|p| p.join(path.file_name().unwrap_or_default()))
|
|
};
|
|
let Some(canonical) = canonical else {
|
|
return false;
|
|
};
|
|
self.allowed_write_paths.iter().any(|allowed| {
|
|
allowed
|
|
.canonicalize()
|
|
.is_ok_and(|a| canonical.starts_with(a))
|
|
})
|
|
}
|
|
|
|
/// Check if network access is allowed for a plugin
|
|
#[must_use]
|
|
pub const fn is_network_allowed(&self, capabilities: &Capabilities) -> bool {
|
|
capabilities.network.enabled && self.allow_network_default
|
|
}
|
|
|
|
/// Check if a specific domain is allowed
|
|
#[must_use]
|
|
pub fn is_domain_allowed(
|
|
&self,
|
|
capabilities: &Capabilities,
|
|
domain: &str,
|
|
) -> bool {
|
|
if !capabilities.network.enabled {
|
|
return false;
|
|
}
|
|
|
|
// If no domain restrictions, allow all domains
|
|
if capabilities.network.allowed_domains.is_none() {
|
|
return self.allow_network_default;
|
|
}
|
|
|
|
// Check against allowed domains list
|
|
capabilities
|
|
.network
|
|
.allowed_domains
|
|
.as_ref()
|
|
.is_some_and(|domains| {
|
|
domains.iter().any(|d| d.eq_ignore_ascii_case(domain))
|
|
})
|
|
}
|
|
|
|
/// Get effective memory limit for a plugin
|
|
#[must_use]
|
|
pub fn get_memory_limit(&self, capabilities: &Capabilities) -> usize {
|
|
capabilities
|
|
.max_memory_bytes
|
|
.unwrap_or(self.max_memory_limit)
|
|
.min(self.max_memory_limit)
|
|
}
|
|
|
|
/// Get effective CPU time limit for a plugin
|
|
#[must_use]
|
|
pub fn get_cpu_time_limit(&self, capabilities: &Capabilities) -> u64 {
|
|
capabilities
|
|
.max_cpu_time_ms
|
|
.unwrap_or(self.max_cpu_time_limit)
|
|
.min(self.max_cpu_time_limit)
|
|
}
|
|
|
|
/// Validate that a function call is allowed for a plugin's declared kinds.
|
|
///
|
|
/// Defense-in-depth: even though the pipeline filters by kind, this prevents
|
|
/// bugs from calling wrong functions on plugins. Returns `true` if allowed.
|
|
#[must_use]
|
|
pub fn validate_function_call(
|
|
&self,
|
|
plugin_kinds: &[String],
|
|
function_name: &str,
|
|
) -> bool {
|
|
match function_name {
|
|
// Lifecycle functions are always allowed
|
|
"initialize" | "shutdown" | "health_check" => true,
|
|
// MediaTypeProvider
|
|
"supported_media_types" | "can_handle" => {
|
|
plugin_kinds.iter().any(|k| k == "media_type")
|
|
},
|
|
// supported_types is shared by metadata_extractor and thumbnail_generator
|
|
"supported_types" => {
|
|
plugin_kinds
|
|
.iter()
|
|
.any(|k| k == "metadata_extractor" || k == "thumbnail_generator")
|
|
},
|
|
// MetadataExtractor
|
|
"extract_metadata" => {
|
|
plugin_kinds.iter().any(|k| k == "metadata_extractor")
|
|
},
|
|
// ThumbnailGenerator
|
|
"generate_thumbnail" => {
|
|
plugin_kinds.iter().any(|k| k == "thumbnail_generator")
|
|
},
|
|
// SearchBackend
|
|
"search" | "index_item" | "remove_item" | "get_stats" => {
|
|
plugin_kinds.iter().any(|k| k == "search_backend")
|
|
},
|
|
// EventHandler
|
|
"interested_events" | "handle_event" => {
|
|
plugin_kinds.iter().any(|k| k == "event_handler")
|
|
},
|
|
// ThemeProvider
|
|
"get_themes" | "load_theme" => {
|
|
plugin_kinds.iter().any(|k| k == "theme_provider")
|
|
},
|
|
// Unknown function names are not allowed
|
|
_ => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for CapabilityEnforcer {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#[allow(unused_imports)]
|
|
use pinakes_plugin_api::{FilesystemCapability, NetworkCapability};
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_validate_memory_limit() {
|
|
let enforcer = CapabilityEnforcer::new().with_max_memory(100 * 1024 * 1024); // 100 MB
|
|
|
|
let mut caps = Capabilities::default();
|
|
caps.max_memory_bytes = Some(50 * 1024 * 1024); // 50 MB - OK
|
|
assert!(enforcer.validate_capabilities(&caps).is_ok());
|
|
|
|
caps.max_memory_bytes = Some(200 * 1024 * 1024); // 200 MB - exceeds limit
|
|
assert!(enforcer.validate_capabilities(&caps).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_cpu_time_limit() {
|
|
let enforcer = CapabilityEnforcer::new().with_max_cpu_time(30_000); // 30 seconds
|
|
|
|
let mut caps = Capabilities::default();
|
|
caps.max_cpu_time_ms = Some(10_000); // 10 seconds - OK
|
|
assert!(enforcer.validate_capabilities(&caps).is_ok());
|
|
|
|
caps.max_cpu_time_ms = Some(60_000); // 60 seconds - exceeds limit
|
|
assert!(enforcer.validate_capabilities(&caps).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_filesystem_read_allowed() {
|
|
// Use real temp directories so canonicalize works
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let allowed_dir = tmp.path().join("allowed");
|
|
std::fs::create_dir_all(&allowed_dir).unwrap();
|
|
let test_file = allowed_dir.join("test.txt");
|
|
std::fs::write(&test_file, "test").unwrap();
|
|
|
|
let enforcer = CapabilityEnforcer::new().allow_read_path(allowed_dir);
|
|
|
|
assert!(enforcer.is_read_allowed(&test_file));
|
|
assert!(!enforcer.is_read_allowed(Path::new("/etc/passwd")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_filesystem_read_denied_when_empty() {
|
|
let enforcer = CapabilityEnforcer::new();
|
|
assert!(!enforcer.is_read_allowed(Path::new("/tmp/test.txt")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_filesystem_write_allowed() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let output_dir = tmp.path().join("output");
|
|
std::fs::create_dir_all(&output_dir).unwrap();
|
|
// Existing file in allowed dir
|
|
let existing = output_dir.join("file.txt");
|
|
std::fs::write(&existing, "test").unwrap();
|
|
|
|
let enforcer =
|
|
CapabilityEnforcer::new().allow_write_path(output_dir.clone());
|
|
|
|
assert!(enforcer.is_write_allowed(&existing));
|
|
// New file in allowed dir (parent exists)
|
|
assert!(enforcer.is_write_allowed(&output_dir.join("new_file.txt")));
|
|
assert!(!enforcer.is_write_allowed(Path::new("/etc/config")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_filesystem_write_denied_when_empty() {
|
|
let enforcer = CapabilityEnforcer::new();
|
|
assert!(!enforcer.is_write_allowed(Path::new("/tmp/file.txt")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_network_allowed() {
|
|
let enforcer = CapabilityEnforcer::new().with_network_default(true);
|
|
|
|
let mut caps = Capabilities::default();
|
|
caps.network.enabled = true;
|
|
|
|
assert!(enforcer.is_network_allowed(&caps));
|
|
|
|
caps.network.enabled = false;
|
|
assert!(!enforcer.is_network_allowed(&caps));
|
|
}
|
|
|
|
#[test]
|
|
fn test_domain_restrictions() {
|
|
let enforcer = CapabilityEnforcer::new().with_network_default(true);
|
|
|
|
let mut caps = Capabilities::default();
|
|
caps.network.enabled = true;
|
|
caps.network.allowed_domains = Some(vec![
|
|
"api.example.com".to_string(),
|
|
"cdn.example.com".to_string(),
|
|
]);
|
|
|
|
assert!(enforcer.is_domain_allowed(&caps, "api.example.com"));
|
|
assert!(enforcer.is_domain_allowed(&caps, "cdn.example.com"));
|
|
assert!(!enforcer.is_domain_allowed(&caps, "evil.com"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_effective_limits() {
|
|
let enforcer = CapabilityEnforcer::new()
|
|
.with_max_memory(100 * 1024 * 1024)
|
|
.with_max_cpu_time(30_000);
|
|
|
|
let mut caps = Capabilities::default();
|
|
|
|
// No limits specified, use the defaults
|
|
assert_eq!(enforcer.get_memory_limit(&caps), 100 * 1024 * 1024);
|
|
assert_eq!(enforcer.get_cpu_time_limit(&caps), 30_000);
|
|
|
|
// Plugin requests lower limits, use plugin's
|
|
caps.max_memory_bytes = Some(50 * 1024 * 1024);
|
|
caps.max_cpu_time_ms = Some(10_000);
|
|
assert_eq!(enforcer.get_memory_limit(&caps), 50 * 1024 * 1024);
|
|
assert_eq!(enforcer.get_cpu_time_limit(&caps), 10_000);
|
|
|
|
// Plugin requests higher limits, cap at system max
|
|
caps.max_memory_bytes = Some(200 * 1024 * 1024);
|
|
caps.max_cpu_time_ms = Some(60_000);
|
|
assert_eq!(enforcer.get_memory_limit(&caps), 100 * 1024 * 1024);
|
|
assert_eq!(enforcer.get_cpu_time_limit(&caps), 30_000);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_function_call_lifecycle_always_allowed() {
|
|
let enforcer = CapabilityEnforcer::new();
|
|
let kinds = vec!["metadata_extractor".to_string()];
|
|
assert!(enforcer.validate_function_call(&kinds, "initialize"));
|
|
assert!(enforcer.validate_function_call(&kinds, "shutdown"));
|
|
assert!(enforcer.validate_function_call(&kinds, "health_check"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_function_call_metadata_extractor() {
|
|
let enforcer = CapabilityEnforcer::new();
|
|
let kinds = vec!["metadata_extractor".to_string()];
|
|
assert!(enforcer.validate_function_call(&kinds, "extract_metadata"));
|
|
assert!(enforcer.validate_function_call(&kinds, "supported_types"));
|
|
assert!(!enforcer.validate_function_call(&kinds, "search"));
|
|
assert!(!enforcer.validate_function_call(&kinds, "generate_thumbnail"));
|
|
assert!(!enforcer.validate_function_call(&kinds, "can_handle"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_function_call_multi_kind() {
|
|
let enforcer = CapabilityEnforcer::new();
|
|
let kinds =
|
|
vec!["media_type".to_string(), "metadata_extractor".to_string()];
|
|
assert!(enforcer.validate_function_call(&kinds, "can_handle"));
|
|
assert!(enforcer.validate_function_call(&kinds, "supported_media_types"));
|
|
assert!(enforcer.validate_function_call(&kinds, "extract_metadata"));
|
|
assert!(!enforcer.validate_function_call(&kinds, "search"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_function_call_unknown_function() {
|
|
let enforcer = CapabilityEnforcer::new();
|
|
let kinds = vec!["metadata_extractor".to_string()];
|
|
assert!(!enforcer.validate_function_call(&kinds, "unknown_func"));
|
|
assert!(!enforcer.validate_function_call(&kinds, ""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_function_call_shared_supported_types() {
|
|
let enforcer = CapabilityEnforcer::new();
|
|
let extractor = vec!["metadata_extractor".to_string()];
|
|
let generator = vec!["thumbnail_generator".to_string()];
|
|
let search = vec!["search_backend".to_string()];
|
|
assert!(enforcer.validate_function_call(&extractor, "supported_types"));
|
|
assert!(enforcer.validate_function_call(&generator, "supported_types"));
|
|
assert!(!enforcer.validate_function_call(&search, "supported_types"));
|
|
}
|
|
}
|