Skip to content

Commit 8253c6c

Browse files
committed
fix(sandbox): use read_link instead of canonicalize for symlink resolution
std::fs::canonicalize resolves /proc/<pid>/root itself (a kernel pseudo-symlink to /) which strips the prefix needed for path extraction. This caused resolution to silently fail in all environments, not just CI. Replace with an iterative read_link loop that walks the symlink chain within the container namespace without resolving the /proc mount point. Add normalize_path helper for relative symlink targets containing .. components. Update procfs_root_accessible test guard to actually probe the full resolution path instead of just checking path existence.
1 parent 79003b9 commit 8253c6c

File tree

1 file changed

+128
-47
lines changed
  • crates/openshell-sandbox/src

1 file changed

+128
-47
lines changed

crates/openshell-sandbox/src/opa.rs

Lines changed: 128 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -621,58 +621,103 @@ fn normalize_endpoint_ports(data: &mut serde_json::Value) {
621621
/// - Path is not a symlink
622622
/// - Resolution fails (binary doesn't exist in container)
623623
/// - Resolved path equals the original
624+
/// Normalize a path by resolving `.` and `..` components without touching
625+
/// the filesystem. Only works correctly for absolute paths.
626+
fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
627+
let mut result = std::path::PathBuf::new();
628+
for component in path.components() {
629+
match component {
630+
std::path::Component::ParentDir => {
631+
result.pop();
632+
}
633+
std::path::Component::CurDir => {}
634+
other => result.push(other),
635+
}
636+
}
637+
result
638+
}
639+
624640
#[cfg(target_os = "linux")]
625641
fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> Option<String> {
626642
if policy_path.contains('*') || entrypoint_pid == 0 {
627643
return None;
628644
}
629645

630-
let container_path = format!("/proc/{entrypoint_pid}/root{policy_path}");
631-
632-
// Check if we can access the container filesystem at all.
633-
// Failure here means /proc/<pid>/root/ is inaccessible (missing
634-
// CAP_SYS_PTRACE, restricted ptrace scope, rootless container, etc.).
635-
let meta = match std::fs::symlink_metadata(&container_path) {
636-
Ok(m) => m,
637-
Err(e) => {
638-
tracing::warn!(
639-
path = %policy_path,
640-
container_path = %container_path,
641-
pid = entrypoint_pid,
642-
error = %e,
643-
"Cannot access container filesystem for symlink resolution; \
644-
binary paths in policy will be matched literally. If a policy \
645-
binary is a symlink (e.g., /usr/bin/python3 -> python3.11), \
646-
use the canonical path instead, or run with CAP_SYS_PTRACE"
647-
);
648-
return None;
646+
// Walk the symlink chain inside the container filesystem using
647+
// read_link rather than canonicalize. canonicalize resolves
648+
// /proc/<pid>/root itself (a kernel pseudo-symlink to /) which
649+
// strips the prefix we need. read_link only reads the target of
650+
// the specified symlink, keeping us in the container's namespace.
651+
let mut resolved = std::path::PathBuf::from(policy_path);
652+
653+
// Linux SYMLOOP_MAX is 40; stop before infinite loops
654+
for _ in 0..40 {
655+
let container_path = format!("/proc/{entrypoint_pid}/root{}", resolved.display());
656+
657+
let meta = match std::fs::symlink_metadata(&container_path) {
658+
Ok(m) => m,
659+
Err(e) => {
660+
// Only warn on the first iteration (the original policy path).
661+
// On subsequent iterations, the intermediate target may
662+
// legitimately not exist (broken symlink chain).
663+
if resolved.as_os_str() == policy_path {
664+
tracing::warn!(
665+
path = %policy_path,
666+
container_path = %container_path,
667+
pid = entrypoint_pid,
668+
error = %e,
669+
"Cannot access container filesystem for symlink resolution; \
670+
binary paths in policy will be matched literally. If a policy \
671+
binary is a symlink (e.g., /usr/bin/python3 -> python3.11), \
672+
use the canonical path instead, or run with CAP_SYS_PTRACE"
673+
);
674+
} else {
675+
tracing::warn!(
676+
original = %policy_path,
677+
current = %resolved.display(),
678+
pid = entrypoint_pid,
679+
error = %e,
680+
"Symlink chain broken during resolution; \
681+
binary will be matched by original path only"
682+
);
683+
}
684+
return None;
685+
}
686+
};
687+
688+
if !meta.file_type().is_symlink() {
689+
// Reached a non-symlink — this is the final resolved target
690+
break;
649691
}
650-
};
651692

652-
// Not a symlink — no expansion needed (this is the common, expected case)
653-
if !meta.file_type().is_symlink() {
654-
return None;
655-
}
693+
let target = match std::fs::read_link(&container_path) {
694+
Ok(t) => t,
695+
Err(e) => {
696+
tracing::warn!(
697+
path = %policy_path,
698+
current = %resolved.display(),
699+
pid = entrypoint_pid,
700+
error = %e,
701+
"Symlink detected but read_link failed; \
702+
binary will be matched by original path only"
703+
);
704+
return None;
705+
}
706+
};
656707

657-
// Resolve through the container's filesystem (handles multi-level symlinks)
658-
let canonical = match std::fs::canonicalize(&container_path) {
659-
Ok(c) => c,
660-
Err(e) => {
661-
tracing::warn!(
662-
path = %policy_path,
663-
pid = entrypoint_pid,
664-
error = %e,
665-
"Symlink detected but canonicalize failed; \
666-
binary will be matched by original path only"
667-
);
668-
return None;
708+
if target.is_absolute() {
709+
resolved = target;
710+
} else {
711+
// Relative symlink: resolve against the containing directory
712+
// e.g., /usr/bin/python3 -> python3.11 becomes /usr/bin/python3.11
713+
if let Some(parent) = resolved.parent() {
714+
resolved = normalize_path(&parent.join(&target));
715+
} else {
716+
break;
717+
}
669718
}
670-
};
719+
}
671720

672-
// Strip the /proc/<pid>/root prefix to get the in-container absolute path
673-
let prefix = format!("/proc/{entrypoint_pid}/root");
674-
let in_container = canonical.strip_prefix(&prefix).ok()?;
675-
let resolved = std::path::PathBuf::from("/").join(in_container);
676721
let resolved_str = resolved.to_string_lossy().into_owned();
677722

678723
if resolved_str == policy_path {
@@ -2946,6 +2991,27 @@ process:
29462991
// Symlink resolution tests (issue #770)
29472992
// ========================================================================
29482993

2994+
#[test]
2995+
fn normalize_path_resolves_parent_and_current() {
2996+
use std::path::{Path, PathBuf};
2997+
assert_eq!(
2998+
normalize_path(Path::new("/usr/bin/../lib/python3")),
2999+
PathBuf::from("/usr/lib/python3")
3000+
);
3001+
assert_eq!(
3002+
normalize_path(Path::new("/usr/bin/./python3")),
3003+
PathBuf::from("/usr/bin/python3")
3004+
);
3005+
assert_eq!(
3006+
normalize_path(Path::new("/a/b/c/../../d")),
3007+
PathBuf::from("/a/d")
3008+
);
3009+
assert_eq!(
3010+
normalize_path(Path::new("/usr/bin/python3")),
3011+
PathBuf::from("/usr/bin/python3")
3012+
);
3013+
}
3014+
29493015
#[test]
29503016
fn resolve_binary_skips_glob_paths() {
29513017
// Glob patterns should never be resolved — they're matched differently
@@ -3290,15 +3356,30 @@ network_policies:
32903356
);
32913357
}
32923358

3293-
/// Check if `/proc/<pid>/root/` is accessible for the current process.
3294-
/// In CI containers or restricted environments, this path may not be
3295-
/// readable even for the process's own PID. Tests that depend on
3296-
/// procfs root access should skip gracefully when this returns false.
3359+
/// Check if symlink resolution through `/proc/<pid>/root/` actually works.
3360+
/// Creates a real symlink in a tempdir and attempts to resolve it via
3361+
/// the procfs root path. This catches environments where the probe path
3362+
/// is readable but canonicalization/read_link fails (e.g., containers
3363+
/// with restricted ptrace scope, rootless containers).
32973364
#[cfg(target_os = "linux")]
32983365
fn procfs_root_accessible() -> bool {
3366+
use std::os::unix::fs::symlink;
3367+
let dir = match tempfile::tempdir() {
3368+
Ok(d) => d,
3369+
Err(_) => return false,
3370+
};
3371+
let target = dir.path().join("probe_target");
3372+
let link = dir.path().join("probe_link");
3373+
if std::fs::write(&target, b"probe").is_err() {
3374+
return false;
3375+
}
3376+
if symlink(&target, &link).is_err() {
3377+
return false;
3378+
}
32993379
let pid = std::process::id();
3300-
let probe = format!("/proc/{pid}/root/tmp");
3301-
std::fs::symlink_metadata(&probe).is_ok()
3380+
let link_path = link.to_string_lossy().to_string();
3381+
// Actually attempt the same resolution our production code uses
3382+
resolve_binary_in_container(&link_path, pid).is_some()
33023383
}
33033384

33043385
#[cfg(target_os = "linux")]

0 commit comments

Comments
 (0)