Skip to main content

capsec_core/
attenuate.rs

1//! Scope-restricted capabilities via [`Attenuated<P, S>`].
2//!
3//! Attenuation narrows a capability's reach. A `Cap<FsRead>` grants permission to
4//! read any file; an `Attenuated<FsRead, DirScope>` grants permission to read files
5//! only within a specific directory tree.
6//!
7//! # Built-in scopes
8//!
9//! - [`DirScope`] — restricts filesystem operations to a directory subtree
10//! - [`HostScope`] — restricts network operations to a set of allowed hosts
11
12use crate::cap::Cap;
13use crate::error::CapSecError;
14use crate::permission::Permission;
15use std::path::{Path, PathBuf};
16
17/// A restriction that narrows the set of targets a capability can act on.
18///
19/// Implement this trait to define custom scopes. The [`check`](Scope::check) method
20/// returns `Ok(())` if the target is within scope, or an error if not.
21pub trait Scope: 'static {
22    /// Checks whether `target` is within this scope.
23    ///
24    /// Returns `Ok(())` if allowed, `Err(CapSecError::OutOfScope)` if not.
25    fn check(&self, target: &str) -> Result<(), CapSecError>;
26}
27
28/// A capability that has been narrowed to a specific scope.
29///
30/// Created via [`Cap::attenuate`]. The attenuated capability can only act on
31/// targets that pass the scope's [`check`](Scope::check) method.
32///
33/// # Example
34///
35/// ```rust,ignore
36/// # use capsec_core::root::test_root;
37/// # use capsec_core::permission::FsRead;
38/// # use capsec_core::attenuate::DirScope;
39/// let root = test_root();
40/// let scoped = root.grant::<FsRead>().attenuate(DirScope::new("/tmp").unwrap());
41/// assert!(scoped.check("/tmp/data.txt").is_ok());
42/// assert!(scoped.check("/etc/passwd").is_err());
43/// ```
44pub struct Attenuated<P: Permission, S: Scope> {
45    _cap: Cap<P>,
46    scope: S,
47}
48
49impl<P: Permission> Cap<P> {
50    /// Narrows this capability to a specific scope.
51    ///
52    /// Consumes the original `Cap<P>` and returns an `Attenuated<P, S>` that
53    /// can only act on targets within the scope.
54    pub fn attenuate<S: Scope>(self, scope: S) -> Attenuated<P, S> {
55        Attenuated { _cap: self, scope }
56    }
57}
58
59impl<P: Permission, S: Scope> Attenuated<P, S> {
60    /// Checks whether `target` is within this capability's scope.
61    pub fn check(&self, target: &str) -> Result<(), CapSecError> {
62        self.scope.check(target)
63    }
64}
65
66/// Restricts filesystem operations to a directory subtree.
67///
68/// Paths are canonicalized before comparison to prevent `../` traversal attacks.
69/// If the target path cannot be canonicalized (e.g., it doesn't exist yet),
70/// the check fails conservatively.
71///
72/// # Example
73///
74/// ```
75/// # use capsec_core::attenuate::{DirScope, Scope};
76/// let scope = DirScope::new("/tmp").unwrap();
77/// // Note: check will fail if /tmp/data.txt doesn't exist (canonicalization)
78/// ```
79pub struct DirScope {
80    root: PathBuf,
81}
82
83impl DirScope {
84    /// Creates a new directory scope rooted at the given path.
85    ///
86    /// The root path is canonicalized to prevent bypass via symlinks or `..` components.
87    /// Returns an error if the root path does not exist or cannot be resolved.
88    pub fn new(root: impl AsRef<Path>) -> Result<Self, CapSecError> {
89        let canonical = root.as_ref().canonicalize().map_err(CapSecError::Io)?;
90        Ok(Self { root: canonical })
91    }
92}
93
94impl Scope for DirScope {
95    fn check(&self, target: &str) -> Result<(), CapSecError> {
96        let target_path = Path::new(target);
97        let canonical = target_path.canonicalize().map_err(CapSecError::Io)?;
98
99        if canonical.starts_with(&self.root) {
100            Ok(())
101        } else {
102            Err(CapSecError::OutOfScope {
103                target: target.to_string(),
104                scope: self.root.display().to_string(),
105            })
106        }
107    }
108}
109
110/// Restricts network operations to a set of allowed host prefixes.
111///
112/// Targets are matched by string prefix — `"api.example.com"` matches
113/// both `"api.example.com:443"` and `"api.example.com/path"`.
114///
115/// # Example
116///
117/// ```
118/// # use capsec_core::attenuate::{HostScope, Scope};
119/// let scope = HostScope::new(["api.example.com", "cdn.example.com"]);
120/// assert!(scope.check("api.example.com:443").is_ok());
121/// assert!(scope.check("evil.com:8080").is_err());
122/// ```
123pub struct HostScope {
124    allowed: Vec<String>,
125}
126
127impl HostScope {
128    /// Creates a new host scope allowing the given host prefixes.
129    pub fn new(hosts: impl IntoIterator<Item = impl Into<String>>) -> Self {
130        Self {
131            allowed: hosts.into_iter().map(Into::into).collect(),
132        }
133    }
134}
135
136impl Scope for HostScope {
137    fn check(&self, target: &str) -> Result<(), CapSecError> {
138        let matches = self.allowed.iter().any(|h| {
139            if target.starts_with(h.as_str()) {
140                // After the prefix, the next character must be a boundary
141                // (end-of-string, ':', or '/') to prevent "api.example.com.evil.com"
142                // from matching "api.example.com".
143                matches!(target.as_bytes().get(h.len()), None | Some(b':' | b'/'))
144            } else {
145                false
146            }
147        });
148        if matches {
149            Ok(())
150        } else {
151            Err(CapSecError::OutOfScope {
152                target: target.to_string(),
153                scope: format!("allowed hosts: {:?}", self.allowed),
154            })
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn host_scope_allows_matching() {
165        let scope = HostScope::new(["api.example.com", "cdn.example.com"]);
166        assert!(scope.check("api.example.com:443").is_ok());
167        assert!(scope.check("cdn.example.com/resource").is_ok());
168    }
169
170    #[test]
171    fn host_scope_rejects_non_matching() {
172        let scope = HostScope::new(["api.example.com"]);
173        assert!(scope.check("evil.com:8080").is_err());
174    }
175
176    #[test]
177    fn dir_scope_rejects_traversal() {
178        // Create a scope for a real directory
179        let scope = DirScope::new("/tmp").unwrap();
180        // Trying to escape via ../
181        let result = scope.check("/tmp/../etc/passwd");
182        // This should either fail (if /etc/passwd doesn't exist for canonicalization)
183        // or succeed only if the canonical path is under /tmp (which it won't be)
184        if let Ok(()) = result {
185            panic!("Should not allow path traversal outside scope");
186        }
187    }
188
189    #[test]
190    fn attenuated_cap_checks_scope() {
191        let root = crate::root::test_root();
192        let cap = root.grant::<crate::permission::NetConnect>();
193        let scoped = cap.attenuate(HostScope::new(["api.example.com"]));
194        assert!(scoped.check("api.example.com:443").is_ok());
195        assert!(scoped.check("evil.com").is_err());
196    }
197}