Skip to content

Commit 1514d0b

Browse files
committed
fix(sandbox): resolve multi-hop symlinks in fs grants
Signed-off-by: James Carnegie <me@kipz.org>
1 parent d1d232e commit 1514d0b

5 files changed

Lines changed: 525 additions & 14 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
//! Kernel-level coverage for nolabs-ai/nono#1771: a capability
2+
//! granted over a multi-hop symlink chain (a symlinked leaf pointing through
3+
//! a symlinked directory component, the common `stow`/`chezmoi` dotfiles
4+
//! shape) must resolve end-to-end under the real Seatbelt sandbox, without
5+
//! widening access to unrelated files reachable through the same
6+
//! intermediate directory.
7+
8+
#![cfg(target_os = "macos")]
9+
10+
use nono_test_support::{Argv, nono_test};
11+
use std::fs;
12+
13+
/// Builds the multi-hop symlink fixture under `t.workspace()`:
14+
///
15+
/// ```text
16+
/// .gitconfig -> hosts/current/gitconfig -> hosts/mymac/gitconfig
17+
/// hosts/current -> hosts/mymac (symlinked directory component)
18+
/// ```
19+
///
20+
/// `hosts/mymac/secret.txt` sits beside the real target but is not part of
21+
/// the granted capability's own resolution chain.
22+
fn write_fixture(workspace: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
23+
let hosts = workspace.join("hosts");
24+
let mymac = hosts.join("mymac");
25+
fs::create_dir_all(&mymac).expect("create hosts/mymac");
26+
fs::write(mymac.join("gitconfig"), "trusted\n").expect("write real gitconfig");
27+
fs::write(mymac.join("secret.txt"), "topsecret\n").expect("write sibling secret");
28+
29+
let current = hosts.join("current");
30+
std::os::unix::fs::symlink(&mymac, &current).expect("symlink hosts/current -> hosts/mymac");
31+
32+
let gitconfig_link = workspace.join(".gitconfig");
33+
std::os::unix::fs::symlink(current.join("gitconfig"), &gitconfig_link)
34+
.expect("symlink .gitconfig -> hosts/current/gitconfig");
35+
36+
(gitconfig_link, mymac.join("secret.txt"))
37+
}
38+
39+
#[test]
40+
fn multi_hop_symlinked_leaf_resolves_through_symlinked_directory() {
41+
let t = nono_test!("symlink-hop-positive");
42+
let workspace = t.workspace().to_path_buf();
43+
let (gitconfig_link, _secret) = write_fixture(&workspace);
44+
45+
let profile = t.write_profile(
46+
"symlink-hop-positive",
47+
&format!(
48+
r#"{{"meta":{{"name":"t"}},"workdir":{{"access":"readwrite"}},"filesystem":{{"read":["{}"]}}}}"#,
49+
gitconfig_link.display()
50+
),
51+
);
52+
53+
t.run()
54+
.profile(&profile)
55+
.exec(Argv::new("/bin/cat").arg(&gitconfig_link))
56+
.assert_stdout_contains("trusted");
57+
}
58+
59+
#[test]
60+
fn multi_hop_symlinked_leaf_does_not_widen_access_to_sibling_in_traversed_directory() {
61+
let t = nono_test!("symlink-hop-negative");
62+
let workspace = t.workspace().to_path_buf();
63+
let (gitconfig_link, secret) = write_fixture(&workspace);
64+
65+
let profile = t.write_profile(
66+
"symlink-hop-negative",
67+
&format!(
68+
r#"{{"meta":{{"name":"t"}},"workdir":{{"access":"readwrite"}},"filesystem":{{"read":["{}"]}}}}"#,
69+
gitconfig_link.display()
70+
),
71+
);
72+
73+
t.run()
74+
.profile(&profile)
75+
.exec(Argv::new("/bin/cat").arg(&secret))
76+
.assert_failure(
77+
"granting a multi-hop symlink must not expose sibling files under the traversed directory",
78+
);
79+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
//! Linux Landlock parity coverage for nolabs-ai/nono#1771: the
2+
//! same multi-hop symlink chain fixed for macOS Seatbelt in
3+
//! `symlink_hop_run.rs` must resolve end-to-end under real Landlock
4+
//! enforcement too, without widening access to unrelated files reachable
5+
//! through the same intermediate directory.
6+
//!
7+
//! Linux needs no code change for this bug: `open_path_rule` opens
8+
//! `cap.resolved` directly with `O_PATH`, so the kernel resolves the whole
9+
//! symlink chain before Landlock ever sees a literal intermediate path. This
10+
//! test exists to prove that resolution-safety empirically under real
11+
//! enforcement, rather than leave it as an unverified claim about Linux's
12+
//! `O_PATH` model.
13+
14+
#![cfg(target_os = "linux")]
15+
16+
use nono_test_support::{Argv, nono_test};
17+
use std::fs;
18+
19+
/// Builds the multi-hop symlink fixture under `t.workspace()`:
20+
///
21+
/// ```text
22+
/// .gitconfig -> hosts/current/gitconfig -> hosts/mymac/gitconfig
23+
/// hosts/current -> hosts/mymac (symlinked directory component)
24+
/// ```
25+
///
26+
/// `hosts/mymac/secret.txt` sits beside the real target but is not part of
27+
/// the granted capability's own resolution chain.
28+
fn write_fixture(workspace: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
29+
let hosts = workspace.join("hosts");
30+
let mymac = hosts.join("mymac");
31+
fs::create_dir_all(&mymac).expect("create hosts/mymac");
32+
fs::write(mymac.join("gitconfig"), "trusted\n").expect("write real gitconfig");
33+
fs::write(mymac.join("secret.txt"), "topsecret\n").expect("write sibling secret");
34+
35+
let current = hosts.join("current");
36+
std::os::unix::fs::symlink(&mymac, &current).expect("symlink hosts/current -> hosts/mymac");
37+
38+
let gitconfig_link = workspace.join(".gitconfig");
39+
std::os::unix::fs::symlink(current.join("gitconfig"), &gitconfig_link)
40+
.expect("symlink .gitconfig -> hosts/current/gitconfig");
41+
42+
(gitconfig_link, mymac.join("secret.txt"))
43+
}
44+
45+
#[test]
46+
fn multi_hop_symlinked_leaf_resolves_through_symlinked_directory() {
47+
let t = nono_test!("symlink-hop-positive-linux");
48+
let workspace = t.workspace().to_path_buf();
49+
let (gitconfig_link, _secret) = write_fixture(&workspace);
50+
51+
let profile = t.write_profile(
52+
"symlink-hop-positive-linux",
53+
&format!(
54+
r#"{{"meta":{{"name":"t"}},"workdir":{{"access":"readwrite"}},"filesystem":{{"read":["{}"]}}}}"#,
55+
gitconfig_link.display()
56+
),
57+
);
58+
59+
t.run()
60+
.profile(&profile)
61+
.exec(Argv::new("/bin/cat").arg(&gitconfig_link))
62+
.assert_stdout_contains("trusted");
63+
}
64+
65+
#[test]
66+
fn multi_hop_symlinked_leaf_does_not_widen_access_to_sibling_in_traversed_directory() {
67+
let t = nono_test!("symlink-hop-negative-linux");
68+
let workspace = t.workspace().to_path_buf();
69+
let (gitconfig_link, secret) = write_fixture(&workspace);
70+
71+
let profile = t.write_profile(
72+
"symlink-hop-negative-linux",
73+
&format!(
74+
r#"{{"meta":{{"name":"t"}},"workdir":{{"access":"readwrite"}},"filesystem":{{"read":["{}"]}}}}"#,
75+
gitconfig_link.display()
76+
),
77+
);
78+
79+
t.run()
80+
.profile(&profile)
81+
.exec(Argv::new("/bin/cat").arg(&secret))
82+
.assert_failure(
83+
"granting a multi-hop symlink must not expose sibling files under the traversed directory",
84+
);
85+
}

crates/nono/src/path.rs

Lines changed: 201 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Path utilities shared across nono library and CLI.
22
3-
use std::path::{Path, PathBuf};
3+
use std::collections::HashSet;
4+
use std::path::{Component, Path, PathBuf};
45

56
/// Canonicalize a path using an ancestor-walk fallback.
67
///
@@ -49,6 +50,106 @@ pub(crate) fn try_canonicalize_ancestor_walk(path: &Path) -> PathBuf {
4950
path.to_path_buf()
5051
}
5152

53+
/// Maximum number of symlink dereferences [`collect_symlink_hops`] will
54+
/// follow, matching typical OS `ELOOP`/`MAXSYMLINKS` limits. Bounds the walk
55+
/// so a symlink cycle truncates instead of looping forever.
56+
const MAX_SYMLINK_HOPS: usize = 40;
57+
58+
/// Enumerate every intermediate absolute path produced while resolving a
59+
/// multi-hop symlink chain, e.g. a symlinked leaf pointing through a
60+
/// symlinked directory component.
61+
///
62+
/// Unlike [`try_canonicalize`], which resolves an entire chain in one
63+
/// `std::fs::canonicalize()` call and only ever sees the two endpoints, this
64+
/// walks the path component by component and records the literal path of
65+
/// every symlink it dereferences along the way (including symlinked
66+
/// directory components, not just a symlinked leaf). Callers use this to
67+
/// find hops that are neither the original path, the final resolved path,
68+
/// nor an ancestor of either -- and so would otherwise get no sandbox grant
69+
/// at all, causing `EPERM` when the kernel dereferences that literal
70+
/// component.
71+
///
72+
/// Returns an empty vec when `path` contains no symlinks. Silently stops
73+
/// (returning whatever was collected so far) on a cycle or a chain deeper
74+
/// than [`MAX_SYMLINK_HOPS`], rather than erroring -- a cycle or excessively
75+
/// deep chain will fail its own `open()` at run time regardless.
76+
///
77+
/// Callers that already hold a canonicalized `resolved` path (e.g.
78+
/// `FsCapability`) call this separately, against the live filesystem, at
79+
/// profile-generation time. If the on-disk symlink chain changes between
80+
/// that earlier canonicalization and this walk, the hop set and `resolved`
81+
/// can briefly disagree -- the same TOCTOU window `original`/`resolved`
82+
/// already have, just with a few more syscalls in it.
83+
pub fn collect_symlink_hops(path: &Path) -> Vec<PathBuf> {
84+
let mut hops = Vec::new();
85+
let mut seen = HashSet::new();
86+
let mut depth = 0usize;
87+
resolve_hops(path, &mut hops, &mut seen, &mut depth);
88+
hops
89+
}
90+
91+
/// Resolve `path` component by component, recording the literal path of each
92+
/// symlink dereferenced into `hops`, and returning the fully resolved
93+
/// (as far as `depth` allows) absolute path.
94+
///
95+
/// `path` need not be absolute on entry (a symlink target can be relative);
96+
/// relative components are resolved against `result` as they're pushed.
97+
fn resolve_hops(
98+
path: &Path,
99+
hops: &mut Vec<PathBuf>,
100+
seen: &mut HashSet<PathBuf>,
101+
depth: &mut usize,
102+
) -> PathBuf {
103+
let mut result = PathBuf::new();
104+
105+
for component in path.components() {
106+
match component {
107+
Component::Prefix(_) | Component::RootDir => {
108+
result.push(component.as_os_str());
109+
}
110+
Component::CurDir => {}
111+
Component::ParentDir => {
112+
result.pop();
113+
}
114+
Component::Normal(segment) => {
115+
result.push(segment);
116+
117+
if *depth >= MAX_SYMLINK_HOPS {
118+
continue;
119+
}
120+
121+
let Ok(metadata) = std::fs::symlink_metadata(&result) else {
122+
continue;
123+
};
124+
if !metadata.file_type().is_symlink() {
125+
continue;
126+
}
127+
128+
*depth += 1;
129+
if seen.insert(result.clone()) {
130+
hops.push(result.clone());
131+
}
132+
133+
let Ok(target) = std::fs::read_link(&result) else {
134+
continue;
135+
};
136+
let target = if target.is_absolute() {
137+
target
138+
} else {
139+
result
140+
.parent()
141+
.map(|parent| parent.join(&target))
142+
.unwrap_or(target)
143+
};
144+
145+
result = resolve_hops(&target, hops, seen, depth);
146+
}
147+
}
148+
}
149+
150+
result
151+
}
152+
52153
#[cfg(test)]
53154
mod tests {
54155
use super::*;
@@ -93,4 +194,103 @@ mod tests {
93194
assert_eq!(result, real_file.canonicalize().expect("canonicalize"));
94195
}
95196
}
197+
198+
#[cfg(unix)]
199+
#[test]
200+
fn collect_symlink_hops_empty_for_plain_path() {
201+
let dir = tempfile::tempdir().expect("tempdir");
202+
let canonical_dir = dir.path().canonicalize().expect("canonicalize");
203+
let real_file = canonical_dir.join("real.txt");
204+
fs::write(&real_file, "hello").expect("write file");
205+
assert!(collect_symlink_hops(&real_file).is_empty());
206+
}
207+
208+
#[cfg(unix)]
209+
#[test]
210+
fn collect_symlink_hops_single_hop_leaf_symlink() {
211+
let dir = tempfile::tempdir().expect("tempdir");
212+
let canonical_dir = dir.path().canonicalize().expect("canonicalize");
213+
let real_file = canonical_dir.join("real.txt");
214+
fs::write(&real_file, "hello").expect("write file");
215+
let link = canonical_dir.join("link.txt");
216+
std::os::unix::fs::symlink(&real_file, &link).expect("symlink");
217+
218+
let hops = collect_symlink_hops(&link);
219+
assert_eq!(hops, vec![link]);
220+
}
221+
222+
/// A symlinked leaf pointing through a symlinked directory component,
223+
/// e.g.
224+
/// `~/.gitconfig -> hosts/current/gitconfig -> hosts/mymac/gitconfig`
225+
/// where `hosts/current -> hosts/mymac` is itself a symlinked directory.
226+
#[cfg(unix)]
227+
#[test]
228+
fn collect_symlink_hops_multi_hop_through_symlinked_directory() {
229+
let dir = tempfile::tempdir().expect("tempdir");
230+
let canonical_dir = dir.path().canonicalize().expect("canonicalize");
231+
232+
let hosts = canonical_dir.join("hosts");
233+
let mymac = hosts.join("mymac");
234+
fs::create_dir_all(&mymac).expect("mkdir mymac");
235+
let real_gitconfig = mymac.join("gitconfig");
236+
fs::write(&real_gitconfig, "[user]\n").expect("write gitconfig");
237+
238+
let current = hosts.join("current");
239+
std::os::unix::fs::symlink(&mymac, &current).expect("symlink dir");
240+
241+
let gitconfig_link = canonical_dir.join(".gitconfig");
242+
std::os::unix::fs::symlink(current.join("gitconfig"), &gitconfig_link)
243+
.expect("symlink leaf");
244+
245+
let hops = collect_symlink_hops(&gitconfig_link);
246+
assert_eq!(hops, vec![gitconfig_link.clone(), current]);
247+
248+
// The fully resolved target is unaffected -- std canonicalization
249+
// still collapses the whole chain in one shot.
250+
assert_eq!(
251+
try_canonicalize(&gitconfig_link),
252+
real_gitconfig.canonicalize().expect("canonicalize")
253+
);
254+
}
255+
256+
#[cfg(unix)]
257+
#[test]
258+
fn collect_symlink_hops_cycle_truncates_instead_of_looping() {
259+
let dir = tempfile::tempdir().expect("tempdir");
260+
let canonical_dir = dir.path().canonicalize().expect("canonicalize");
261+
let a = canonical_dir.join("a");
262+
let b = canonical_dir.join("b");
263+
std::os::unix::fs::symlink(&b, &a).expect("symlink a->b");
264+
std::os::unix::fs::symlink(&a, &b).expect("symlink b->a");
265+
266+
let hops = collect_symlink_hops(&a);
267+
// Must terminate (this test itself is the proof); depth-bounded, so
268+
// only ever sees the two distinct literal hop paths.
269+
assert!(hops.len() <= MAX_SYMLINK_HOPS);
270+
assert!(hops.contains(&a));
271+
assert!(hops.contains(&b));
272+
}
273+
274+
#[cfg(unix)]
275+
#[test]
276+
fn collect_symlink_hops_nonexistent_final_target_keeps_existing_hops() {
277+
let dir = tempfile::tempdir().expect("tempdir");
278+
let canonical_dir = dir.path().canonicalize().expect("canonicalize");
279+
280+
let hosts = canonical_dir.join("hosts");
281+
let mymac = hosts.join("mymac");
282+
fs::create_dir_all(&mymac).expect("mkdir mymac");
283+
// Note: no `gitconfig` file written under mymac -- final target
284+
// does not exist, but the directory symlink hop is still real.
285+
286+
let current = hosts.join("current");
287+
std::os::unix::fs::symlink(&mymac, &current).expect("symlink dir");
288+
289+
let gitconfig_link = canonical_dir.join(".gitconfig");
290+
std::os::unix::fs::symlink(current.join("gitconfig"), &gitconfig_link)
291+
.expect("symlink leaf");
292+
293+
let hops = collect_symlink_hops(&gitconfig_link);
294+
assert_eq!(hops, vec![gitconfig_link, current]);
295+
}
96296
}

0 commit comments

Comments
 (0)