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}