Skip to content

Commit 95956b0

Browse files
committed
fix(network): scope Windows egress by socket owner (NVBug 6783370)
Resolve each accepted Windows proxy connection to its unique owning PID and executable before evaluating binary-scoped network policy. Fail closed when ownership or process identity cannot be established, and cover allowed and undeclared child processes with a real MXC regression.
1 parent 4f06e23 commit 95956b0

10 files changed

Lines changed: 590 additions & 59 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ terminal-colorsaurus = "1.0"
5656
miette = { version = "7", features = ["fancy"] }
5757
thiserror = "2"
5858

59-
# Windows platform APIs (ETW/TDH audit consumer in openshell-driver-mxc; Windows-only)
60-
windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_System_Diagnostics_Etw", "Win32_System_Time"] }
59+
# Windows platform APIs (MXC audit and host-proxy process identity; Windows-only)
60+
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"] }
6161
anyhow = "1"
6262

6363
# Logging/Tracing

‎crates/openshell-driver-mxc/src/driver.rs‎

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -508,14 +508,6 @@ fn allocate_sandbox_proxy_addr(
508508
const MINIMAL_WINDOWS_BOOTSTRAP_ENV: [&str; 5] =
509509
["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"];
510510

511-
fn host_proxy_binary_path(config: &MxcSandboxConfig) -> PathBuf {
512-
config
513-
.command
514-
.first()
515-
.filter(|command| !command.trim().is_empty())
516-
.map_or_else(|| PathBuf::from("mxc-agent"), PathBuf::from)
517-
}
518-
519511
const TLS_ENV_KEYS: [&str; 6] = [
520512
"NODE_EXTRA_CA_CERTS",
521513
"DENO_CERT",
@@ -1461,7 +1453,6 @@ async fn run_lifecycle(
14611453
openshell_supervisor_network::host::HostProxyConfig {
14621454
bind_addr: addr,
14631455
policy: proxy_policy,
1464-
binary_path: host_proxy_binary_path(&sandbox_config),
14651456
client_auth: proxy_auth.host_client_auth(),
14661457
sandbox_id: Some(sandbox_id.clone()),
14671458
sandbox_name: Some(sandbox_name.clone()),

‎crates/openshell-driver-mxc/tests/wxc_exec_real.rs‎

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -961,6 +961,171 @@ async fn pc_https_egress_reads_injected_ca_bundle() {
961961
);
962962
}
963963

964+
/// Prove that host-proxy binary policy follows the process that owns each TCP
965+
/// connection, rather than the sandbox entry command. This deliberately uses
966+
/// L4 CONNECT policy so the assertion is independent of TLS/L7 enforcement.
967+
#[tokio::test]
968+
#[ignore = "requires real wxc-exec and outbound HTTPS"]
969+
async fn pc_proxy_scopes_network_policy_to_socket_owner() {
970+
let Some(wxc) = wxc_path() else {
971+
eprintln!("SKIP: wxc-exec not found");
972+
return;
973+
};
974+
if let Err(reason) = probe_processcontainer(&wxc) {
975+
eprintln!("SKIP: processcontainer not live: {reason}");
976+
return;
977+
}
978+
979+
// QueryFullProcessImageNameW returns this Win32 spelling on the Windows
980+
// test image. Keep the spelling exact here: case normalization is covered
981+
// separately by NVBug 6782969.
982+
let cmd = PathBuf::from(r"C:\Windows\System32\cmd.exe");
983+
let curl = PathBuf::from(r"C:\Windows\System32\curl.exe");
984+
if !cmd.exists() || !curl.exists() {
985+
eprintln!(
986+
"SKIP: expected Windows binaries are absent (cmd={}, curl={})",
987+
cmd.display(),
988+
curl.display()
989+
);
990+
return;
991+
}
992+
993+
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
994+
run_proxy_binary_scope_case(&wxc, "pc-owner-allow-child", &cmd, &curl, &curl, true).await;
995+
run_proxy_binary_scope_case(&wxc, "pc-owner-deny-child", &cmd, &curl, &cmd, false).await;
996+
}
997+
998+
async fn run_proxy_binary_scope_case(
999+
wxc: &Path,
1000+
sandbox_id: &str,
1001+
cmd: &Path,
1002+
curl: &Path,
1003+
allowed_binary: &Path,
1004+
expect_allowed: bool,
1005+
) {
1006+
let output_dir = tempfile::tempdir().expect("proxy scope output directory");
1007+
let output_path = output_dir.path().join("example.html");
1008+
let diagnostic_path = output_dir.path().join("curl-diagnostic.txt");
1009+
let output_dir_string = output_dir.path().to_string_lossy().into_owned();
1010+
let command = vec![
1011+
cmd.to_string_lossy().into_owned(),
1012+
"/d".to_string(),
1013+
"/c".to_string(),
1014+
format!(
1015+
"echo proxy-scope 1>\"{}\" && \"{}\" --fail --silent --show-error --ssl-no-revoke --cacert \"%CURL_CA_BUNDLE%\" https://example.com/ --output \"{}\" 2>>\"{}\"",
1016+
diagnostic_path.display(),
1017+
curl.display(),
1018+
output_path.display(),
1019+
diagnostic_path.display()
1020+
),
1021+
];
1022+
let serde_json::Value::Object(driver_config) = serde_json::json!({
1023+
"command": command,
1024+
"cwd": output_dir_string,
1025+
}) else {
1026+
unreachable!();
1027+
};
1028+
let policy = SandboxPolicy {
1029+
version: 1,
1030+
filesystem: Some(FilesystemPolicy {
1031+
include_workdir: false,
1032+
read_only: Vec::new(),
1033+
read_write: vec![output_dir_string],
1034+
}),
1035+
network_policies: std::collections::HashMap::from([(
1036+
"https_example".to_string(),
1037+
NetworkPolicyRule {
1038+
name: "https-example".to_string(),
1039+
endpoints: vec![NetworkEndpoint {
1040+
host: "example.com".to_string(),
1041+
ports: vec![443],
1042+
..Default::default()
1043+
}],
1044+
binaries: vec![NetworkBinary {
1045+
path: allowed_binary.to_string_lossy().into_owned(),
1046+
}],
1047+
},
1048+
)]),
1049+
..Default::default()
1050+
};
1051+
let sandbox = DriverSandbox {
1052+
id: sandbox_id.to_string(),
1053+
name: sandbox_id.to_string(),
1054+
spec: Some(DriverSandboxSpec {
1055+
template: Some(DriverSandboxTemplate {
1056+
driver_config: Some(
1057+
openshell_core::proto_struct::json_object_to_struct(driver_config)
1058+
.expect("driver config"),
1059+
),
1060+
..Default::default()
1061+
}),
1062+
policy: Some(policy),
1063+
..Default::default()
1064+
}),
1065+
..Default::default()
1066+
};
1067+
let backend = MxcComputeBackend::new(MxcComputeConfig {
1068+
wxc_exec_path: wxc.to_string_lossy().into_owned(),
1069+
egress_proxy: true,
1070+
egress_proxy_addr: "127.0.0.1:18080".to_string(),
1071+
..Default::default()
1072+
});
1073+
backend
1074+
.create_sandbox(&sandbox)
1075+
.await
1076+
.expect("real proxy-scope sandbox create accepted");
1077+
1078+
let mut terminal_condition = None;
1079+
for _ in 0..600 {
1080+
if let Some(observed) = backend.get_sandbox(sandbox_id).await
1081+
&& let Some(condition) = observed
1082+
.status
1083+
.and_then(|status| status.conditions.into_iter().find(|c| c.r#type == "Ready"))
1084+
&& matches!(
1085+
condition.reason.as_str(),
1086+
"AgentCompleted" | "ExecFailed" | "ProvisionFailed"
1087+
)
1088+
{
1089+
terminal_condition = Some(condition);
1090+
break;
1091+
}
1092+
tokio::time::sleep(Duration::from_millis(100)).await;
1093+
}
1094+
let condition = terminal_condition.expect("proxy-scope sandbox should terminate");
1095+
let diagnostic = std::fs::read_to_string(&diagnostic_path)
1096+
.unwrap_or_else(|error| format!("failed to read curl diagnostic: {error}"));
1097+
backend
1098+
.delete_sandbox(sandbox_id, sandbox_id)
1099+
.await
1100+
.expect("delete completed proxy-scope sandbox");
1101+
1102+
if expect_allowed {
1103+
assert_eq!(
1104+
condition.reason, "AgentCompleted",
1105+
"declared child binary must be allowed: {}; diagnostic: {diagnostic}",
1106+
condition.message
1107+
);
1108+
assert!(
1109+
std::fs::metadata(&output_path).is_ok_and(|metadata| metadata.len() > 0),
1110+
"allowed curl response should be non-empty; diagnostic: {diagnostic}"
1111+
);
1112+
} else {
1113+
assert_eq!(
1114+
condition.reason, "ExecFailed",
1115+
"entry-command grant must not be inherited by curl: {}; diagnostic: {diagnostic}",
1116+
condition.message
1117+
);
1118+
assert!(
1119+
diagnostic.contains("403"),
1120+
"undeclared curl child should receive proxy 403; diagnostic: {diagnostic}"
1121+
);
1122+
assert!(
1123+
!output_path.exists(),
1124+
"denied curl child must not write an HTTPS response"
1125+
);
1126+
}
1127+
}
1128+
9641129
/// Write to a path OUTSIDE the granted dir; assert exit non-zero and file absent.
9651130
/// This is the genuine OS default-deny proof — the `AppContainer` blocks the write
9661131
/// without requiring any host ACL lockdown. The mock can only fake this.

‎crates/openshell-supervisor-network/Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ tokio-stream = { workspace = true, features = ["net"] }
6969
[target.'cfg(unix)'.dependencies]
7070
libc = "0.2"
7171

72+
[target.'cfg(target_os = "windows")'.dependencies]
73+
windows = { workspace = true }
74+
7275
[target.'cfg(unix)'.dev-dependencies]
7376

7477
[lints]

‎crates/openshell-supervisor-network/src/host.rs‎

Lines changed: 14 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,6 @@ pub struct HostProxyConfig {
6161
pub bind_addr: SocketAddr,
6262
/// Network-only policy produced by the compute driver's policy split.
6363
pub policy: ProtoSandboxPolicy,
64-
/// Static process identity used when the platform cannot recover the
65-
/// socket-owning sandbox process. Policy binaries must match this path for
66-
/// L4/L7 allow rules to pass.
67-
pub binary_path: PathBuf,
6864
/// Per-sandbox client authentication. Host-side MXC proxies must set this
6965
/// so another sandbox cannot borrow this proxy's identity and policy.
7066
pub client_auth: HostProxyClientAuth,
@@ -215,10 +211,9 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result<HostProxyHandle
215211
(None, None, None)
216212
}
217213
};
218-
let identity_mode = ProxyIdentityMode::static_binary_with_client_auth(
219-
config.binary_path,
220-
Some(config.client_auth.expected_proxy_authorization),
221-
)?;
214+
let identity_mode = ProxyIdentityMode::windows_with_client_auth(Some(
215+
config.client_auth.expected_proxy_authorization,
216+
));
222217
let proxy = ProxyHandle::start_with_bind_addr(
223218
&proxy_policy,
224219
Some(config.bind_addr),
@@ -256,14 +251,13 @@ mod tests {
256251

257252
use super::*;
258253

259-
fn test_config(bind_addr: SocketAddr, binary_path: PathBuf) -> HostProxyConfig {
254+
fn test_config(bind_addr: SocketAddr) -> HostProxyConfig {
260255
HostProxyConfig {
261256
bind_addr,
262257
policy: ProtoSandboxPolicy {
263258
version: 1,
264259
..Default::default()
265260
},
266-
binary_path,
267261
client_auth: HostProxyClientAuth::basic("openshell", "test-secret"),
268262
sandbox_id: Some("sandbox-123".to_string()),
269263
sandbox_name: Some("agent-box".to_string()),
@@ -307,7 +301,9 @@ mod tests {
307301
let mut client = TcpStream::connect(addr).await.unwrap();
308302
client.write_all(request.as_bytes()).await.unwrap();
309303
let mut response = Vec::new();
310-
tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut response))
304+
// The first authenticated CONNECT performs a full executable hash for
305+
// TOFU identity binding; debug test binaries can be hundreds of MB.
306+
tokio::time::timeout(Duration::from_secs(10), client.read_to_end(&mut response))
311307
.await
312308
.unwrap()
313309
.unwrap();
@@ -316,11 +312,7 @@ mod tests {
316312

317313
#[tokio::test]
318314
async fn rejects_non_loopback_bind_addr() {
319-
let result = start_host_proxy(test_config(
320-
([192, 0, 2, 1], 0).into(),
321-
PathBuf::from("missing-agent.exe"),
322-
))
323-
.await;
315+
let result = start_host_proxy(test_config(([192, 0, 2, 1], 0).into())).await;
324316

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

334326
#[tokio::test]
335327
async fn rejects_middleware_policy_without_registry() {
336-
let mut config = test_config(
337-
([127, 0, 0, 1], 0).into(),
338-
PathBuf::from("missing-agent.exe"),
339-
);
328+
let mut config = test_config(([127, 0, 0, 1], 0).into());
340329
config.policy.network_middlewares.insert(
341330
"redactor".into(),
342331
NetworkMiddlewareConfig {
@@ -365,15 +354,9 @@ mod tests {
365354
#[tokio::test]
366355
async fn starts_loopback_proxy_and_serves_policy_local() {
367356
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
368-
let binary = tempfile::NamedTempFile::new().unwrap();
369-
std::fs::write(binary.path(), b"agent").unwrap();
370-
371-
let handle = start_host_proxy(test_config(
372-
([127, 0, 0, 1], 0).into(),
373-
binary.path().to_path_buf(),
374-
))
375-
.await
376-
.unwrap();
357+
let handle = start_host_proxy(test_config(([127, 0, 0, 1], 0).into()))
358+
.await
359+
.unwrap();
377360

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

414397
#[tokio::test]
415398
async fn per_sandbox_credentials_reject_missing_wrong_cross_and_duplicate_auth() {
416-
let binary = tempfile::NamedTempFile::new().unwrap();
417-
std::fs::write(binary.path(), b"agent").unwrap();
418-
419399
let auth_a = HostProxyClientAuth::basic("openshell", "sandbox-a-secret");
420400
let auth_b = HostProxyClientAuth::basic("openshell", "sandbox-b-secret");
421401
// Node's EnvHttpProxyAgent currently emits the field name in lower
@@ -429,11 +409,11 @@ mod tests {
429409
auth_b.expected_proxy_authorization
430410
);
431411

432-
let mut config_a = test_config(([127, 0, 0, 1], 0).into(), binary.path().to_path_buf());
412+
let mut config_a = test_config(([127, 0, 0, 1], 0).into());
433413
config_a.client_auth = auth_a;
434414
let proxy_a = start_host_proxy(config_a).await.unwrap();
435415

436-
let mut config_b = test_config(([127, 0, 0, 1], 0).into(), binary.path().to_path_buf());
416+
let mut config_b = test_config(([127, 0, 0, 1], 0).into());
437417
config_b.client_auth = auth_b;
438418
let proxy_b = start_host_proxy(config_b).await.unwrap();
439419

‎crates/openshell-supervisor-network/src/lib.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ pub mod run;
2121
pub mod sigv4;
2222
mod token_grant;
2323
pub mod upstream_proxy;
24+
#[cfg(target_os = "windows")]
25+
pub(crate) mod windows_process;
2426

2527
#[cfg(test)]
2628
pub(crate) mod test_alloc {

0 commit comments

Comments
 (0)