Skip to content
Merged
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ jobs:
windows::tests::appcontainer_denies_reads_and_writes_outside_granted_roots
windows::tests::appcontainer_denies_verbatim_path_reads_outside_granted_roots
windows::tests::appcontainer_denies_parent_traversal_reads_outside_granted_roots
windows::tests::appcontainer_denies_short_name_alias_reads_outside_granted_roots
windows::tests::appcontainer_child_cannot_connect_to_host_loopback
windows::tests::live_appcontainer_memory_limit_blocks_over_budget_allocation
)
Expand Down Expand Up @@ -151,6 +152,7 @@ jobs:
windows::tests::appcontainer_owned_termination_stops_descendants_after_parent_exit
windows::tests::appcontainer_untyped_owned_launch_does_not_inherit_the_host_environment
windows::tests::appcontainer_denies_reads_and_writes_outside_granted_roots
windows::tests::appcontainer_denies_short_name_alias_reads_outside_granted_roots
windows::tests::appcontainer_child_cannot_connect_to_host_loopback
windows::tests::live_appcontainer_memory_limit_blocks_over_budget_allocation
)
Expand Down
194 changes: 194 additions & 0 deletions crates/tinybox-jail/src/windows_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,184 @@ fn appcontainer_denies_parent_traversal_reads_outside_granted_roots() -> io::Res
Ok(())
}

#[test]
fn appcontainer_denies_short_name_alias_reads_outside_granted_roots() -> io::Result<()> {
const PROBE_ENV: &str = "TINYBOX_APPCONTAINER_SHORT_NAME_READ_PROBE";
const SECRET: &str = "outside-short-name-secret";

let workspace = tempfile::tempdir()?;
let granted_root = workspace.path().join("granted");
let outside_root = workspace.path().join("sibling directory with long name");
std::fs::create_dir(&granted_root)?;
std::fs::create_dir(&outside_root)?;
let secret = outside_root.join("outside secret with a long filename.txt");
std::fs::write(&secret, SECRET)?;

let short_path = short_path_with_get_short_path_name(&secret)?;
assert!(
!short_path.is_empty(),
"GetShortPathNameW returned an empty path"
);
let short_filename = Path::new(&short_path)
.file_name()
.map(OsStr::to_string_lossy)
.unwrap_or_default();
assert!(
short_filename.contains('~'),
"the Windows runner must produce an 8.3 alias for the secret filename; got {short_path:?}"
);
assert_eq!(
std::fs::read_to_string(&short_path)?,
SECRET,
"the host must resolve the short-name alias before testing AppContainer denial"
);

let (status, output, error_output) =
appcontainer_short_name_read_probe(&granted_root, PROBE_ENV, &short_path)?;

assert!(
status,
"short-name child failed: status={status:?}, stdout={output:?}, stderr={error_output:?}"
);
assert!(
output.contains("SHORT_NAME_READ_DENIED"),
"AppContainer must deny the short-name alias outside granted roots: stdout={output:?}, stderr={error_output:?}"
);
assert!(
!output.contains(SECRET),
"AppContainer child read the outside secret through an 8.3 alias"
);
Ok(())
}

fn short_path_with_get_short_path_name(path: &Path) -> io::Result<String> {
use std::process::Command;

let script = r#"
$ErrorActionPreference = 'Stop'
Add-Type -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
public static class TinyBoxShortPath {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetShortPathNameW(string longPath, StringBuilder shortPath, uint bufferLength);
public static string Get(string longPath) {
uint required = GetShortPathNameW(longPath, null, 0);
if (required == 0) throw new Win32Exception(Marshal.GetLastWin32Error());
var buffer = new StringBuilder(checked((int)required + 1));
uint written = GetShortPathNameW(longPath, buffer, (uint)buffer.Capacity);
if (written == 0) throw new Win32Exception(Marshal.GetLastWin32Error());
if (written >= buffer.Capacity) throw new InvalidOperationException("short path buffer was too small");
return buffer.ToString();
}
}
'@
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
[Console]::Write([TinyBoxShortPath]::Get($env:TINYBOX_SHORT_NAME_TARGET))
"#;
let output = Command::new("powershell.exe")
.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
script,
])
.env("TINYBOX_SHORT_NAME_TARGET", path)
.output()?;
if !output.status.success() {
return Err(io::Error::other(format!(
"GetShortPathNameW failed: status={:?}, stderr={}",
output.status,
String::from_utf8_lossy(&output.stderr)
)));
}
String::from_utf8(output.stdout)
.map(|path| path.trim().to_owned())
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
}

fn appcontainer_short_name_read_probe(
root: &Path,
probe_env: &str,
short_path: &str,
) -> io::Result<(bool, String, String)> {
use std::io::Read;
use std::time::{Duration, Instant};
use tinybox_core::ExecRequest;

let executable = std::env::current_exe()?;
let executable_arg = executable.to_string_lossy().into_owned();
let executable_directory = executable
.parent()
.ok_or_else(|| io::Error::other("test executable has no parent directory"))?;
let invocation = JailInvocation::new(
ExecRequest::new([
executable_arg.as_str(),
"--exact",
"windows::tests::appcontainer_short_name_read_child_probe",
"--nocapture",
])
.with_cwd(root)
.with_env(probe_env, short_path),
true,
None,
);
let jail = Jail::new(root, "short-name-path-denial-test")
.add_read_only(executable_directory)
.deny_net();
let mut child = crate::spawn_owned_invocation_captured_with(
&AppContainerBackend::new(),
&jail,
&invocation,
)?;
let stdout = child
.take_stdout()
.ok_or_else(|| io::Error::other("captured stdout missing"))?;
let stderr = child
.take_stderr()
.ok_or_else(|| io::Error::other("captured stderr missing"))?;
let stdout_reader = std::thread::spawn(move || {
let mut output = String::new();
stdout
.take(64 * 1024)
.read_to_string(&mut output)
.map(|_| output)
});
let stderr_reader = std::thread::spawn(move || {
let mut output = String::new();
stderr
.take(64 * 1024)
.read_to_string(&mut output)
.map(|_| output)
});
let deadline = Instant::now() + Duration::from_secs(10);
let status = loop {
if let Some(status) = child.try_wait()? {
break status;
}
if Instant::now() >= deadline {
child.terminate()?;
let _ = stdout_reader.join();
let _ = stderr_reader.join();
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"AppContainer short-name probe exceeded 10 seconds",
));
}
std::thread::sleep(Duration::from_millis(25));
};
let output = stdout_reader
.join()
.map_err(|_| io::Error::other("stdout reader thread panicked"))??;
let error_output = stderr_reader
.join()
.map_err(|_| io::Error::other("stderr reader thread panicked"))??;
Ok((status.success(), output, error_output))
}

#[test]
fn appcontainer_parent_traversal_read_child_probe() {
const PROBE_ENV: &str = "TINYBOX_APPCONTAINER_PARENT_READ_PROBE";
Expand All @@ -805,6 +983,22 @@ fn appcontainer_parent_traversal_read_child_probe() {
}
}

#[test]
fn appcontainer_short_name_read_child_probe() {
const PROBE_ENV: &str = "TINYBOX_APPCONTAINER_SHORT_NAME_READ_PROBE";
let Some(path) = std::env::var_os(PROBE_ENV) else {
return;
};

match std::fs::read(path) {
Ok(contents) => println!(
"SHORT_NAME_READ_ALLOWED:{}",
String::from_utf8_lossy(&contents)
),
Err(error) => println!("SHORT_NAME_READ_DENIED:{error}"),
}
}

#[test]
fn appcontainer_verbatim_path_read_child_probe() {
const PROBE_ENV: &str = "TINYBOX_APPCONTAINER_VERBATIM_READ_PROBE";
Expand Down
Loading