Skip to content
Merged
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
8 changes: 8 additions & 0 deletions crates/openshell-driver-mxc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ debug = false
etw_audit = false
```

`wxc_exec_path` is required and must be an absolute path to `wxc-exec.exe`.
The gateway rejects an omitted or relative value (including the bare filename
`wxc-exec.exe`) at startup, before any sandbox is created: `wxc-exec.exe` is
the binary that builds every sandbox, so a relative value would let
PATH-lookup or working-directory-relative resolution execute an unapproved
binary with the gateway's identity instead of the approved `wxc-exec`. There
is no usable default.

When `egress_proxy` is enabled, `egress_proxy_addr` must be a loopback
`IP:PORT` seed. For policies with explicit network rules, the driver preserves
the configured IP and allocates a unique ephemeral port for that sandbox's
Expand Down
84 changes: 82 additions & 2 deletions crates/openshell-driver-mxc/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,12 @@ impl MxcBackend {
#[serde(default, deny_unknown_fields)]
#[allow(clippy::struct_excessive_bools)] // Independent, existing gateway TOML options.
pub struct MxcComputeConfig {
/// Path to `wxc-exec.exe`. Required for live runs.
/// Path to `wxc-exec.exe`. Required for live runs, and must be an
/// absolute path: `wxc-exec` is the binary that builds every sandbox, so
/// a relative path (including the unset default) would let PATH-lookup
/// or working-directory-relative resolution execute a decoy binary with
/// the gateway's identity instead of the approved `wxc-exec`. Enforced
/// at gateway startup by the compute-driver config preflight.
pub wxc_exec_path: String,
/// Backend to target. Default: `process_container`.
pub backend: MxcBackend,
Expand Down Expand Up @@ -256,7 +261,12 @@ pub struct MxcComputeConfig {
impl Default for MxcComputeConfig {
fn default() -> Self {
Self {
wxc_exec_path: "wxc-exec.exe".into(),
// No usable default: `wxc_exec_path` must be explicitly set to an
// absolute path (see `validate_configuration` and the field doc
// comment above). Shipping a bare relative filename here would
// silently reintroduce the exact PATH/CWD-hijack risk the
// validation exists to reject.
wxc_exec_path: String::new(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes an existing omitted field from a usable default to a startup error, but docs/reference/gateway-config.mdx and the MXC README only show examples; neither states that wxc_exec_path is now required and absolute. Please document the migration and requirement in this PR, as required for driver configuration default changes.

backend: MxcBackend::default(),
pc_least_privilege: false,
pc_capabilities: Vec::new(),
Expand All @@ -275,6 +285,32 @@ impl Default for MxcComputeConfig {
}
}

impl MxcComputeConfig {
/// Validate startup configuration without touching `wxc-exec` or the
/// filesystem beyond `Path::is_absolute`.
///
/// `wxc_exec_path` must be set to an absolute path: it is the binary
/// that builds every sandbox, so a relative path (including an unset,
/// empty value) would let PATH-lookup or working-directory-relative
/// resolution execute a decoy binary with the gateway's identity instead
/// of the approved `wxc-exec`, turning the containment mechanism itself
/// into an arbitrary-code-execution primitive.
pub fn validate_configuration(&self) -> openshell_core::Result<()> {
if self.wxc_exec_path.trim().is_empty() {
return Err(openshell_core::Error::config(
"[openshell.drivers.mxc] wxc_exec_path must be set to an absolute path to wxc-exec.exe",
));
}
if !Path::new(&self.wxc_exec_path).is_absolute() {
return Err(openshell_core::Error::config(format!(
"[openshell.drivers.mxc] wxc_exec_path must be an absolute path, got '{}'",
self.wxc_exec_path
)));
}
Ok(())
}
}

/// Per-sandbox MXC workload settings supplied through
/// `template.driver_config.mxc` / `--driver-config-json`.
#[derive(Debug, Clone, Deserialize)]
Expand Down Expand Up @@ -2790,6 +2826,50 @@ mod lifecycle_tests {
}
}

#[test]
fn validate_configuration_rejects_unset_wxc_exec_path() {
// Regression test: the shipped default used to be the bare relative
// filename "wxc-exec.exe", which is exactly the PATH/CWD-hijack
// primitive this validation exists to reject. The default must stay
// rejected, not silently become a usable-but-insecure fallback.
let config = MxcComputeConfig::default();
assert!(config.wxc_exec_path.is_empty());
let error = config.validate_configuration().unwrap_err();
assert!(error.to_string().contains("wxc_exec_path"));
}

#[test]
fn validate_configuration_rejects_relative_wxc_exec_path() {
let config = MxcComputeConfig {
wxc_exec_path: "wxc-exec.exe".into(),
..Default::default()
};
let error = config.validate_configuration().unwrap_err();
assert!(error.to_string().contains("wxc_exec_path"));

let config = MxcComputeConfig {
wxc_exec_path: r"..\wxc-exec.exe".into(),
..Default::default()
};
assert!(config.validate_configuration().is_err());
}

#[test]
fn validate_configuration_accepts_absolute_wxc_exec_path() {
let config = MxcComputeConfig {
wxc_exec_path: r"C:\mxc-kit\bin\wxc-exec.exe".into(),
..Default::default()
};
config.validate_configuration().unwrap();
}

#[test]
fn governed_egress_defaults_off_and_allocates_unique_loopback_ports() {
let config = MxcComputeConfig::default();
assert!(!config.egress_proxy);
assert!(config.egress_proxy_addr.is_empty());
}

#[test]
fn sandbox_proxy_addr_uses_ephemeral_loopback_port() {
let configured = "127.0.0.1:18080".parse().unwrap();
Expand Down
4 changes: 2 additions & 2 deletions crates/openshell-gateway/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,8 @@ impl openshell_server::ComputeDriverFactory for MxcFactory {
&self,
context: openshell_server::ComputeDriverConfigContext<'_>,
) -> openshell_core::Result<()> {
let _: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?;
Ok(())
let config: openshell_driver_mxc::MxcComputeConfig = context.driver_config()?;
config.validate_configuration()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the actual enforcement boundary for the security fix, but the new tests only call MxcComputeConfig::validate_configuration directly. If this factory call regressed to the previous no-op, all added tests would still pass and relative paths would again reach startup. Please add a Windows gateway/config-preflight test that selects mxc and verifies omitted and relative paths fail while an absolute path passes.

}

async fn build(
Expand Down
100 changes: 100 additions & 0 deletions crates/openshell-gateway/tests/mxc_config_preflight.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Integration test for the actual enforcement boundary of the
//! `wxc_exec_path` absolute-path requirement: the real `openshell-gateway`
//! binary's `config preflight` subcommand, selecting the `mxc` compute
//! driver.
//!
//! `openshell_driver_mxc::MxcComputeConfig::validate_configuration` already
//! has unit coverage, but that only proves the validation function itself is
//! correct -- it says nothing about whether `MxcFactory::validate_config` (in
//! `src/lib.rs`) still calls it. Before this fix, that factory method was a
//! no-op that discarded the parsed config entirely, so a regression back to
//! that shape would leave every unit test passing while a relative
//! `wxc_exec_path` again reached gateway startup. Spawning the real compiled
//! binary through its actual CLI entry point exercises the whole chain: CLI
//! parsing, TOML config loading, driver selection, `MxcFactory::validate_config`,
//! and `MxcComputeConfig::validate_configuration`.
//!
//! These tests assert only pass/fail, not the diagnostic's exact wording:
//! `openshell_server::cli::run_effective_config_preflight` replaces any
//! validation failure with a generic "malformed" message whenever a config
//! file path is in play (`ConfigPreflightError::invalid_current`), so the
//! specific `wxc_exec_path` wording from `validate_configuration` is not
//! observable through this boundary today. That message-masking behavior is
//! pre-existing and unrelated to this fix.

#![cfg(all(target_os = "windows", feature = "compute-driver-mxc"))]

use std::io::Write;
use std::process::Command;

/// Run `openshell-gateway config preflight` against a disposable TOML config
/// with the `mxc` driver selected. Returns whether the process exited
/// successfully and its captured stderr.
fn run_preflight(toml_body: &str) -> (bool, String) {
let mut config_file = tempfile::NamedTempFile::new().expect("create temp config file");
write!(config_file, "{toml_body}").expect("write temp config file");

let output = Command::new(env!("CARGO_BIN_EXE_openshell-gateway"))
.arg("config")
.arg("preflight")
.env("OPENSHELL_GATEWAY_CONFIG", config_file.path())
.env("OPENSHELL_COMPUTE_DRIVER", "mxc")
.env("OPENSHELL_DISABLE_TLS", "true")
.env_remove("OPENSHELL_DRIVERS")
.output()
.expect("spawn openshell-gateway config preflight");

let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
(output.status.success(), stderr)
}

#[test]
fn config_preflight_rejects_omitted_wxc_exec_path() {
let (ok, stderr) = run_preflight(
r"
[openshell]
version = 2
",
);
assert!(
!ok,
"omitted wxc_exec_path must fail preflight, stderr: {stderr}"
);
}

#[test]
fn config_preflight_rejects_relative_wxc_exec_path() {
let (ok, stderr) = run_preflight(
r#"
[openshell]
version = 2

[openshell.drivers.mxc]
wxc_exec_path = "wxc-exec.exe"
"#,
);
assert!(
!ok,
"a relative wxc_exec_path must fail preflight, stderr: {stderr}"
);
}

#[test]
fn config_preflight_accepts_absolute_wxc_exec_path() {
let (ok, stderr) = run_preflight(
r#"
[openshell]
version = 2

[openshell.drivers.mxc]
wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe"
"#,
);
assert!(
ok,
"an absolute wxc_exec_path must pass preflight, stderr: {stderr}"
);
}
15 changes: 15 additions & 0 deletions docs/reference/gateway-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,14 @@ debug = false
etw_audit = true
```

