Skip to content

Commit 907b9fe

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 70bfde3 commit 907b9fe

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
@@ -637,58 +637,103 @@ fn normalize_endpoint_ports(data: &mut serde_json::Value) {
637637
/// - Path is not a symlink
638638
/// - Resolution fails (binary doesn't exist in container)
639639
/// - Resolved path equals the original
640+
/// Normalize a path by resolving `.` and `..` components without touching
641+
/// the filesystem. Only works correctly for absolute paths.
642+
fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
643+
let mut result = std::path::PathBuf::new();
644+
for component in path.components() {
645+
match component {
646+
std::path::Component::ParentDir => {
647+
result.pop();
648+
}
649+
std::path::Component::CurDir => {}
650+
other => result.push(other),
651+
}
652+
}
653+
result
654+
}
655+
640656
#[cfg(target_os = "linux")]
641657
fn resolve_binary_in_container(policy_path: &str, entrypoint_pid: u32) -> Option<String> {
642658
if policy_path.contains('*') || entrypoint_pid == 0 {
643659
return None;
644660
}
645661

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

668-
// Not a symlink — no expansion needed (this is the common, expected case)
669-
if !meta.file_type().is_symlink() {
670-
return None;
671-
}
709+
let target = match std::fs::read_link(&container_path) {
710+
Ok(t) => t,
711+
Err(e) => {
712+
tracing::warn!(
713+
path = %policy_path,
714+
current = %resolved.display(),
715+
pid = entrypoint_pid,
716+
error = %e,
717+
"Symlink detected but read_link failed; \
718+
binary will be matched by original path only"
719+
);
720+
return None;
721+
}
722+
};
672723

673-
// Resolve through the container's filesystem (handles multi-level symlinks)
674-
let canonical = match std::fs::canonicalize(&container_path) {
675-
Ok(c) => c,
676-
Err(e) => {
677-
tracing::warn!(
678-
path = %policy_path,
679-
pid = entrypoint_pid,
680-
error = %e,
681-
"Symlink detected but canonicalize failed; \
682-
binary will be matched by original path only"
683-
);
684-
return None;
724+
if target.is_absolute() {
725+
resolved = target;
726+
} else {
727+
// Relative symlink: resolve against the containing directory
728+
// e.g., /usr/bin/python3 -> python3.11 becomes /usr/bin/python3.11
729+
if let Some(parent) = resolved.parent() {
730+
resolved = normalize_path(&parent.join(&target));
731+
} else {
732+
break;
733+
}
685734
}
686-
};
735+
}
687736

688-
// Strip the /proc/<pid>/root prefix to get the in-container absolute path
689-
let prefix = format!("/proc/{entrypoint_pid}/root");
690-
let in_container = canonical.strip_prefix(&prefix).ok()?;
691-
let resolved = std::path::PathBuf::from("/").join(in_container);
692737
let resolved_str = resolved.to_string_lossy().into_owned();
693738

694739
if resolved_str == policy_path {
@@ -2962,6 +3007,27 @@ process:
29623007
// Symlink resolution tests (issue #770)
29633008
// ========================================================================
29643009

3010+
#[test]
3011+
fn normalize_path_resolves_parent_and_current() {
3012+
use std::path::{Path, PathBuf};
3013+
assert_eq!(
3014+
normalize_path(Path::new("/usr/bin/../lib/python3")),
3015+
PathBuf::from("/usr/lib/python3")
3016+
);
3017+
assert_eq!(
3018+
normalize_path(Path::new("/usr/bin/./python3")),
3019+
PathBuf::from("/usr/bin/python3")
3020+
);
3021+
assert_eq!(
3022+
normalize_path(Path::new("/a/b/c/../../d")),
3023+
PathBuf::from("/a/d")
3024+
);
3025+
assert_eq!(
3026+
normalize_path(Path::new("/usr/bin/python3")),
3027+
PathBuf::from("/usr/bin/python3")
3028+
);
3029+
}
3030+
29653031
#[test]
29663032
fn resolve_binary_skips_glob_paths() {
29673033
// Glob patterns should never be resolved — they're matched differently
@@ -3306,15 +3372,30 @@ network_policies:
33063372
);
33073373
}
33083374

3309-
/// Check if `/proc/<pid>/root/` is accessible for the current process.
3310-
/// In CI containers or restricted environments, this path may not be
3311-
/// readable even for the process's own PID. Tests that depend on
3312-
/// procfs root access should skip gracefully when this returns false.
3375+
/// Check if symlink resolution through `/proc/<pid>/root/` actually works.
3376+
/// Creates a real symlink in a tempdir and attempts to resolve it via
3377+
/// the procfs root path. This catches environments where the probe path
3378+
/// is readable but canonicalization/read_link fails (e.g., containers
3379+
/// with restricted ptrace scope, rootless containers).
33133380
#[cfg(target_os = "linux")]
33143381
fn procfs_root_accessible() -> bool {
3382+
use std::os::unix::fs::symlink;
3383+
let dir = match tempfile::tempdir() {
3384+
Ok(d) => d,
3385+
Err(_) => return false,
3386+
};
3387+
let target = dir.path().join("probe_target");
3388+
let link = dir.path().join("probe_link");
3389+
if std::fs::write(&target, b"probe").is_err() {
3390+
return false;
3391+
}
3392+
if symlink(&target, &link).is_err() {
3393+
return false;
3394+
}
33153395
let pid = std::process::id();
3316-
let probe = format!("/proc/{pid}/root/tmp");
3317-
std::fs::symlink_metadata(&probe).is_ok()
3396+
let link_path = link.to_string_lossy().to_string();
3397+
// Actually attempt the same resolution our production code uses
3398+
resolve_binary_in_container(&link_path, pid).is_some()
33183399
}
33193400

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

0 commit comments

Comments
 (0)