Skip to main content

cuttlefish_host/
caps.rs

1//! What a job is permitted to reach.
2//!
3//! This is the security boundary. The compile-time check in `cuttlefish-core`
4//! exists to give spec authors good error messages; the check *here* is the one
5//! a malicious or malfunctioning block actually runs into, and it fails closed.
6
7use std::path::{Path, PathBuf};
8
9/// The capabilities a spec grants a job.
10///
11/// v1 has exactly one kind, filesystem reads under named roots. An empty set
12/// grants nothing — deny-by-default is the whole posture, so "no capabilities
13/// declared" must never read as "unrestricted".
14#[derive(Debug, Clone, Default)]
15pub struct Capabilities {
16    read_roots: Vec<PathBuf>,
17    fetch_prefixes: Vec<String>,
18}
19
20impl Capabilities {
21    /// Grant read access beneath each of `read_roots`, and nothing else.
22    pub fn new(read_roots: Vec<PathBuf>) -> Self {
23        Self {
24            read_roots,
25            fetch_prefixes: Vec::new(),
26        }
27    }
28
29    /// Also grant fetching any URL beginning with one of `fetch_prefixes`.
30    pub fn with_fetch(mut self, fetch_prefixes: Vec<String>) -> Self {
31        self.fetch_prefixes = fetch_prefixes;
32        self
33    }
34
35    /// Whether `url` may be fetched.
36    ///
37    /// Prefix matching on the URL as written, deliberately: it is the rule a
38    /// spec author can predict from reading their own capability line. No
39    /// normalisation, no host-only matching — `Fetch "https://x.org/docs/"`
40    /// grants that subtree and not `https://x.org/other`, and not
41    /// `http://` either, since the scheme is part of the prefix.
42    ///
43    /// The one thing checked beyond the prefix is that the URL cannot climb
44    /// out with `..`, which would otherwise let a granted prefix reach
45    /// anywhere on the host.
46    pub fn allows_fetch(&self, url: &str) -> bool {
47        if url.contains("..") {
48            return false;
49        }
50        self.fetch_prefixes.iter().any(|p| url.starts_with(p))
51    }
52
53    /// The granted fetch prefixes, as configured.
54    pub fn fetch_prefixes(&self) -> &[String] {
55        &self.fetch_prefixes
56    }
57
58    /// Whether `path` may be read.
59    ///
60    /// Both sides are canonicalized before comparison, and that is the entire
61    /// substance of this function. The tempting implementation —
62    /// `path.starts_with(root)` on the raw strings — admits two escapes:
63    ///
64    /// - **Traversal.** `/granted/inner/../../secret` has `/granted/inner` as a
65    ///   string prefix while naming a file outside it.
66    /// - **Symlinks.** A path genuinely under the granted root can name a file
67    ///   anywhere on the system.
68    ///
69    /// `canonicalize` resolves `..` and follows symlinks, so the comparison is
70    /// between the real locations rather than the spellings. Comparing with
71    /// `Path::starts_with` rather than string prefixes additionally means
72    /// `/data-secret` is not treated as nested inside `/data`.
73    ///
74    /// A path that cannot be canonicalized — because it does not exist — is
75    /// denied. That is deliberate on two counts: a nonexistent path cannot be
76    /// *proven* inside the grant, and refusing it here means the decision cannot
77    /// be made against a file that only appears afterwards.
78    ///
79    /// Note the residual limitation: this resolves the path once, and the caller
80    /// opens it separately. A sufficiently determined attacker who can swap a
81    /// symlink between those two steps still has a window. Closing it properly
82    /// needs the caller to pass an already-open descriptor; see the transport
83    /// discussion in the `cuttlefishd` crate docs.
84    pub fn allows_read(&self, path: &Path) -> bool {
85        let Ok(target) = path.canonicalize() else {
86            return false;
87        };
88        self.read_roots.iter().any(|root| {
89            root.canonicalize()
90                .map(|root| target.starts_with(root))
91                .unwrap_or(false)
92        })
93    }
94
95    /// The granted read roots, as configured.
96    pub fn read_roots(&self) -> &[PathBuf] {
97        &self.read_roots
98    }
99}