use crate::cap::Cap;
use crate::error::CapSecError;
use crate::permission::Permission;
use std::path::{Path, PathBuf};
pub trait Scope: 'static {
fn check(&self, target: &str) -> Result<(), CapSecError>;
}
pub struct Attenuated<P: Permission, S: Scope> {
_cap: Cap<P>,
scope: S,
}
impl<P: Permission> Cap<P> {
pub fn attenuate<S: Scope>(self, scope: S) -> Attenuated<P, S> {
Attenuated { _cap: self, scope }
}
}
impl<P: Permission, S: Scope> Attenuated<P, S> {
#[must_use = "ignoring a scope check silently discards scope violations"]
pub fn check(&self, target: &str) -> Result<(), CapSecError> {
self.scope.check(target)
}
}
impl<P: Permission, S: Scope> crate::cap_provider::CapProvider<P> for Attenuated<P, S> {
fn provide_cap(&self, target: &str) -> Result<Cap<P>, CapSecError> {
self.check(target)?;
Ok(Cap::new())
}
}
pub struct DirScope {
root: PathBuf,
}
impl DirScope {
pub fn new(root: impl AsRef<Path>) -> Result<Self, CapSecError> {
let canonical = root.as_ref().canonicalize().map_err(CapSecError::Io)?;
Ok(Self { root: canonical })
}
}
impl Scope for DirScope {
fn check(&self, target: &str) -> Result<(), CapSecError> {
let target_path = Path::new(target);
let canonical = target_path.canonicalize().map_err(CapSecError::Io)?;
if canonical.starts_with(&self.root) {
Ok(())
} else {
Err(CapSecError::OutOfScope {
target: target.to_string(),
scope: self.root.display().to_string(),
})
}
}
}
pub struct HostScope {
allowed: Vec<String>,
}
impl HostScope {
pub fn new(hosts: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
allowed: hosts.into_iter().map(Into::into).collect(),
}
}
}
impl Scope for HostScope {
fn check(&self, target: &str) -> Result<(), CapSecError> {
let matches = self.allowed.iter().any(|h| {
if target.starts_with(h.as_str()) {
matches!(target.as_bytes().get(h.len()), None | Some(b':' | b'/'))
} else {
false
}
});
if matches {
Ok(())
} else {
Err(CapSecError::OutOfScope {
target: target.to_string(),
scope: format!("allowed hosts: {:?}", self.allowed),
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_scope_allows_matching() {
let scope = HostScope::new(["api.example.com", "cdn.example.com"]);
assert!(scope.check("api.example.com:443").is_ok());
assert!(scope.check("cdn.example.com/resource").is_ok());
}
#[test]
fn host_scope_rejects_non_matching() {
let scope = HostScope::new(["api.example.com"]);
assert!(scope.check("evil.com:8080").is_err());
}
#[test]
fn host_scope_rejects_domain_confusion() {
let scope = HostScope::new(["api.example.com"]);
assert!(scope.check("api.example.com.evil.com").is_err());
assert!(scope.check("api.example.com").is_ok());
assert!(scope.check("api.example.com:443").is_ok());
assert!(scope.check("api.example.com/path").is_ok());
}
#[test]
fn dir_scope_rejects_traversal() {
let scope = DirScope::new("/tmp").unwrap();
let result = scope.check("/tmp/../etc/passwd");
if let Ok(()) = result {
panic!("Should not allow path traversal outside scope");
}
}
#[test]
fn attenuated_cap_checks_scope() {
let root = crate::root::test_root();
let cap = root.grant::<crate::permission::NetConnect>();
let scoped = cap.attenuate(HostScope::new(["api.example.com"]));
assert!(scoped.check("api.example.com:443").is_ok());
assert!(scoped.check("evil.com").is_err());
}
}