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}