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    #[must_use = "ignoring a scope check silently discards scope violations"]
62    pub fn check(&self, target: &str) -> Result<(), CapSecError> {
63        self.scope.check(target)
64    }
65}
66
67// Note: Attenuated<P, S> does NOT implement Has<P> (to prevent scope bypass),
68// so the blanket CapProvider<P> impl for T: Has<P> does not apply.
69// Instead, Attenuated implements CapProvider<P> directly with scope enforcement.
70//
71// This is sound because Rust's coherence rules guarantee no overlap:
72// Attenuated never implements Has<P>, so the blanket impl cannot cover it.
73// We use a negative-impl-style guarantee by never adding Has<P> for Attenuated.
74impl<P: Permission, S: Scope> crate::cap_provider::CapProvider<P> for Attenuated<P, S> {
75    fn provide_cap(&self, target: &str) -> Result<Cap<P>, CapSecError> {
76        self.check(target)?;
77        Ok(Cap::new())
78    }
79}
80
81/// Restricts filesystem operations to a directory subtree.
82///
83/// Paths are canonicalized before comparison to prevent `../` traversal attacks.
84/// If the target path cannot be canonicalized (e.g., it doesn't exist yet),
85/// the check fails conservatively.
86///
87/// # Example
88///
89/// ```
90/// # use capsec_core::attenuate::{DirScope, Scope};
91/// let scope = DirScope::new("/tmp").unwrap();
92/// // Note: check will fail if /tmp/data.txt doesn't exist (canonicalization)
93/// ```
94pub struct DirScope {
95    root: PathBuf,
96}
97
98impl DirScope {
99    /// Creates a new directory scope rooted at the given path.
100    ///
101    /// The root path is canonicalized to prevent bypass via symlinks or `..` components.
102    /// Returns an error if the root path does not exist or cannot be resolved.
103    pub fn new(root: impl AsRef<Path>) -> Result<Self, CapSecError> {
104        let canonical = root.as_ref().canonicalize().map_err(CapSecError::Io)?;
105        Ok(Self { root: canonical })
106    }
107}
108
109impl Scope for DirScope {
110    fn check(&self, target: &str) -> Result<(), CapSecError> {
111        let target_path = Path::new(target);
112        let canonical = target_path.canonicalize().map_err(CapSecError::Io)?;
113
114        if canonical.starts_with(&self.root) {
115            Ok(())
116        } else {
117            Err(CapSecError::OutOfScope {
118                target: target.to_string(),
119                scope: self.root.display().to_string(),
120            })
121        }
122    }
123}
124
125/// Restricts network operations to a set of allowed host prefixes.
126///
127/// Targets are matched by string prefix — `"api.example.com"` matches
128/// both `"api.example.com:443"` and `"api.example.com/path"`.
129///
130/// # Example
131///
132/// ```
133/// # use capsec_core::attenuate::{HostScope, Scope};
134/// let scope = HostScope::new(["api.example.com", "cdn.example.com"]);
135/// assert!(scope.check("api.example.com:443").is_ok());
136/// assert!(scope.check("evil.com:8080").is_err());
137/// ```
138pub struct HostScope {
139    allowed: Vec<String>,
140}
141
142impl HostScope {
143    /// Creates a new host scope allowing the given host prefixes.
144    pub fn new(hosts: impl IntoIterator<Item = impl Into<String>>) -> Self {
145        Self {
146            allowed: hosts.into_iter().map(Into::into).collect(),
147        }
148    }
149}
150
151impl Scope for HostScope {
152    fn check(&self, target: &str) -> Result<(), CapSecError> {
153        let matches = self.allowed.iter().any(|h| {
154            if target.starts_with(h.as_str()) {
155                // After the prefix, the next character must be a boundary
156                // (end-of-string, ':', or '/') to prevent "api.example.com.evil.com"
157                // from matching "api.example.com".
158                matches!(target.as_bytes().get(h.len()), None | Some(b':' | b'/'))
159            } else {
160                false
161            }
162        });
163        if matches {
164            Ok(())
165        } else {
166            Err(CapSecError::OutOfScope {
167                target: target.to_string(),
168                scope: format!("allowed hosts: {:?}", self.allowed),
169            })
170        }
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn host_scope_allows_matching() {
180        let scope = HostScope::new(["api.example.com", "cdn.example.com"]);
181        assert!(scope.check("api.example.com:443").is_ok());
182        assert!(scope.check("cdn.example.com/resource").is_ok());
183    }
184
185    #[test]
186    fn host_scope_rejects_non_matching() {
187        let scope = HostScope::new(["api.example.com"]);
188        assert!(scope.check("evil.com:8080").is_err());
189    }
190
191    #[test]
192    fn host_scope_rejects_domain_confusion() {
193        let scope = HostScope::new(["api.example.com"]);
194        // "api.example.com.evil.com" must NOT match "api.example.com"
195        assert!(scope.check("api.example.com.evil.com").is_err());
196        // But exact match and valid suffixes must still work
197        assert!(scope.check("api.example.com").is_ok());
198        assert!(scope.check("api.example.com:443").is_ok());
199        assert!(scope.check("api.example.com/path").is_ok());
200    }
201
202    #[test]
203    fn dir_scope_rejects_traversal() {
204        // Create a scope for a real directory
205        let scope = DirScope::new("/tmp").unwrap();
206        // Trying to escape via ../
207        let result = scope.check("/tmp/../etc/passwd");
208        // This should either fail (if /etc/passwd doesn't exist for canonicalization)
209        // or succeed only if the canonical path is under /tmp (which it won't be)
210        if let Ok(()) = result {
211            panic!("Should not allow path traversal outside scope");
212        }
213    }
214
215    #[test]
216    fn attenuated_cap_checks_scope() {
217        let root = crate::root::test_root();
218        let cap = root.grant::<crate::permission::NetConnect>();
219        let scoped = cap.attenuate(HostScope::new(["api.example.com"]));
220        assert!(scoped.check("api.example.com:443").is_ok());
221        assert!(scoped.check("evil.com").is_err());
222    }
223}