`wxc_exec_path` is required and must be an absolute path to `wxc-exec.exe`.
The gateway rejects an omitted or relative value at startup: `wxc-exec.exe`
is the binary that builds every sandbox, so a relative value would let
PATH-lookup or working-directory-relative resolution execute an unapproved
binary with the gateway's identity instead of the intended `wxc-exec`. There
is no usable default; older configurations that relied on the bare relative
filename `wxc-exec.exe` must set an absolute path before upgrading.

`etw_audit` defaults to `false`. When enabled, the gateway account must be an
administrator or belong to the Windows Performance Log Users group. Workload
commands and working directories remain sandbox-scoped and must be supplied in
Expand Down Expand Up @@ -949,6 +957,13 @@ debug = false
etw_audit = false
```

`wxc_exec_path` is required and must be an absolute path; the gateway rejects
an omitted or relative value at startup. `wxc-exec.exe` builds every sandbox,
so a relative value would let PATH-lookup or working-directory-relative
resolution execute an unapproved binary with the gateway's identity. There is
no usable default -- older configurations that relied on the bare relative
filename `wxc-exec.exe` must set an absolute path before upgrading.

The default `pc_least_privilege = false` still runs the workload with an
AppContainer token. Set it to `true` to request the stricter Less Privileged
AppContainer (LPAC) variant. Windows AppContainer tokens retain the launching
Expand Down
Loading