various: simplify code; work on security and performance
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I9a5114addcab5fbff430ab2b919b83466a6a6964
This commit is contained in:
parent
016841b200
commit
c4adc4e3e0
74 changed files with 12714 additions and 151 deletions
341
crates/pinakes-core/src/plugin/security.rs
Normal file
341
crates/pinakes-core/src/plugin/security.rs
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
//! Capability-based security for plugins
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use pinakes_plugin_api::Capabilities;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// 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
|
||||
pub 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
|
||||
pub fn with_max_memory(mut self, bytes: usize) -> Self {
|
||||
self.max_memory_limit = bytes;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set maximum CPU time limit
|
||||
pub fn with_max_cpu_time(mut self, milliseconds: u64) -> Self {
|
||||
self.max_cpu_time_limit = milliseconds;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add allowed read path
|
||||
pub fn allow_read_path(mut self, path: PathBuf) -> Self {
|
||||
self.allowed_read_paths.push(path);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add allowed write path
|
||||
pub fn allow_write_path(mut self, path: PathBuf) -> Self {
|
||||
self.allowed_write_paths.push(path);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set default network access policy
|
||||
pub fn with_network_default(mut self, allow: bool) -> Self {
|
||||
self.allow_network_default = allow;
|
||||
self
|
||||
}
|
||||
|
||||
/// Validate capabilities requested by a plugin
|
||||
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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a path is allowed for reading
|
||||
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
|
||||
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
|
||||
pub fn is_network_allowed(&self, capabilities: &Capabilities) -> bool {
|
||||
capabilities.network.enabled && self.allow_network_default
|
||||
}
|
||||
|
||||
/// Check if a specific domain is allowed
|
||||
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()
|
||||
.map(|domains| domains.iter().any(|d| d.eq_ignore_ascii_case(domain)))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get effective memory limit for a plugin
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CapabilityEnforcer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[allow(unused_imports)]
|
||||
use pinakes_plugin_api::{FilesystemCapability, NetworkCapability};
|
||||
|
||||
#[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.clone());
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue