Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ terminal-colorsaurus = "1.0"
miette = { version = "7", features = ["fancy"] }
thiserror = "2"

# Windows platform APIs (ETW/TDH audit consumer in openshell-driver-mxc; Windows-only)
windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_System_Diagnostics_Etw", "Win32_System_Time"] }
# Windows platform APIs (MXC audit and host-proxy process identity; Windows-only)
windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_System_Diagnostics_Etw", "Win32_System_Threading", "Win32_System_Time"] }
anyhow = "1"

# Logging/Tracing
Expand Down
9 changes: 0 additions & 9 deletions crates/openshell-driver-mxc/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -508,14 +508,6 @@ fn allocate_sandbox_proxy_addr(
const MINIMAL_WINDOWS_BOOTSTRAP_ENV: [&str; 5] =
["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"];

fn host_proxy_binary_path(config: &MxcSandboxConfig) -> PathBuf {
config
.command
.first()
.filter(|command| !command.trim().is_empty())
.map_or_else(|| PathBuf::from("mxc-agent"), PathBuf::from)
}

const TLS_ENV_KEYS: [&str; 6] = [
"NODE_EXTRA_CA_CERTS",
"DENO_CERT",
Expand Down Expand Up @@ -1461,7 +1453,6 @@ async fn run_lifecycle(
openshell_supervisor_network::host::HostProxyConfig {
bind_addr: addr,
policy: proxy_policy,
binary_path: host_proxy_binary_path(&sandbox_config),
client_auth: proxy_auth.host_client_auth(),
sandbox_id: Some(sandbox_id.clone()),
sandbox_name: Some(sandbox_name.clone()),
Expand Down
165 changes: 165 additions & 0 deletions crates/openshell-driver-mxc/tests/wxc_exec_real.rs
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,171 @@ async fn pc_https_egress_reads_injected_ca_bundle() {
);
}

/// Prove that host-proxy binary policy follows the process that owns each TCP
/// connection, rather than the sandbox entry command. This deliberately uses
/// L4 CONNECT policy so the assertion is independent of TLS/L7 enforcement.
#[tokio::test]
#[ignore = "requires real wxc-exec and outbound HTTPS"]
async fn pc_proxy_scopes_network_policy_to_socket_owner() {
let Some(wxc) = wxc_path() else {
eprintln!("SKIP: wxc-exec not found");
return;
};
if let Err(reason) = probe_processcontainer(&wxc) {
eprintln!("SKIP: processcontainer not live: {reason}");
return;
}

// QueryFullProcessImageNameW returns this Win32 spelling on the Windows
// test image. Keep the spelling exact here; path and case normalization
// are covered separately.
let cmd = PathBuf::from(r"C:\Windows\System32\cmd.exe");
let curl = PathBuf::from(r"C:\Windows\System32\curl.exe");
if !cmd.exists() || !curl.exists() {
eprintln!(
"SKIP: expected Windows binaries are absent (cmd={}, curl={})",
cmd.display(),
curl.display()
);
return;
}

let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
run_proxy_binary_scope_case(&wxc, "pc-owner-allow-child", &cmd, &curl, &curl, true).await;
run_proxy_binary_scope_case(&wxc, "pc-owner-deny-child", &cmd, &curl, &cmd, false).await;
}

async fn run_proxy_binary_scope_case(
wxc: &Path,
sandbox_id: &str,
cmd: &Path,
curl: &Path,
allowed_binary: &Path,
expect_allowed: bool,
) {
let output_dir = tempfile::tempdir().expect("proxy scope output directory");
let output_path = output_dir.path().join("example.html");
let diagnostic_path = output_dir.path().join("curl-diagnostic.txt");
let output_dir_string = output_dir.path().to_string_lossy().into_owned();
let command = vec![
cmd.to_string_lossy().into_owned(),
"/d".to_string(),
"/c".to_string(),
format!(
"echo proxy-scope 1>\"{}\" && \"{}\" --fail --silent --show-error --ssl-no-revoke --cacert \"%CURL_CA_BUNDLE%\" https://example.com/ --output \"{}\" 2>>\"{}\"",
diagnostic_path.display(),
curl.display(),
output_path.display(),
diagnostic_path.display()
),
];
let serde_json::Value::Object(driver_config) = serde_json::json!({
"command": command,
"cwd": output_dir_string,
}) else {
unreachable!();
};
let policy = SandboxPolicy {
version: 1,
filesystem: Some(FilesystemPolicy {
include_workdir: false,
read_only: Vec::new(),
read_write: vec![output_dir_string],
}),
network_policies: std::collections::HashMap::from([(
"https_example".to_string(),
NetworkPolicyRule {
name: "https-example".to_string(),
endpoints: vec![NetworkEndpoint {
host: "example.com".to_string(),
ports: vec![443],
..Default::default()
}],
binaries: vec![NetworkBinary {
path: allowed_binary.to_string_lossy().into_owned(),
}],
},
)]),
..Default::default()
};
let sandbox = DriverSandbox {
id: sandbox_id.to_string(),
name: sandbox_id.to_string(),
spec: Some(DriverSandboxSpec {
template: Some(DriverSandboxTemplate {
driver_config: Some(
openshell_core::proto_struct::json_object_to_struct(driver_config)
.expect("driver config"),
),
..Default::default()
}),
policy: Some(policy),
..Default::default()
}),
..Default::default()
};
let backend = MxcComputeBackend::new(MxcComputeConfig {
wxc_exec_path: wxc.to_string_lossy().into_owned(),
egress_proxy: true,
egress_proxy_addr: "127.0.0.1:18080".to_string(),
..Default::default()
});
backend
.create_sandbox(&sandbox)
.await
.expect("real proxy-scope sandbox create accepted");

let mut terminal_condition = None;
for _ in 0..600 {
if let Some(observed) = backend.get_sandbox(sandbox_id).await
&& let Some(condition) = observed
.status
.and_then(|status| status.conditions.into_iter().find(|c| c.r#type == "Ready"))
&& matches!(
condition.reason.as_str(),
"AgentCompleted" | "ExecFailed" | "ProvisionFailed"
)
{
terminal_condition = Some(condition);
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
let condition = terminal_condition.expect("proxy-scope sandbox should terminate");
let diagnostic = std::fs::read_to_string(&diagnostic_path)
.unwrap_or_else(|error| format!("failed to read curl diagnostic: {error}"));
backend
.delete_sandbox(sandbox_id, sandbox_id)
.await
.expect("delete completed proxy-scope sandbox");

if expect_allowed {
assert_eq!(
condition.reason, "AgentCompleted",
"declared child binary must be allowed: {}; diagnostic: {diagnostic}",
condition.message
);
assert!(
std::fs::metadata(&output_path).is_ok_and(|metadata| metadata.len() > 0),
"allowed curl response should be non-empty; diagnostic: {diagnostic}"
);
} else {
assert_eq!(
condition.reason, "ExecFailed",
"entry-command grant must not be inherited by curl: {}; diagnostic: {diagnostic}",
condition.message
);
assert!(
diagnostic.contains("403"),
"undeclared curl child should receive proxy 403; diagnostic: {diagnostic}"
);
assert!(
!output_path.exists(),
"denied curl child must not write an HTTPS response"
);
}
}

/// Write to a path OUTSIDE the granted dir; assert exit non-zero and file absent.
/// This is the genuine OS default-deny proof — the `AppContainer` blocks the write
/// without requiring any host ACL lockdown. The mock can only fake this.
Expand Down
3 changes: 3 additions & 0 deletions crates/openshell-supervisor-network/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ tokio-stream = { workspace = true, features = ["net"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"

[target.'cfg(target_os = "windows")'.dependencies]
windows = { workspace = true }

[target.'cfg(unix)'.dev-dependencies]

[lints]
Expand Down
48 changes: 14 additions & 34 deletions crates/openshell-supervisor-network/src/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,6 @@ pub struct HostProxyConfig {
pub bind_addr: SocketAddr,
/// Network-only policy produced by the compute driver's policy split.
pub policy: ProtoSandboxPolicy,
/// Static process identity used when the platform cannot recover the
/// socket-owning sandbox process. Policy binaries must match this path for
/// L4/L7 allow rules to pass.
pub binary_path: PathBuf,
/// Per-sandbox client authentication. Host-side MXC proxies must set this
/// so another sandbox cannot borrow this proxy's identity and policy.
pub client_auth: HostProxyClientAuth,
Expand Down Expand Up @@ -215,10 +211,9 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result<HostProxyHandle
(None, None, None)
}
};
let identity_mode = ProxyIdentityMode::static_binary_with_client_auth(
config.binary_path,
Some(config.client_auth.expected_proxy_authorization),
)?;
let identity_mode = ProxyIdentityMode::windows_with_client_auth(Some(
config.client_auth.expected_proxy_authorization,
));
let proxy = ProxyHandle::start_with_bind_addr(
&proxy_policy,
Some(config.bind_addr),
Expand Down Expand Up @@ -256,14 +251,13 @@ mod tests {

use super::*;

fn test_config(bind_addr: SocketAddr, binary_path: PathBuf) -> HostProxyConfig {
fn test_config(bind_addr: SocketAddr) -> HostProxyConfig {
HostProxyConfig {
bind_addr,
policy: ProtoSandboxPolicy {
version: 1,
..Default::default()
},
binary_path,
client_auth: HostProxyClientAuth::basic("openshell", "test-secret"),
sandbox_id: Some("sandbox-123".to_string()),
sandbox_name: Some("agent-box".to_string()),
Expand Down Expand Up @@ -307,7 +301,9 @@ mod tests {
let mut client = TcpStream::connect(addr).await.unwrap();
client.write_all(request.as_bytes()).await.unwrap();
let mut response = Vec::new();
tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut response))
// The first authenticated CONNECT performs a full executable hash for
// TOFU identity binding; debug test binaries can be hundreds of MB.
tokio::time::timeout(Duration::from_secs(10), client.read_to_end(&mut response))
.await
.unwrap()
.unwrap();
Expand All @@ -316,11 +312,7 @@ mod tests {

#[tokio::test]
async fn rejects_non_loopback_bind_addr() {
let result = start_host_proxy(test_config(
([192, 0, 2, 1], 0).into(),
PathBuf::from("missing-agent.exe"),
))
.await;
let result = start_host_proxy(test_config(([192, 0, 2, 1], 0).into())).await;

let Err(err) = result else {
panic!("host proxy should reject non-loopback bind addresses");
Expand All @@ -333,10 +325,7 @@ mod tests {

#[tokio::test]
async fn rejects_middleware_policy_without_registry() {
let mut config = test_config(
([127, 0, 0, 1], 0).into(),
PathBuf::from("missing-agent.exe"),
);
let mut config = test_config(([127, 0, 0, 1], 0).into());
config.policy.network_middlewares.insert(
"redactor".into(),
NetworkMiddlewareConfig {
Expand Down Expand Up @@ -365,15 +354,9 @@ mod tests {
#[tokio::test]
async fn starts_loopback_proxy_and_serves_policy_local() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let binary = tempfile::NamedTempFile::new().unwrap();
std::fs::write(binary.path(), b"agent").unwrap();

let handle = start_host_proxy(test_config(
([127, 0, 0, 1], 0).into(),
binary.path().to_path_buf(),
))
.await
.unwrap();
let handle = start_host_proxy(test_config(([127, 0, 0, 1], 0).into()))
.await
.unwrap();

let addr = handle.http_addr().expect("proxy should report bound addr");
assert!(addr.ip().is_loopback());
Expand Down Expand Up @@ -413,9 +396,6 @@ mod tests {

#[tokio::test]
async fn per_sandbox_credentials_reject_missing_wrong_cross_and_duplicate_auth() {
let binary = tempfile::NamedTempFile::new().unwrap();
std::fs::write(binary.path(), b"agent").unwrap();

let auth_a = HostProxyClientAuth::basic("openshell", "sandbox-a-secret");
let auth_b = HostProxyClientAuth::basic("openshell", "sandbox-b-secret");
// Node's EnvHttpProxyAgent currently emits the field name in lower
Expand All @@ -429,11 +409,11 @@ mod tests {
auth_b.expected_proxy_authorization
);

let mut config_a = test_config(([127, 0, 0, 1], 0).into(), binary.path().to_path_buf());
let mut config_a = test_config(([127, 0, 0, 1], 0).into());
config_a.client_auth = auth_a;
let proxy_a = start_host_proxy(config_a).await.unwrap();

let mut config_b = test_config(([127, 0, 0, 1], 0).into(), binary.path().to_path_buf());
let mut config_b = test_config(([127, 0, 0, 1], 0).into());
config_b.client_auth = auth_b;
let proxy_b = start_host_proxy(config_b).await.unwrap();

Expand Down
2 changes: 2 additions & 0 deletions crates/openshell-supervisor-network/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ pub mod run;
pub mod sigv4;
mod token_grant;
pub mod upstream_proxy;
#[cfg(target_os = "windows")]
pub(crate) mod windows_process;

#[cfg(test)]
pub(crate) mod test_alloc {
Expand Down
Loading
Loading