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    /// Why a read was refused, when it was.
96    ///
97    /// [`Self::allows_read`] answers yes-or-no, which is the right shape for
98    /// the decision and the wrong shape for the message. Two very different
99    /// situations reach it as `false`: a path the spec never granted, and a
100    /// granted path that simply is not there. Reporting both as "read not
101    /// permitted" sends somebody to re-read their `capabilities` line when
102    /// what they actually have is a typo in a filename or a manifest listing
103    /// a file that was moved.
104    ///
105    /// Saying "does not exist" is only safe where the job could have found
106    /// out anyway. So it is said only when the nearest *existing* ancestor of
107    /// the path is itself inside a granted root — a directory the job may
108    /// already open and read. For anything else the answer stays the
109    /// undifferentiated refusal, because distinguishing "absent" from
110    /// "forbidden" outside the grant is exactly the probe a capability list
111    /// exists to prevent.
112    pub fn read_denial(&self, path: &Path) -> Option<ReadDenial> {
113        if self.allows_read(path) {
114            return None;
115        }
116
117        // Something is genuinely there — a symlink inside the grant pointing
118        // out of it, say. It was refused because of where it *resolves*, not
119        // because it is absent, and calling that "no such file" would send
120        // the reader looking for a typo in a path that is spelled correctly.
121        // `symlink_metadata` rather than `exists`, which follows the link and
122        // would report the escape as absent whenever the target is.
123        if path.symlink_metadata().is_ok() {
124            return Some(ReadDenial::NotGranted);
125        }
126
127        // The nearest ancestor that exists. `canonicalize` fails on the whole
128        // path when any component is missing, so walk up until it succeeds.
129        let mut ancestor = path.parent();
130        while let Some(dir) = ancestor {
131            if let Ok(real) = dir.canonicalize() {
132                let inside = self.read_roots.iter().any(|root| {
133                    root.canonicalize()
134                        .map(|root| real.starts_with(root))
135                        .unwrap_or(false)
136                });
137                return Some(if inside {
138                    ReadDenial::Missing
139                } else {
140                    ReadDenial::NotGranted
141                });
142            }
143            ancestor = dir.parent();
144        }
145        Some(ReadDenial::NotGranted)
146    }
147
148    /// The granted read roots, as configured.
149    pub fn read_roots(&self) -> &[PathBuf] {
150        &self.read_roots
151    }
152}
153
154/// Why [`Capabilities::allows_read`] said no.
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum ReadDenial {
157    /// The path is not inside any granted root — or is somewhere the job may
158    /// not learn anything about, including whether it exists.
159    NotGranted,
160    /// The path would be readable, but there is nothing there. Only reported
161    /// where the job could have discovered that itself; see
162    /// [`Capabilities::read_denial`].
163    Missing,
164}