mirror of
https://github.com/openai/codex.git
synced 2026-10-20 08:19:50 +00:00
Block WSL interop escapes from restricted filesystem sandboxes (#44286)
## Why With network access enabled, WSL interop can launch Windows processes outside the Linux filesystem sandbox, including using `wsl.exe` to reenter the distribution as root. ## What changed - Mask WSL interop sockets in bubblewrap when filesystem write access is restricted, and hide inherited procfs when running without a fresh proc mount. - Deny `AF_VSOCK` sockets and `io_uring` under restricted filesystem policies even when ordinary network access is allowed, closing the VM socket route to host services. - Reject legacy Landlock configurations that combine restricted filesystem access with unrestricted networking when the WSL interop directory exists. ## Testing Add unit coverage for mount masking and legacy policy rejection, plus WSL integration tests for Windows process launch, bind aliases, direct `/init` invocation, root reentry, and `--no-proc` isolation. WSL tests skip when host prerequisites are unavailable. GitOrigin-RevId: 1e8b97ac43675807ee541ca614534d9b1febf410
This commit is contained in:
@@ -55,6 +55,7 @@ const LINUX_PLATFORM_DEFAULT_READ_ROOTS: &[&str] = &[
|
||||
];
|
||||
|
||||
const MAX_UNREADABLE_GLOB_MATCHES: usize = 8192;
|
||||
pub(crate) const WSL_INTEROP_DIR: &str = "/run/WSL";
|
||||
|
||||
/// Options that control how bubblewrap is invoked.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -66,6 +67,8 @@ pub(crate) struct BwrapOptions {
|
||||
pub mount_proc: bool,
|
||||
/// How networking should be configured inside the bubblewrap sandbox.
|
||||
pub network_mode: BwrapNetworkMode,
|
||||
/// Hide the WSL Windows interop socket from commands with restricted filesystem access.
|
||||
pub mask_wsl_interop: bool,
|
||||
/// Optional maximum depth for expanding unreadable glob patterns with ripgrep.
|
||||
///
|
||||
/// Keep this uncapped by default so existing nested deny-read matches are
|
||||
@@ -78,6 +81,7 @@ impl Default for BwrapOptions {
|
||||
Self {
|
||||
mount_proc: true,
|
||||
network_mode: BwrapNetworkMode::FullAccess,
|
||||
mask_wsl_interop: false,
|
||||
glob_scan_max_depth: None,
|
||||
}
|
||||
}
|
||||
@@ -329,6 +333,19 @@ fn create_bwrap_flags(
|
||||
args.push("--new-session".to_string());
|
||||
args.push("--die-with-parent".to_string());
|
||||
args.extend(filesystem_args);
|
||||
if options.mask_wsl_interop {
|
||||
// WSL's Windows-process bridge uses Unix sockets under /run/WSL.
|
||||
// Mask them after all filesystem binds so network access cannot bypass
|
||||
// the Linux filesystem policy by launching an unrestricted Windows process.
|
||||
args.push("--tmpfs".to_string());
|
||||
args.push(WSL_INTEROP_DIR.to_string());
|
||||
if !options.mount_proc {
|
||||
// Without a fresh procfs, the root bind retains the host procfs.
|
||||
// Hide it so /proc/<host-pid>/root cannot alias the interop sockets.
|
||||
args.push("--tmpfs".to_string());
|
||||
args.push("/proc".to_string());
|
||||
}
|
||||
}
|
||||
// Request a user namespace explicitly rather than relying on bubblewrap's
|
||||
// auto-enable behavior, which is skipped when the caller runs as uid 0.
|
||||
args.push("--unshare-user".to_string());
|
||||
|
||||
@@ -36,7 +36,7 @@ use seccompiler::apply_filter;
|
||||
///
|
||||
/// This function is responsible for:
|
||||
/// - enabling `PR_SET_NO_NEW_PRIVS` when restrictions apply, and
|
||||
/// - installing the network seccomp filter when network access is disabled.
|
||||
/// - installing seccomp restrictions for network isolation and VM sockets.
|
||||
///
|
||||
/// Filesystem restrictions are intentionally handled by bubblewrap.
|
||||
pub(crate) fn apply_permission_profile_to_current_thread(
|
||||
@@ -52,7 +52,15 @@ pub(crate) fn apply_permission_profile_to_current_thread(
|
||||
network_sandbox_policy,
|
||||
allow_network_for_proxy,
|
||||
proxy_routed_network,
|
||||
);
|
||||
)
|
||||
.or_else(|| {
|
||||
// VM sockets can reach host services outside the filesystem sandbox.
|
||||
// In WSL2 they also allow Windows process launch through an alias of
|
||||
// the interop socket, even when /run/WSL is masked. Keep ordinary
|
||||
// network access while denying that host bridge.
|
||||
(!file_system_sandbox_policy.has_full_disk_write_access())
|
||||
.then_some(NetworkSeccompMode::VmSocketRestricted)
|
||||
});
|
||||
|
||||
// `PR_SET_NO_NEW_PRIVS` is required for seccomp, but it also prevents
|
||||
// setuid privilege elevation. Many `bwrap` deployments rely on setuid, so
|
||||
@@ -91,6 +99,7 @@ pub(crate) fn apply_permission_profile_to_current_thread(
|
||||
enum NetworkSeccompMode {
|
||||
Restricted,
|
||||
ProxyRouted,
|
||||
VmSocketRestricted,
|
||||
}
|
||||
|
||||
fn should_install_network_seccomp(
|
||||
@@ -176,9 +185,13 @@ fn install_network_seccomp_filter_on_current_thread(
|
||||
// Build rule map.
|
||||
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
|
||||
|
||||
deny_syscall(&mut rules, libc::SYS_ptrace);
|
||||
deny_syscall(&mut rules, libc::SYS_process_vm_readv);
|
||||
deny_syscall(&mut rules, libc::SYS_process_vm_writev);
|
||||
if mode != NetworkSeccompMode::VmSocketRestricted {
|
||||
deny_syscall(&mut rules, libc::SYS_ptrace);
|
||||
deny_syscall(&mut rules, libc::SYS_process_vm_readv);
|
||||
deny_syscall(&mut rules, libc::SYS_process_vm_writev);
|
||||
}
|
||||
// io_uring can create AF_VSOCK sockets without a socket() syscall, so
|
||||
// keep it unavailable in every mode with socket-family restrictions.
|
||||
deny_syscall(&mut rules, libc::SYS_io_uring_setup);
|
||||
deny_syscall(&mut rules, libc::SYS_io_uring_enter);
|
||||
deny_syscall(&mut rules, libc::SYS_io_uring_register);
|
||||
@@ -245,6 +258,16 @@ fn install_network_seccomp_filter_on_current_thread(
|
||||
rules.insert(libc::SYS_socket, vec![deny_non_ip_socket]);
|
||||
rules.insert(libc::SYS_socketpair, vec![deny_non_unix_socketpair]);
|
||||
}
|
||||
NetworkSeccompMode::VmSocketRestricted => {
|
||||
let deny_vsock = SeccompRule::new(vec![SeccompCondition::new(
|
||||
0,
|
||||
SeccompCmpArgLen::Dword,
|
||||
SeccompCmpOp::Eq,
|
||||
libc::AF_VSOCK as u64,
|
||||
)?])?;
|
||||
rules.insert(libc::SYS_socket, vec![deny_vsock.clone()]);
|
||||
rules.insert(libc::SYS_socketpair, vec![deny_vsock]);
|
||||
}
|
||||
}
|
||||
|
||||
let filter = SeccompFilter::new(
|
||||
|
||||
@@ -22,6 +22,7 @@ use std::time::Duration;
|
||||
|
||||
use crate::bwrap::BwrapNetworkMode;
|
||||
use crate::bwrap::BwrapOptions;
|
||||
use crate::bwrap::WSL_INTEROP_DIR;
|
||||
use crate::bwrap::create_bwrap_command_args;
|
||||
use crate::landlock::apply_permission_profile_to_current_thread;
|
||||
use crate::launcher::exec_bwrap;
|
||||
@@ -186,7 +187,9 @@ pub fn run_main() -> ! {
|
||||
use_legacy_landlock,
|
||||
&file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
allow_network_for_proxy,
|
||||
&sandbox_policy_cwd,
|
||||
Path::new(WSL_INTEROP_DIR),
|
||||
);
|
||||
|
||||
// Inner stage: apply seccomp/no_new_privs after bubblewrap has already
|
||||
@@ -378,7 +381,9 @@ fn ensure_legacy_landlock_mode_supports_policy(
|
||||
use_legacy_landlock: bool,
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
allow_network_for_proxy: bool,
|
||||
sandbox_policy_cwd: &Path,
|
||||
wsl_interop_dir: &Path,
|
||||
) {
|
||||
if use_legacy_landlock
|
||||
&& file_system_sandbox_policy
|
||||
@@ -388,6 +393,18 @@ fn ensure_legacy_landlock_mode_supports_policy(
|
||||
"permission profiles requiring direct runtime enforcement are incompatible with --use-legacy-landlock"
|
||||
);
|
||||
}
|
||||
if use_legacy_landlock
|
||||
&& network_sandbox_policy.is_enabled()
|
||||
&& !allow_network_for_proxy
|
||||
&& !file_system_sandbox_policy.has_full_disk_write_access()
|
||||
// Interop can use another binfmt handler or a newly created socket.
|
||||
// An active endpoint probe would race with sandboxed command startup.
|
||||
&& wsl_interop_dir.is_dir()
|
||||
{
|
||||
panic!(
|
||||
"legacy Landlock cannot isolate WSL Windows interop with network access enabled; use bubblewrap or restrict network access"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_bwrap_with_proc_fallback(
|
||||
@@ -414,6 +431,8 @@ fn run_bwrap_with_proc_fallback(
|
||||
let options = BwrapOptions {
|
||||
mount_proc,
|
||||
network_mode,
|
||||
mask_wsl_interop: !file_system_sandbox_policy.has_full_disk_write_access()
|
||||
&& Path::new(WSL_INTEROP_DIR).is_dir(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut bwrap_args = build_bwrap_argv(
|
||||
|
||||
@@ -203,6 +203,48 @@ fn inserts_unshare_net_when_proxy_only_network_mode_requested() {
|
||||
assert!(argv.contains(&"--unshare-net".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_wsl_interop_with_full_network_and_restricted_filesystem() {
|
||||
let argv = build_bwrap_argv(
|
||||
vec!["/bin/true".to_string()],
|
||||
&read_only_file_system_policy(),
|
||||
Path::new("/"),
|
||||
Path::new("/"),
|
||||
BwrapOptions {
|
||||
network_mode: BwrapNetworkMode::FullAccess,
|
||||
mask_wsl_interop: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("build bwrap argv")
|
||||
.args;
|
||||
|
||||
assert!(argv.windows(2).any(|args| args == ["--tmpfs", "/run/WSL"]));
|
||||
assert!(!argv.iter().any(|arg| arg == "--unshare-net"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_inherited_procfs_when_wsl_interop_is_masked_without_fresh_proc() {
|
||||
let argv = build_bwrap_argv(
|
||||
vec!["/bin/true".to_string()],
|
||||
&read_only_file_system_policy(),
|
||||
Path::new("/"),
|
||||
Path::new("/"),
|
||||
BwrapOptions {
|
||||
mount_proc: false,
|
||||
network_mode: BwrapNetworkMode::FullAccess,
|
||||
mask_wsl_interop: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("build bwrap argv")
|
||||
.args;
|
||||
|
||||
assert!(argv.windows(2).any(|args| args == ["--tmpfs", "/run/WSL"]));
|
||||
assert!(argv.windows(2).any(|args| args == ["--tmpfs", "/proc"]));
|
||||
assert!(!argv.windows(2).any(|args| args == ["--proc", "/proc"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_only_mode_takes_precedence_over_full_network_policy() {
|
||||
let mode = bwrap_network_mode(
|
||||
@@ -673,13 +715,44 @@ fn legacy_landlock_rejects_split_only_filesystem_policies() {
|
||||
/*use_legacy_landlock*/ true,
|
||||
&policy,
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
/*allow_network_for_proxy*/ false,
|
||||
temp_dir.path(),
|
||||
&temp_dir.path().join("WSL"),
|
||||
);
|
||||
});
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_landlock_rejects_full_network_when_wsl_interop_is_available() {
|
||||
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
||||
let wsl_interop_dir = temp_dir.path().join("WSL");
|
||||
std::fs::create_dir(&wsl_interop_dir).expect("create interop directory");
|
||||
let policy = read_only_file_system_policy();
|
||||
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
ensure_legacy_landlock_mode_supports_policy(
|
||||
/*use_legacy_landlock*/ true,
|
||||
&policy,
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
/*allow_network_for_proxy*/ false,
|
||||
temp_dir.path(),
|
||||
&wsl_interop_dir,
|
||||
);
|
||||
});
|
||||
assert!(result.is_err());
|
||||
|
||||
ensure_legacy_landlock_mode_supports_policy(
|
||||
/*use_legacy_landlock*/ true,
|
||||
&policy,
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
/*allow_network_for_proxy*/ true,
|
||||
temp_dir.path(),
|
||||
&wsl_interop_dir,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_inner_stage_modes_do_not_panic() {
|
||||
ensure_inner_stage_mode_is_valid(
|
||||
|
||||
@@ -21,6 +21,8 @@ use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Output;
|
||||
use std::time::Duration;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
// At least on GitHub CI, the arm64 tests appear to need longer timeouts.
|
||||
@@ -251,6 +253,363 @@ fn expect_denied(
|
||||
}
|
||||
}
|
||||
|
||||
async fn wsl_windows_executable(name: &str) -> Option<String> {
|
||||
if !std::path::Path::new("/run/WSL").is_dir() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let windows_path = format!(r"C:\Windows\System32\{name}");
|
||||
let output = tokio::time::timeout(
|
||||
Duration::from_millis(NETWORK_TIMEOUT_MS),
|
||||
tokio::process::Command::new("wslpath")
|
||||
.args(["-u", &windows_path])
|
||||
.kill_on_drop(true)
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let path = String::from_utf8(output.stdout).ok()?.trim().to_string();
|
||||
(!path.is_empty()).then_some(path)
|
||||
}
|
||||
|
||||
async fn wsl_baseline_output(executable: &str, args: &[&str]) -> Option<Output> {
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(NETWORK_TIMEOUT_MS),
|
||||
tokio::process::Command::new(executable)
|
||||
.args(args)
|
||||
.kill_on_drop(true)
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
.ok()?
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wsl_interop_cannot_run_windows_program_with_network_access() {
|
||||
let Some(powershell) = wsl_windows_executable(r"WindowsPowerShell\v1.0\powershell.exe").await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let args = [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"Write-Output interop-escaped",
|
||||
];
|
||||
if wsl_baseline_output(&powershell, &args).await.is_none() {
|
||||
eprintln!("skipping WSL interop test: Windows interop is unavailable on the host");
|
||||
return;
|
||||
}
|
||||
if should_skip_bwrap_tests().await {
|
||||
eprintln!("skipping WSL interop test: bwrap sandbox prerequisites are unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
let output = expect_denied(
|
||||
run_cmd_result_with_writable_roots(
|
||||
&[&powershell, args[0], args[1], args[2], args[3]],
|
||||
&[],
|
||||
NETWORK_TIMEOUT_MS,
|
||||
/*use_legacy_landlock*/ false,
|
||||
/*network_access*/ true,
|
||||
)
|
||||
.await,
|
||||
"WSL interop must not start a Windows program inside a restricted filesystem sandbox",
|
||||
);
|
||||
assert!(!output.stdout.text.contains("interop-escaped"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wsl_interop_bind_alias_cannot_run_windows_program() {
|
||||
let Some(powershell) = wsl_windows_executable(r"WindowsPowerShell\v1.0\powershell.exe").await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if wsl_baseline_output(
|
||||
&powershell,
|
||||
&["-NoProfile", "-NonInteractive", "-Command", "exit 0"],
|
||||
)
|
||||
.await
|
||||
.is_none()
|
||||
|| should_skip_bwrap_tests().await
|
||||
|| !std::path::Path::new("/usr/bin/unshare").exists()
|
||||
|| !std::path::Path::new("/usr/bin/mount").exists()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Ok(interop_socket) = std::env::var("WSL_INTEROP") else {
|
||||
eprintln!("skipping WSL bind alias test: WSL_INTEROP is unavailable");
|
||||
return;
|
||||
};
|
||||
if !std::path::Path::new(&interop_socket).exists() {
|
||||
eprintln!("skipping WSL bind alias test: interop socket is unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
let mount_dir = tempfile::tempdir().expect("create mount target");
|
||||
let alias = mount_dir.path().join("WSL");
|
||||
std::fs::create_dir(&alias).expect("create interop bind target");
|
||||
let namespace_probe = tokio::time::timeout(
|
||||
Duration::from_millis(NETWORK_TIMEOUT_MS),
|
||||
tokio::process::Command::new("unshare")
|
||||
.args([
|
||||
"--user",
|
||||
"--map-root-user",
|
||||
"--mount",
|
||||
"--propagation",
|
||||
"private",
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"mount --bind /run/WSL \"$1\" && umount \"$1\"",
|
||||
"sh",
|
||||
])
|
||||
.arg(&alias)
|
||||
.kill_on_drop(true)
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
let Ok(Ok(probe)) = namespace_probe else {
|
||||
eprintln!("skipping WSL bind alias test: user/mount namespace probe could not run");
|
||||
return;
|
||||
};
|
||||
if !probe.status.success() {
|
||||
eprintln!(
|
||||
"skipping WSL bind alias test: user/mount namespace unavailable: {}",
|
||||
String::from_utf8_lossy(&probe.stderr)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let profile = PermissionProfile::from_runtime_permissions(
|
||||
&FileSystemSandboxPolicy::read_only(),
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
);
|
||||
let profile = serde_json::to_string(&profile).expect("serialize permission profile");
|
||||
let cwd = std::env::current_dir().expect("current directory");
|
||||
let script = r#"
|
||||
mount --bind /run/WSL "$1"
|
||||
trap 'umount "$1"' EXIT
|
||||
export WSL_INTEROP="$1/${WSL_INTEROP##*/}"
|
||||
"$2" -NoProfile -NonInteractive -Command 'Write-Output alias-baseline-ok'
|
||||
bash -c 'exec -a "$1" /init "$1" -NoProfile -NonInteractive -Command "Write-Output direct-init-baseline-ok"' bash "$2"
|
||||
"$3" --sandbox-policy-cwd "$4" --permission-profile "$5" -- \
|
||||
"$2" -NoProfile -NonInteractive -Command 'Write-Output interop-escaped'
|
||||
status=$?
|
||||
printf 'sandbox-exit=%s\n' "$status"
|
||||
if test "$status" -eq 0; then exit 1; fi
|
||||
"$3" --sandbox-policy-cwd "$4" --permission-profile "$5" -- \
|
||||
bash -c 'exec -a "$1" /init "$1" -NoProfile -NonInteractive -Command "Write-Output direct-init-escaped"' bash "$2"
|
||||
status=$?
|
||||
printf 'direct-init-exit=%s\n' "$status"
|
||||
test "$status" -ne 0
|
||||
"#;
|
||||
let output = tokio::time::timeout(
|
||||
Duration::from_millis(NETWORK_TIMEOUT_MS * 3),
|
||||
tokio::process::Command::new("unshare")
|
||||
.args([
|
||||
"--user",
|
||||
"--map-root-user",
|
||||
"--mount",
|
||||
"--propagation",
|
||||
"private",
|
||||
"--",
|
||||
])
|
||||
.args(["/bin/sh", "-c", script, "sh"])
|
||||
.arg(&alias)
|
||||
.arg(&powershell)
|
||||
.arg(codex_linux_sandbox_exe())
|
||||
.arg(&cwd)
|
||||
.arg(profile)
|
||||
.kill_on_drop(true)
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
.expect("WSL bind alias test should finish")
|
||||
.expect("unshare should start");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"WSL bind alias must be isolated: stdout={stdout} stderr={}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(stdout.contains("alias-baseline-ok"), "stdout={stdout}");
|
||||
assert!(
|
||||
stdout.contains("direct-init-baseline-ok"),
|
||||
"stdout={stdout}"
|
||||
);
|
||||
assert!(stdout.contains("sandbox-exit="), "stdout={stdout}");
|
||||
assert!(stdout.contains("direct-init-exit="), "stdout={stdout}");
|
||||
assert!(!stdout.contains("interop-escaped"), "stdout={stdout}");
|
||||
assert!(!stdout.contains("direct-init-escaped"), "stdout={stdout}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wsl_no_proc_masks_inherited_procfs_and_windows_interop() {
|
||||
let Some(powershell) = wsl_windows_executable(r"WindowsPowerShell\v1.0\powershell.exe").await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let args = [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"Write-Output interop-escaped",
|
||||
];
|
||||
if wsl_baseline_output(&powershell, &args).await.is_none() || should_skip_bwrap_tests().await {
|
||||
return;
|
||||
}
|
||||
|
||||
let profile = PermissionProfile::from_runtime_permissions(
|
||||
&FileSystemSandboxPolicy::read_only(),
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
);
|
||||
let profile = serde_json::to_string(&profile).expect("serialize permission profile");
|
||||
let cwd = std::env::current_dir().expect("current directory");
|
||||
let proc_check = tokio::time::timeout(
|
||||
Duration::from_millis(NETWORK_TIMEOUT_MS),
|
||||
tokio::process::Command::new(codex_linux_sandbox_exe())
|
||||
.arg("--sandbox-policy-cwd")
|
||||
.arg(&cwd)
|
||||
.args([
|
||||
"--permission-profile",
|
||||
&profile,
|
||||
"--no-proc",
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"test ! -e /proc/1",
|
||||
])
|
||||
.kill_on_drop(true)
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
.expect("procfs check should finish")
|
||||
.expect("sandbox helper should start");
|
||||
assert!(
|
||||
proc_check.status.success(),
|
||||
"inherited procfs was not masked: {}",
|
||||
String::from_utf8_lossy(&proc_check.stderr)
|
||||
);
|
||||
|
||||
let output = tokio::time::timeout(
|
||||
Duration::from_millis(NETWORK_TIMEOUT_MS),
|
||||
tokio::process::Command::new(codex_linux_sandbox_exe())
|
||||
.arg("--sandbox-policy-cwd")
|
||||
.arg(&cwd)
|
||||
.args([
|
||||
"--permission-profile",
|
||||
&profile,
|
||||
"--no-proc",
|
||||
"--",
|
||||
&powershell,
|
||||
])
|
||||
.args(args)
|
||||
.kill_on_drop(true)
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
.expect("sandbox command should finish")
|
||||
.expect("sandbox helper should start");
|
||||
assert!(!output.status.success());
|
||||
assert!(!String::from_utf8_lossy(&output.stdout).contains("interop-escaped"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wsl_interop_cannot_reenter_distro_as_root_with_network_access() {
|
||||
let Some(wsl) = wsl_windows_executable("wsl.exe").await else {
|
||||
return;
|
||||
};
|
||||
let Ok(distro) = std::env::var("WSL_DISTRO_NAME") else {
|
||||
eprintln!("skipping WSL root test: distro name is unavailable");
|
||||
return;
|
||||
};
|
||||
let args = ["-d", &distro, "-u", "root", "--", "/usr/bin/id", "-u"];
|
||||
let Some(baseline) = wsl_baseline_output(&wsl, &args).await else {
|
||||
eprintln!("skipping WSL root test: Windows interop is unavailable on the host");
|
||||
return;
|
||||
};
|
||||
assert_eq!(baseline.stdout, b"0\n");
|
||||
if should_skip_bwrap_tests().await {
|
||||
eprintln!("skipping WSL root test: bwrap sandbox prerequisites are unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
let output = expect_denied(
|
||||
run_cmd_result_with_writable_roots(
|
||||
&[
|
||||
&wsl, args[0], args[1], args[2], args[3], args[4], args[5], args[6],
|
||||
],
|
||||
&[],
|
||||
NETWORK_TIMEOUT_MS,
|
||||
/*use_legacy_landlock*/ false,
|
||||
/*network_access*/ true,
|
||||
)
|
||||
.await,
|
||||
"WSL interop must not start a new root session inside a restricted filesystem sandbox",
|
||||
);
|
||||
assert_ne!(output.stdout.text.trim(), "0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_landlock_rejects_wsl_interop_with_network_access() {
|
||||
let Some(powershell) = wsl_windows_executable(r"WindowsPowerShell\v1.0\powershell.exe").await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if wsl_baseline_output(
|
||||
&powershell,
|
||||
&["-NoProfile", "-NonInteractive", "-Command", "exit 0"],
|
||||
)
|
||||
.await
|
||||
.is_none()
|
||||
{
|
||||
eprintln!("skipping legacy WSL interop test: Windows interop is unavailable on the host");
|
||||
return;
|
||||
}
|
||||
|
||||
let safe_profile = PermissionProfile::from_runtime_permissions(
|
||||
&FileSystemSandboxPolicy::read_only(),
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
);
|
||||
let linux_output = run_cmd_result_with_permission_profile(
|
||||
&["/bin/true"],
|
||||
safe_profile,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
/*use_legacy_landlock*/ true,
|
||||
)
|
||||
.await
|
||||
.expect("legacy Landlock should run a Linux command");
|
||||
assert_eq!(linux_output.exit_code, 0);
|
||||
|
||||
let unsafe_profile = PermissionProfile::from_runtime_permissions(
|
||||
&FileSystemSandboxPolicy::read_only(),
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
);
|
||||
let output = expect_denied(
|
||||
run_cmd_result_with_permission_profile(
|
||||
&["/bin/true"],
|
||||
unsafe_profile,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
/*use_legacy_landlock*/ true,
|
||||
)
|
||||
.await,
|
||||
"legacy Landlock must reject restricted filesystem access with full network access on WSL",
|
||||
);
|
||||
assert!(
|
||||
output
|
||||
.stderr
|
||||
.text
|
||||
.contains("legacy Landlock cannot isolate WSL Windows interop")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_root_read() {
|
||||
run_cmd(&["ls", "-l", "/bin"], &[], SHORT_TIMEOUT_MS).await;
|
||||
|
||||
Reference in New Issue
Block a user