Skip to main content

netget/client/git/
sandbox.rs

1//! Filesystem confinement for the Git client.
2//!
3//! Every path this client touches comes from the model: `git_clone`'s `path` and `url`, and
4//! the `remote_addr` / `local_path` a caller hands `connect()`. Without a boundary, libgit2
5//! is pointed at whatever the model names — the operator's own repositories included, where
6//! `git_checkout` and `git_delete_branch --force` are destructive and `git_push` publishes.
7//!
8//! So there is a boundary: one root directory, declared as the `allowed_root` startup
9//! parameter, defaulting to a NetGet-owned workspace. Every model-supplied path is resolved
10//! against it and **refused** if it lands outside.
11//!
12//! ## Refuse, never relocate
13//!
14//! A path outside the root is an error naming `allowed_root`. It is deliberately not
15//! rewritten to sit inside: a model that asked for `/tmp/x` and silently got
16//! `<root>/tmp/x` has a confusing bug, where a refusal is a clear one, and a silent
17//! rewrite would also make `git_clone` report success for a repository that is not where
18//! the model believes it is.
19//!
20//! A *relative* path is a different thing and is resolved against the root rather than the
21//! process's working directory. That is not a relocation — a relative path names no
22//! location until something supplies a base — and resolving against the cwd would mean
23//! `./repo` was refused for being outside the root, which is useless. `local_path: "./my-repo"`,
24//! which is what every startup example in `actions.rs` shows, therefore means
25//! `<allowed_root>/my-repo`.
26//!
27//! ## How escape is prevented
28//!
29//! `..` and symlinks both have to be dealt with, and neither yields to string matching:
30//! `<root>/a/../../etc` is textually inside the root, and `<root>/link -> /etc` is textually
31//! inside it too. [`GitSandbox::resolve`] therefore canonicalises rather than compares text.
32//! Since a clone destination does not exist yet, it canonicalises the **longest existing
33//! ancestor** — which is where every symlink and every `..` that can be resolved lives — and
34//! re-appends the non-existent tail, refusing any `..` in that tail because there is nothing
35//! for it to mean. The comparison is then between two canonical absolute paths.
36//!
37//! A TOCTOU window remains: a symlink created between the check and libgit2's own `open()`
38//! would not be seen. Closing it needs `openat2(RESOLVE_BENEATH)` or a per-operation chroot,
39//! neither of which libgit2 exposes. The window requires an attacker who can already write
40//! inside the workspace, which is a strictly larger capability than anything this guard is
41//! defending against.
42
43use anyhow::{bail, Context, Result};
44use std::path::{Path, PathBuf};
45
46/// Startup parameter naming the directory the client may touch. Named in every refusal, so
47/// the operator is told which knob to turn rather than just that something was denied.
48pub const ALLOWED_ROOT_PARAM: &str = "allowed_root";
49
50/// Startup parameter opting in to operations that reach *outside* the sandbox entirely.
51pub const ALLOW_REMOTE_WRITES_PARAM: &str = "allow_remote_writes";
52
53/// The workspace NetGet owns when `allowed_root` is not given.
54///
55/// Deliberately neither `$HOME` nor the process's working directory: the second would be
56/// whatever repository the operator happened to launch NetGet from, which is precisely the
57/// thing being protected. The platform's local-data directory gives NetGet a subtree nothing
58/// else writes to, stable across runs, so a repository cloned by one client is still there
59/// for the next one — `~/Library/Application Support/netget/git-workspace` on macOS,
60/// `~/.local/share/netget/git-workspace` on Linux, `%LOCALAPPDATA%\netget\git-workspace` on
61/// Windows.
62///
63/// **Not `~/.netget/git-workspace`**, which was the obvious first choice and is impossible:
64/// `Settings::settings_path` (`src/settings.rs`) makes `~/.netget` NetGet's settings *file*,
65/// so `create_dir_all` on a path beneath it fails with `ENOTDIR`. It was the existing Git
66/// client tests that caught this, which is a good argument for having them.
67pub fn default_root() -> PathBuf {
68    if let Some(data) = dirs::data_local_dir() {
69        return data.join("netget").join("git-workspace");
70    }
71    if let Some(home) = dirs::home_dir() {
72        return home.join(".netget-git-workspace");
73    }
74    // No home and no data directory (a daemon with no passwd entry, some containers). The
75    // temp directory is the only writable location that can be assumed, and a confined
76    // scratch space there is still far better than no confinement.
77    std::env::temp_dir().join("netget-git-workspace")
78}
79
80/// One Git client's filesystem boundary.
81///
82/// Cheap to clone — it is a canonical `PathBuf` and a flag — which matters because every
83/// git2 call runs on `spawn_blocking` and needs an owned copy.
84#[derive(Debug, Clone)]
85pub struct GitSandbox {
86    /// Canonicalised, and the directory is known to exist: both are established in
87    /// [`GitSandbox::new`] so that `resolve` never has to reason about a root that does not.
88    root: PathBuf,
89    allow_remote_writes: bool,
90}
91
92impl GitSandbox {
93    /// Build the boundary from the client's startup parameters.
94    ///
95    /// Creates the root if it does not exist — `canonicalize` needs a real directory, and a
96    /// client whose workspace has to be pre-made by hand would fail on first use for a
97    /// reason nobody would guess.
98    pub fn new(configured_root: Option<&str>, allow_remote_writes: bool) -> Result<Self> {
99        let requested = match configured_root {
100            Some(raw) if !raw.trim().is_empty() => PathBuf::from(raw.trim()),
101            _ => default_root(),
102        };
103
104        std::fs::create_dir_all(&requested).with_context(|| {
105            format!(
106                "could not create the Git client's {ALLOWED_ROOT_PARAM} directory {}",
107                requested.display()
108            )
109        })?;
110
111        let root = requested.canonicalize().with_context(|| {
112            format!(
113                "could not canonicalise the Git client's {ALLOWED_ROOT_PARAM} {}",
114                requested.display()
115            )
116        })?;
117
118        if !root.is_dir() {
119            bail!(
120                "the Git client's {ALLOWED_ROOT_PARAM} ({}) is not a directory",
121                root.display()
122            );
123        }
124
125        Ok(Self {
126            root,
127            allow_remote_writes,
128        })
129    }
130
131    /// The canonical root. Every accepted path is under this.
132    pub fn root(&self) -> &Path {
133        &self.root
134    }
135
136    /// Whether operations that publish outside the sandbox were opted into.
137    pub fn remote_writes_allowed(&self) -> bool {
138        self.allow_remote_writes
139    }
140
141    /// Refuse a verb that reaches outside the sandbox unless it was opted into.
142    ///
143    /// Confinement bounds what can be *read and written on this disk*; it says nothing about
144    /// what gets pushed to a remote. `git_push` and the remote half of `git_delete_branch`
145    /// both send to a URL that lives in the cloned repository's own config, using whatever
146    /// credentials the client was given — so they can publish to, or delete a branch on, a
147    /// real forge, and no allow-list of local paths can prevent that. Those two are gated.
148    ///
149    /// Verbs whose damage is *inside* the sandbox are deliberately **not** gated:
150    /// `git_checkout` and a `force` local branch delete destroy history in a scratch clone,
151    /// which is what a scratch clone is for. Gating them would train the operator to turn
152    /// the flag on for routine work, which is how an opt-in stops meaning anything.
153    pub fn require_remote_writes(&self, verb: &str) -> Result<()> {
154        if self.allow_remote_writes {
155            return Ok(());
156        }
157        bail!(
158            "{verb} is refused: it writes to a remote repository, which is outside anything \
159             {ALLOWED_ROOT_PARAM} can confine — the remote URL comes from the cloned \
160             repository's own config and the push uses this client's credentials. Set the \
161             `{ALLOW_REMOTE_WRITES_PARAM}` startup parameter to true to permit it. It \
162             defaults to false because a model that can push can publish or delete work on a \
163             real forge, and that is not recoverable by deleting the workspace."
164        );
165    }
166
167    /// Resolve a model-supplied path and refuse it if it escapes the root.
168    ///
169    /// The path need not exist — this is what a clone destination goes through. `context`
170    /// names the thing being resolved (`"git_clone path"`, `"local_path"`) and appears in
171    /// the error, because "path refused" without saying *which* path is unactionable when
172    /// three of them are in play.
173    pub fn resolve(&self, raw: &str, context: &str) -> Result<PathBuf> {
174        let trimmed = raw.trim();
175        if trimmed.is_empty() {
176            bail!("{context} is empty");
177        }
178
179        let requested = Path::new(trimmed);
180        // A relative path is resolved against the root, not the process's working
181        // directory — see the module docs. An absolute path is taken at face value and
182        // then checked.
183        let absolute = if requested.is_absolute() {
184            requested.to_path_buf()
185        } else {
186            self.root.join(requested)
187        };
188
189        // Canonicalise the longest ancestor that exists, so symlinks and resolvable `..`
190        // are dealt with by the OS rather than by string surgery, then re-append the tail.
191        let mut tail: Vec<std::ffi::OsString> = Vec::new();
192        let mut cursor = absolute.clone();
193        let canonical_prefix = loop {
194            if let Ok(canonical) = cursor.canonicalize() {
195                break canonical;
196            }
197            let name = cursor.file_name().map(|n| n.to_os_string());
198            let parent = cursor.parent().map(|p| p.to_path_buf());
199            match (name, parent) {
200                (Some(name), Some(parent)) => {
201                    if name == ".." {
202                        // A `..` below a component that does not exist cannot be resolved
203                        // against anything real, so there is no safe interpretation. Refuse.
204                        bail!(
205                            "{context} ({trimmed}) contains '..' inside a path that does not \
206                             exist, so it cannot be resolved safely"
207                        );
208                    }
209                    tail.push(name);
210                    cursor = parent;
211                }
212                _ => bail!(
213                    "{context} ({trimmed}) has no resolvable existing ancestor, so it cannot \
214                     be checked against {ALLOWED_ROOT_PARAM}"
215                ),
216            }
217        };
218
219        let mut resolved = canonical_prefix;
220        for name in tail.into_iter().rev() {
221            resolved.push(name);
222        }
223
224        if !resolved.starts_with(&self.root) {
225            bail!(
226                "{context} ({trimmed}) resolves to {} which is outside this Git client's \
227                 {ALLOWED_ROOT_PARAM} ({}). Refused rather than relocated: silently moving it \
228                 inside the root would leave the repository somewhere other than where it was \
229                 asked for. Point it inside the root, or set the `{ALLOWED_ROOT_PARAM}` \
230                 startup parameter to a directory that contains it.",
231                resolved.display(),
232                self.root.display()
233            );
234        }
235
236        Ok(resolved)
237    }
238}
239
240/// Where a `git_clone` reads from: a network URL, or a path on this disk.
241///
242/// The distinction matters because the source is model-supplied too. A local clone source
243/// is a **read** of an arbitrary repository — point it at the operator's private work and
244/// the contents land in the sandbox, from where a permitted `git_push` could publish them.
245/// So a local source is confined; a network URL is not a filesystem concern at all.
246#[derive(Debug, PartialEq, Eq)]
247pub enum CloneSource {
248    /// Fetched over the network. Out of scope for path confinement.
249    Remote,
250    /// Read from this filesystem. The payload is the path portion, which for a `file://`
251    /// URL is what follows the scheme.
252    Local(String),
253}
254
255/// Classify a clone URL as network or local.
256///
257/// `file://` is a local path wearing a URL, so it is unwrapped rather than waved through on
258/// the strength of containing `://` — that would have been a hole big enough to drive the
259/// whole guard through.
260pub fn classify_clone_source(url: &str) -> CloneSource {
261    let trimmed = url.trim();
262
263    if let Some(rest) = trimmed.strip_prefix("file://") {
264        // `file:///abs/path` (three slashes, empty authority) is the common form; strip the
265        // empty authority so what is left is the absolute path.
266        let path = rest.strip_prefix("localhost/").unwrap_or(rest);
267        return CloneSource::Local(if path.starts_with('/') {
268            path.to_string()
269        } else {
270            format!("/{path}")
271        });
272    }
273
274    if trimmed.contains("://") {
275        return CloneSource::Remote;
276    }
277
278    // scp-style `git@host:owner/repo.git`. A Windows drive letter (`C:\repos\x`) also
279    // contains a colon but has no `@`, so requiring one keeps the two apart.
280    if let Some((before_colon, _)) = trimmed.split_once(':') {
281        if before_colon.contains('@') && !before_colon.contains('/') {
282            return CloneSource::Remote;
283        }
284    }
285
286    CloneSource::Local(trimmed.to_string())
287}