pinakes/crates/pinakes-core/src/plugin/security.rs
NotAShelf 4edda201e6
pinakes-core: add plugin pipeline; impl signature verification & dependency resolution
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ida98135cf868db0f5a46a64b8ac562366a6a6964
2026-03-08 15:16:58 +03:00

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"));
}
}