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}