diff --git a/docs/api-reference/dotnet/v1/types.md b/docs/api-reference/dotnet/v1/types.md index c92c8f0db..a45500ccd 100644 --- a/docs/api-reference/dotnet/v1/types.md +++ b/docs/api-reference/dotnet/v1/types.md @@ -36,6 +36,11 @@ public enum BackendCapability CaptureDenials = 1, ProxyEnforcement = 2, FilesystemDeniedPaths = 3, + /// + /// With explicit host-loopback allow, the baseline is policy-permitted stateful outbound + /// loopback. Native Windows ingress support adds bidirectional access, including + /// container-hosted loopback servers. + /// IngressHostLoopbackAllow = 4, FilesystemEnumeratePaths = 5, IdentitylessLoopbackProxy = 6, @@ -954,6 +959,11 @@ public sealed class ProbeFacts public bool BfsCompiledIn { get; init; } public bool BaseContainerSupportsDenyPaths { get; init; } public bool BaseContainerSupportsEnumeratePaths { get; init; } + /// + /// With explicit host-loopback allow, the baseline is policy-permitted stateful outbound + /// loopback. Native Windows ingress support adds bidirectional access, including + /// container-hosted loopback servers. + /// public bool BaseContainerSupportsIngressHostLoopbackAllow { get; init; } public bool BaseContainerSupportsIdentitylessLoopbackProxy { get; init; } public bool IsolationSessionAvailable { get; init; } diff --git a/docs/api-reference/node/v1/types.md b/docs/api-reference/node/v1/types.md index 3517465d2..8794c1e95 100644 --- a/docs/api-reference/node/v1/types.md +++ b/docs/api-reference/node/v1/types.md @@ -29,6 +29,11 @@ export type BackendCapability = | 'captureDenials' | 'filesystemDeniedPaths' | 'filesystemEnumeratePaths' + /** + * With explicit host-loopback allow, the baseline is policy-permitted stateful outbound + * loopback. Native Windows ingress support adds bidirectional access, including + * container-hosted loopback servers. + */ | 'ingressHostLoopbackAllow' | 'proxyEnforcement' | 'identitylessLoopbackProxy' @@ -578,6 +583,11 @@ export interface ProbeFacts { bfsCompiledIn: boolean; baseContainerSupportsDenyPaths: boolean; baseContainerSupportsEnumeratePaths: boolean; + /** + * With explicit host-loopback allow, the baseline is policy-permitted stateful outbound + * loopback. Native Windows ingress support adds bidirectional access, including + * container-hosted loopback servers. + */ baseContainerSupportsIngressHostLoopbackAllow: boolean; baseContainerSupportsIdentitylessLoopbackProxy: boolean; isolationSessionAvailable: boolean; diff --git a/docs/api-reference/rust/v1/types.md b/docs/api-reference/rust/v1/types.md index 2fd24faaf..e9af44da3 100644 --- a/docs/api-reference/rust/v1/types.md +++ b/docs/api-reference/rust/v1/types.md @@ -29,6 +29,9 @@ pub enum BackendCapability { CaptureDenials, FilesystemDeniedPaths, FilesystemEnumeratePaths, + /// With explicit host-loopback allow, the baseline is policy-permitted stateful outbound + /// loopback. Native Windows ingress support adds bidirectional access, including + /// container-hosted loopback servers. IngressHostLoopbackAllow, ProxyEnforcement, IdentitylessLoopbackProxy, @@ -535,6 +538,9 @@ pub struct ProbeFacts { pub bfs_compiled_in: bool, pub base_container_supports_deny_paths: bool, pub base_container_supports_enumerate_paths: bool, + /// With explicit host-loopback allow, the baseline is policy-permitted stateful outbound + /// loopback. Native Windows ingress support adds bidirectional access, including + /// container-hosted loopback servers. pub base_container_supports_ingress_host_loopback_allow: bool, pub base_container_supports_identityless_loopback_proxy: bool, pub isolation_session_available: bool, diff --git a/docs/backends/process-container/networking.md b/docs/backends/process-container/networking.md index 6bc04a87d..0c12c5fd4 100644 --- a/docs/backends/process-container/networking.md +++ b/docs/backends/process-container/networking.md @@ -10,6 +10,13 @@ For the shared policy shape and defaults, see [directional networking](../../schema.md#directional-networking-supported-contracts). This guide covers how the Windows ProcessContainer backend enforces them. +ProcessContainer is available on Windows 11 24H2 and later. The initial +24H2 and 25H2 releases (builds 26100.9278 and 26200.9278, respectively) support +connections to medium-integrity host listeners. Builds 26100.9550 and +26200.9550 add support for hosting loopback servers inside the container and +connecting to low-integrity listeners. See +[Windows OS-version support](os-version-support.md) for supported releases. + ## 1. What this backend targets at GA On ProcessContainer paths with OS-scoped enforcement, each container gets two enforcement primitives scoped to its @@ -102,26 +109,13 @@ common WFP endpoint enforcement. The proxy endpoint is the loopback address and `runtimeConfig.networkProxy`. The packaged row covers both AppContainer and non-AppContainer proxies because their client policy and package identity are the same; the enforcement table below separates their additional protections. -`ingress.hostLoopback` is bidirectional. `allowedProxyPeer` authorizes a package family or AppContainer profile without +`ingress.hostLoopback` requests access in both directions; the supported directions depend on the Windows build. +`allowedProxyPeer` authorizes a package family or AppContainer profile without opening general host-loopback access, so identity-scoped paths keep `hostLoopback: "deny"`. Only an unpackaged non-AppContainer proxy lacks an accepted peer identity and requires `hostLoopback: "allow"`. This is a weaker development/testing compatibility deployment, not the shared policy's strict host-loopback-closure guarantee. The shared setting requests both host-loopback directions, while WFP restricts client-container egress to the configured proxy endpoint. -For direct-egress policies, MXC passes `ingress.default` and `ingress.hostLoopback` through the PSEC 1.1 ingress table when -`IsProcessSecurityEnvironmentVersionSupported` reports contract 1.1 or newer and -`QueryProcessSecurityEnvironmentSupport` advertises ingress support. Proxy policies omit that native table because -PSEC rejects it alongside a proxy. Their ingress default still selects `privateNetworkClientServer`, and their -host-loopback setting selects both the `networkLoopback` capability and the `MXC-Loopback` peer rather than an -identity-scoped proxy peer. The peer alone is insufficient: without the capability, the environment can be created -but its client cannot reach the host proxy. -The public ingress policy remains required. Direct-egress host-loopback allow -requires PSEC 1.1 and the ingress support flag. For an identity-less -`runtimeConfig.networkProxy` with explicit `hostLoopback: "allow"`, MXC also -supports a compatibility workaround on hosts that support PSEC 1.0 but not 1.1. -Hosts that support PSEC 1.1 or newer must still advertise the ingress support -flag; accepting a 1.0 payload does not enable this workaround. -Requests that do not allow host loopback use the PSEC 1.0 capability mapping. Requests the PSEC contract cannot preserve continue to an AppContainer tier. #### Discovering identity-less loopback proxy support @@ -130,12 +124,7 @@ Backend discovery reports `identitylessLoopbackProxy` on the ProcessContainer `base-container` tier when the host supports an identity-less proxy on loopback. The Windows request probe also reports `probes.baseContainerSupportsIdentitylessLoopbackProxy`, including when called -without a request. Support is available on PSEC 1.0-only hosts and on hosts with -PSEC 1.1 or newer that advertise ingress support. A host that reports -`baseContainerSupportsIngressHostLoopbackAllow` also supports an identity-less -loopback proxy. PSEC 1.1 or newer without ingress support does not qualify, -even if it also accepts PSEC 1.0 payloads. Version-query failures omit the -capability, set the probe fact to `false`, and add a warning. +without a request. This does not grant general `ingressHostLoopbackAllow` support or report that the current request uses an identity-less proxy. The request must set diff --git a/docs/development/build-and-test/ci-validation-infrastructure.md b/docs/development/build-and-test/ci-validation-infrastructure.md index 9834ea6d6..87ed24a9e 100644 --- a/docs/development/build-and-test/ci-validation-infrastructure.md +++ b/docs/development/build-and-test/ci-validation-infrastructure.md @@ -226,7 +226,7 @@ get fixed or wired. | Backend | Status | Notes | |---------|--------|-------| -| Process T1 | ✅ Good | Windows 24H2+ only. Runs the primitives suite, tier-gated to `base-container`. Includes supported directional networking phases (capability matrix, model-3 equivalence, explicit egress rules, host loopback, runtime proxy, reject surface). Remaining failures are genuine MXC bugs or harness limitations. | +| Process T1 | ✅ Good | Windows 24H2+ only. Runs the primitives suite, tier-gated to `base-container`. Includes supported directional networking phases (capability matrix, model-3 equivalence, explicit egress rules, host loopback, runtime proxy, reject surface). Host-loopback checks cover both ingress defaults; proxy checks verify IPv4 and IPv6 endpoint isolation without requiring native ingress support. Remaining failures are genuine MXC bugs or harness limitations. | | Process T3 | ✅ Good | Windows 23H2 only. Runs the primitives suite tier-gated to `appcontainer-dacl`, plus `T3-Workloads.ps1` (real programs — pwsh, git, node, python, cmd) on top of the T3 primitives. The directional networking phases assert the documented *rejection* behavior here, since AppContainer cannot carry egress rules, proxy peer identity, or host-loopback configuration. | | Bubblewrap | ✅ Good | | | LXC | ✅ Good | Some networking tests fail on distros other than Ubuntu 24.04; seems to be an issue with MXC. The in-process streaming handle is covered at PR time by `lxc-e2e.yml` instead, because this matrix runs prebuilt binaries. | diff --git a/docs/examples.md b/docs/examples.md index c6a523a5b..cd72ad4aa 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -134,10 +134,7 @@ the development/testing configuration is: This identity-less host-loopback deployment restricts client egress to the configured proxy address and port, but does not verify which process owns that -endpoint. It supports PSEC 1.0-only hosts and hosts with PSEC 1.1 or newer that -advertise ingress support. PSEC 1.1 or newer without that flag rejects the -request, even if the host also accepts PSEC 1.0 payloads. For production -ProcessContainer deployments, identify a packaged proxy through +endpoint. For production ProcessContainer deployments, identify a packaged proxy through `processContainer.network.allowedProxyPeer` instead; see [proxy deployment choices](backends/process-container/networking.md#proxy-deployment-choices). Bubblewrap and Seatbelt also support a caller-managed loopback proxy, but diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/PlatformDiscovery.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/PlatformDiscovery.cs index b7eed9cfc..9a363c4d8 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/PlatformDiscovery.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/PlatformDiscovery.cs @@ -70,7 +70,9 @@ public enum BackendCapability FilesystemDeniedPaths = 3, /// - /// Host-loopback allow enforcement at the reported tier. + /// With explicit host-loopback allow, the baseline is policy-permitted stateful outbound + /// loopback. Native Windows ingress support adds bidirectional access, including + /// container-hosted loopback servers. /// IngressHostLoopbackAllow = 4, @@ -151,6 +153,11 @@ public sealed class ProbeFacts public bool BfsCompiledIn { get; init; } public bool BaseContainerSupportsDenyPaths { get; init; } public bool BaseContainerSupportsEnumeratePaths { get; init; } + /// + /// With explicit host-loopback allow, the baseline is policy-permitted stateful outbound + /// loopback. Native Windows ingress support adds bidirectional access, including + /// container-hosted loopback servers. + /// public bool BaseContainerSupportsIngressHostLoopbackAllow { get; init; } /// Identity-less proxy support on loopback; requires explicit host-loopback allow. public bool BaseContainerSupportsIdentitylessLoopbackProxy { get; init; } diff --git a/sdk/node/src/v1/types.ts b/sdk/node/src/v1/types.ts index b19af0127..ad3a25f6f 100644 --- a/sdk/node/src/v1/types.ts +++ b/sdk/node/src/v1/types.ts @@ -681,6 +681,11 @@ export type BackendCapability = | 'captureDenials' | 'filesystemDeniedPaths' | 'filesystemEnumeratePaths' + /** + * With explicit host-loopback allow, the baseline is policy-permitted stateful outbound + * loopback. Native Windows ingress support adds bidirectional access, including + * container-hosted loopback servers. + */ | 'ingressHostLoopbackAllow' | 'proxyEnforcement' | 'identitylessLoopbackProxy' @@ -756,6 +761,11 @@ export interface ProbeFacts { bfsCompiledIn: boolean; baseContainerSupportsDenyPaths: boolean; baseContainerSupportsEnumeratePaths: boolean; + /** + * With explicit host-loopback allow, the baseline is policy-permitted stateful outbound + * loopback. Native Windows ingress support adds bidirectional access, including + * container-hosted loopback servers. + */ baseContainerSupportsIngressHostLoopbackAllow: boolean; /** Identity-less proxy support on loopback; requires explicit host-loopback allow. */ baseContainerSupportsIdentitylessLoopbackProxy: boolean; diff --git a/src/mxc-sdk/src/backends/process_container/common/base_container_helpers.rs b/src/mxc-sdk/src/backends/process_container/common/base_container_helpers.rs index 9b955d883..9550eaf8a 100644 --- a/src/mxc-sdk/src/backends/process_container/common/base_container_helpers.rs +++ b/src/mxc-sdk/src/backends/process_container/common/base_container_helpers.rs @@ -10,10 +10,10 @@ use crate::mxc_common::models::{ use crate::process_security_environment_spec::process_security_environment_layout::{ finish_process_security_environment_buffer, DestinationRuleT as PsecDestinationRuleT, EndpointPolicyT as PsecEndpointPolicy, EndpointRuleT as PsecEndpointRuleT, - FilterAction as PsecFilterAction, IngressPolicyT as PsecIngressPolicy, - IpProtocol as PsecIpProtocol, IpSubnetT as PsecIpSubnetT, NetworkPolicyT as PsecNetworkPolicy, - PortRuleT as PsecPortRuleT, ProcessSecurityEnvironmentT as PsecProcessSecurityEnvironment, - ProxyInfoT as PsecProxyInfo, SchemaVersionT, + FilterAction as PsecFilterAction, IpProtocol as PsecIpProtocol, IpSubnetT as PsecIpSubnetT, + NetworkPolicyT as PsecNetworkPolicy, PortRuleT as PsecPortRuleT, + ProcessSecurityEnvironmentT as PsecProcessSecurityEnvironment, ProxyInfoT as PsecProxyInfo, + SchemaVersionT, }; use crate::process_container_common::network_policy_helpers::{ @@ -30,7 +30,6 @@ pub(super) fn has_conflicting_proxy_identity(policy: &ContainerPolicy) -> bool { pub(super) fn build_psec_v1_security_environment_spec( request: &ExecutionRequest, version: SecurityEnvironmentVersion, - supports_network_ingress: bool, capture_network_decisions: bool, ) -> Vec { assert_eq!( @@ -39,10 +38,7 @@ pub(super) fn build_psec_v1_security_environment_spec( ); let mut builder = flatbuffers::FlatBufferBuilder::with_capacity(1024); let mut capabilities = effective_capabilities(request, capture_network_decisions); - if request.policy.network_proxy.is_enabled() - && unrestricted_host_loopback_allowed(&request.policy) - { - // Proxy mode cannot carry the native ingress table that grants loopback. + if unrestricted_host_loopback_allowed(&request.policy) { ensure_capability(&mut capabilities, "networkLoopback"); } let ui_restrictions = crate::process_container_common::job_object::to_job_object_uilimit_mask( @@ -64,10 +60,7 @@ pub(super) fn build_psec_v1_security_environment_spec( fs_read_only: non_empty_paths(&request.policy.readonly_paths), fs_deny: non_empty_paths(&request.policy.denied_paths), fs_enumerate: non_empty_paths(&request.policy.enumerate_paths), - network_policy: Some(Box::new(build_psec_v1_network_policy( - &request.policy, - supports_network_ingress, - ))), + network_policy: Some(Box::new(build_psec_v1_network_policy(&request.policy))), }; let spec = spec.pack(&mut builder); finish_process_security_environment_buffer(&mut builder, spec); @@ -103,10 +96,7 @@ fn non_empty_paths(paths: &[String]) -> Option> { (!paths.is_empty()).then(|| paths.to_vec()) } -fn build_psec_v1_network_policy( - policy: &ContainerPolicy, - supports_network_ingress: bool, -) -> PsecNetworkPolicy { +fn build_psec_v1_network_policy(policy: &ContainerPolicy) -> PsecNetworkPolicy { let mut network = PsecNetworkPolicy::default(); if policy.network_proxy.is_enabled() { // Proxy and direct egress are mutually exclusive PSEC policy forms. @@ -133,30 +123,10 @@ fn build_psec_v1_network_policy( } network.egress = Some(Box::new(egress)); } - // Proxy mode uses the private-network capability and peer for ingress. - // PSEC rejects a proxy combined with the native ingress table. - let ingress_policy = if supports_network_ingress && !policy.network_proxy.is_enabled() { - policy.network_ingress.as_ref() - } else { - None - }; network.allowed_appcontainer_peer = allowed_appcontainer_peer(policy); - if let Some(ingress_policy) = ingress_policy { - network.ingress = Some(Box::new(PsecIngressPolicy { - default_action: psec_filter_action(ingress_policy.default), - host_loopback: psec_filter_action(ingress_policy.host_loopback), - })); - } network } -fn psec_filter_action(action: NetworkAction) -> PsecFilterAction { - match action { - NetworkAction::Allow => PsecFilterAction::allow, - NetworkAction::Deny => PsecFilterAction::deny, - } -} - fn effective_egress_default(policy: &ContainerPolicy) -> NetworkAction { policy .network_egress diff --git a/src/mxc-sdk/src/backends/process_container/common/base_container_helpers/tests.rs b/src/mxc-sdk/src/backends/process_container/common/base_container_helpers/tests.rs index c1ebce1bd..226eaa6cc 100644 --- a/src/mxc-sdk/src/backends/process_container/common/base_container_helpers/tests.rs +++ b/src/mxc-sdk/src/backends/process_container/common/base_container_helpers/tests.rs @@ -53,50 +53,46 @@ fn runtime_proxy_request(proxy: &TcpListener) -> ExecutionRequest { fn runtime_proxy_uses_peer_and_capability_without_native_ingress() { let proxy = TcpListener::bind(("127.0.0.1", 0)).unwrap(); let request = runtime_proxy_request(&proxy); - let bytes = build_psec_v1_security_environment_spec( - &request, - SecurityEnvironmentVersion::V1_1, - true, - false, - ); - let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); - let network = spec.network_policy().expect("network policy"); let proxy_url = format!("http://{}", proxy.local_addr().unwrap()); - assert_eq!(spec.version().minor(), 1); - assert_eq!( - spec.capabilities().unwrap().split(',').collect::>(), - [PRIVATE_NETWORK_CAPABILITY, "networkLoopback"] - ); - assert_eq!( - network.proxy().and_then(|proxy| proxy.url()), - Some(proxy_url.as_str()) - ); - assert_eq!( - network.allowed_appcontainer_peer(), - Some(LOOPBACK_NETWORK_PEER) - ); - assert!(network.egress().is_none()); - assert!( - network.ingress().is_none(), - "PSEC proxy mode must not include the incompatible native ingress table" - ); + for (version, minor) in [ + (SecurityEnvironmentVersion::V1_0, 0), + (SecurityEnvironmentVersion::V1_1, 1), + ] { + let bytes = build_psec_v1_security_environment_spec(&request, version, false); + let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); + let network = spec.network_policy().expect("network policy"); + + assert_eq!(spec.version().minor(), minor); + assert_eq!( + spec.capabilities().unwrap().split(',').collect::>(), + [PRIVATE_NETWORK_CAPABILITY, "networkLoopback"] + ); + assert_eq!( + network.proxy().and_then(|proxy| proxy.url()), + Some(proxy_url.as_str()) + ); + assert_eq!( + network.allowed_appcontainer_peer(), + Some(LOOPBACK_NETWORK_PEER) + ); + assert!(network.egress().is_none()); + assert!(network.ingress().is_none()); + } } #[test] fn runtime_proxy_creates_native_security_environment() { - if !BaseContainerRunner::supports_ingress_host_loopback_allow() { - eprintln!("SKIPPED: native proxy regression requires usable PSEC 1.1 ingress support"); + if !BaseContainerRunner::supports_identityless_loopback_proxy() + .expect("query identity-less loopback proxy support") + { + eprintln!("SKIPPED: native proxy regression requires PSEC 1.0 support"); return; } let proxy = TcpListener::bind(("127.0.0.1", 0)).unwrap(); let request = runtime_proxy_request(&proxy); - let bytes = build_psec_v1_security_environment_spec( - &request, - SecurityEnvironmentVersion::V1_1, - true, - false, - ); + let bytes = + build_psec_v1_security_environment_spec(&request, SecurityEnvironmentVersion::V1_0, false); let environment = create(&bytes, PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE).unwrap_or_else(|error| { panic!("valid runtime proxy policy failed to create PSEC: {error}") @@ -115,12 +111,8 @@ fn identity_scoped_proxy_does_not_grant_host_loopback() { .unwrap() .host_loopback = NetworkAction::Deny; request.policy.allowed_proxy_peer = Some("Contoso.Proxy_12345".to_string()); - let bytes = build_psec_v1_security_environment_spec( - &request, - SecurityEnvironmentVersion::V1_0, - false, - false, - ); + let bytes = + build_psec_v1_security_environment_spec(&request, SecurityEnvironmentVersion::V1_0, false); let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); let network = spec.network_policy().unwrap(); @@ -180,8 +172,10 @@ fn run_proxy_command_with_stdout( #[test] fn runtime_proxy_launches_child() { - if !BaseContainerRunner::supports_ingress_host_loopback_allow() { - eprintln!("SKIPPED: native proxy launch requires usable PSEC 1.1 ingress support"); + if !BaseContainerRunner::supports_identityless_loopback_proxy() + .expect("query identity-less loopback proxy support") + { + eprintln!("SKIPPED: native proxy launch requires PSEC 1.0 support"); return; } let proxy = TcpListener::bind(("127.0.0.1", 0)).unwrap(); @@ -192,10 +186,11 @@ fn runtime_proxy_launches_child() { #[test] fn runtime_proxy_captures_denials_and_launches_child() { let _guard = crate::process_container_common::test_env::lock(); - if !BaseContainerRunner::supports_ingress_host_loopback_allow() + if !BaseContainerRunner::supports_identityless_loopback_proxy() + .expect("query identity-less loopback proxy support") || !BaseContainerRunner::is_native_capture_available() { - eprintln!("SKIPPED: native proxy capture requires PSEC 1.1 and Learning Mode APIs"); + eprintln!("SKIPPED: native proxy capture requires PSEC 1.0 and Learning Mode APIs"); return; } let proxy = TcpListener::bind(("127.0.0.1", 0)).unwrap(); @@ -239,8 +234,10 @@ fn runtime_proxy_captures_denials_and_launches_child() { #[test] fn runtime_proxy_allows_only_its_ipv4_loopback_endpoint() { - if !BaseContainerRunner::supports_ingress_host_loopback_allow() { - eprintln!("SKIPPED: native proxy networking requires usable PSEC 1.1 ingress support"); + if !BaseContainerRunner::supports_identityless_loopback_proxy() + .expect("query identity-less loopback proxy support") + { + eprintln!("SKIPPED: native proxy networking requires PSEC 1.0 support"); return; } assert_proxy_endpoint_only("127.0.0.1"); @@ -248,8 +245,10 @@ fn runtime_proxy_allows_only_its_ipv4_loopback_endpoint() { #[test] fn runtime_proxy_blocks_ipv6_loopback_bypass() { - if !BaseContainerRunner::supports_ingress_host_loopback_allow() { - eprintln!("SKIPPED: native proxy networking requires usable PSEC 1.1 ingress support"); + if !BaseContainerRunner::supports_identityless_loopback_proxy() + .expect("query identity-less loopback proxy support") + { + eprintln!("SKIPPED: native proxy networking requires PSEC 1.0 support"); return; } assert_proxy_endpoint_only("::1"); diff --git a/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs b/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs index 7731780c5..27b83a786 100644 --- a/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs +++ b/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs @@ -57,7 +57,6 @@ use crate::mxc_common::string_util; use crate::mxc_common::validator::{validate_network_policy_support, NetworkPolicySupport}; use crate::process_container_common::base_container_helpers::{ build_psec_v1_security_environment_spec, has_conflicting_proxy_identity, - unrestricted_host_loopback_allowed, }; use crate::process_container_common::capture_output::{ combine_capture_and_cleanup_results, combine_process_and_teardown_results, @@ -150,9 +149,6 @@ const PSEC_ENUMERATE_PATHS_UNSUPPORTED_MSG: &str = and QueryProcessSecurityEnvironmentSupport to advertise PSE_SUPPORT_FS_ENUMERATE; this OS \ build does not support that policy, and enumeration-only access cannot fall back to \ AppContainer enforcement"; -const PSEC_INGRESS_UNSUPPORTED_MSG: &str = - "network.ingress.hostLoopback='allow' requires Process Security Environment contract version \ - 1.1 with ingress support"; const CREATE_PROCESS_IN_SECURITY_ENVIRONMENT_API: &str = "CreateProcessW(PROC_THREAD_ATTRIBUTE_SECURITY_ENVIRONMENT)"; @@ -258,7 +254,6 @@ pub(crate) enum BaseContainerRequestDecision { PolicyIncompatible, DeniedPathsUnsupported, EnumeratePathsUnsupported, - IngressUnsupported, NativeCaptureUnavailable, } @@ -314,7 +309,6 @@ impl BaseContainerRunner { &self, request: &ExecutionRequest, version: SecurityEnvironmentVersion, - supports_network_ingress: bool, ) -> Result, ScriptResponse> { let capture_network_decisions = match request.policy.capture_denials.as_ref() { Some(_) => self @@ -331,7 +325,6 @@ impl BaseContainerRunner { Ok(build_psec_v1_security_environment_spec( request, version, - supports_network_ingress, capture_network_decisions, )) } @@ -389,8 +382,7 @@ impl BaseContainerRunner { && secenv::query_support(SecurityEnvironmentSupport::FileSystemEnumerate) } - /// Whether BaseContainer can enforce - /// `network.ingress.hostLoopback = "allow"`. + /// Whether BaseContainer supports native bidirectional host-loopback ingress. pub fn supports_ingress_host_loopback_allow() -> bool { Self::is_base_container_api_present() && secenv::query_support(SecurityEnvironmentSupport::NetworkIngress) @@ -402,55 +394,13 @@ impl BaseContainerRunner { if !Self::is_base_container_api_present() { return Ok(false); } - Self::identityless_loopback_proxy_supported( - Self::supports_ingress_host_loopback_allow(), - secenv::supports_version, - ) - } - - fn identityless_loopback_proxy_supported( - ingress_supported: bool, - supports_version: impl FnMut(SecurityEnvironmentVersion) -> Result, - ) -> Result { - if ingress_supported { - return Ok(true); - } - Self::psec_1_0_only(supports_version) - } - - fn psec_1_0_only( - mut supports_version: impl FnMut(SecurityEnvironmentVersion) -> Result, - ) -> Result { - // A 1.1+ host must keep using the advertised ingress support flag, - // even if it also accepts a 1.0 payload. - if supports_version(SecurityEnvironmentVersion::V1_1)? { - return Ok(false); - } - supports_version(SecurityEnvironmentVersion::V1_0) - } - - // Temporary compatibility for explicit identity-less proxies on PSEC 1.0-only - // hosts. Remove once those hosts support PSEC 1.1; keep loopback allow opt-in. - fn psec_1_0_proxy_loopback_workaround( - request: &ExecutionRequest, - supports_version: impl FnMut(SecurityEnvironmentVersion) -> Result, - ) -> Result { - let policy = &request.policy; - if !policy.runtime_network_proxy_specified - || !policy.network_proxy.is_enabled() - || policy.allowed_proxy_peer.is_some() - || !unrestricted_host_loopback_allowed(policy) - { - return Ok(false); - } - Self::psec_1_0_only(supports_version) + secenv::supports_version(SecurityEnvironmentVersion::V1_0) } /// The single PSEC version-selection point: start at 1.0 and raise it to /// the highest version required by any requested native feature. fn choose_min_required_psec_version_for_request( request: &ExecutionRequest, - proxy_loopback_workaround: bool, ) -> SecurityEnvironmentVersion { let mut version = SecurityEnvironmentVersion::V1_0; if !request.policy.denied_paths.is_empty() { @@ -460,9 +410,7 @@ impl BaseContainerRunner { version = version.max(SecurityEnvironmentSupport::FileSystemEnumerate.required_version()); } - if unrestricted_host_loopback_allowed(&request.policy) && !proxy_loopback_workaround { - version = version.max(SecurityEnvironmentSupport::NetworkIngress.required_version()); - } + version } @@ -502,15 +450,7 @@ impl BaseContainerRunner { if !request.policy.denied_paths.is_empty() && !Self::supports_native_denied_paths() { return BaseContainerRequestDecision::DeniedPathsUnsupported; } - if unrestricted_host_loopback_allowed(&request.policy) - && !Self::supports_ingress_host_loopback_allow() - && !matches!( - Self::psec_1_0_proxy_loopback_workaround(request, secenv::supports_version), - Ok(true) - ) - { - return BaseContainerRequestDecision::IngressUnsupported; - } + BaseContainerRequestDecision::Serviceable } } @@ -540,17 +480,7 @@ impl BaseContainerRunner { logger, ); - let proxy_loopback_workaround = - Self::psec_1_0_proxy_loopback_workaround(request, secenv::supports_version).map_err( - |error| ScriptResponse { - failure_phase: FailurePhase::BackendUnavailable, - ..ScriptResponse::error(&format!( - "failed to query PSEC proxy compatibility support: {error}" - )) - }, - )?; - let psec_version = - Self::choose_min_required_psec_version_for_request(request, proxy_loopback_workaround); + let psec_version = Self::choose_min_required_psec_version_for_request(request); let version_supported = secenv::supports_version(psec_version).map_err(|error| ScriptResponse { failure_phase: FailurePhase::BackendUnavailable, @@ -562,8 +492,6 @@ impl BaseContainerRunner { return Err(ScriptResponse::rejected( if !request.policy.enumerate_paths.is_empty() { PSEC_ENUMERATE_PATHS_UNSUPPORTED_MSG - } else if unrestricted_host_loopback_allowed(&request.policy) { - PSEC_INGRESS_UNSUPPORTED_MSG } else { "the required Process Security Environment schema version is not supported" }, @@ -584,14 +512,8 @@ impl BaseContainerRunner { let _ = writeln!(logger, "{EMOJI_SECTION} SECTION: captureDenials"); } - let supports_network_ingress = psec_version >= SecurityEnvironmentVersion::V1_1 - && secenv::query_support(SecurityEnvironmentSupport::NetworkIngress); - let process_security_environment_spec = self - .build_capture_aware_security_environment_spec( - request, - psec_version, - supports_network_ingress, - )?; + let process_security_environment_spec = + self.build_capture_aware_security_environment_spec(request, psec_version)?; let _ = writeln!( logger, "process security environment spec built (PSEC {}.{}, {} bytes)", @@ -2329,13 +2251,9 @@ mod tests { impl BaseContainerRunner { fn build_process_security_environment_spec(request: &ExecutionRequest) -> Vec { - let version = Self::choose_min_required_psec_version_for_request(request, false); + let version = Self::choose_min_required_psec_version_for_request(request); Self::with_capture_factory(fake_capture_factory()) - .build_capture_aware_security_environment_spec( - request, - version, - version >= SecurityEnvironmentVersion::V1_1, - ) + .build_capture_aware_security_environment_spec(request, version) .unwrap() } } @@ -3041,7 +2959,6 @@ mod tests { .build_capture_aware_security_environment_spec( &request, SecurityEnvironmentVersion::V1_0, - false, ) .unwrap(); let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); @@ -3207,51 +3124,108 @@ mod tests { } #[test] - fn psec_1_1_encodes_host_loopback_ingress() { - let mut request = ExecutionRequest::default(); - request.policy.network_ingress = Some(crate::mxc_common::models::NetworkIngressPolicy { - default: NetworkAction::Deny, - host_loopback: NetworkAction::Allow, + fn psec_direct_host_loopback_encodes_capability_and_peer() { + let mut request = host_loopback_request(); + request.policy.network_egress = Some(crate::mxc_common::models::NetworkEgressPolicy { + default: NetworkAction::Allow, + ..Default::default() }); - let bytes = BaseContainerRunner::build_process_security_environment_spec(&request); - let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); - let ingress = spec - .network_policy() - .and_then(|network| network.ingress()) - .expect("PSEC 1.1 must carry host-loopback policy"); + for version in [ + SecurityEnvironmentVersion::V1_0, + SecurityEnvironmentVersion::V1_1, + ] { + for (default, capabilities) in [ + ( + NetworkAction::Allow, + "internetClient,privateNetworkClientServer,networkLoopback", + ), + (NetworkAction::Deny, "internetClient,networkLoopback"), + ] { + request.policy.network_ingress.as_mut().unwrap().default = default; + request.policy.enumerate_paths = if version == SecurityEnvironmentVersion::V1_1 { + vec!["C:\\tools".to_string()] + } else { + Vec::new() + }; + let bytes = build_psec_v1_security_environment_spec(&request, version, false); + let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); + let network = spec.network_policy().unwrap(); - assert_eq!(spec.version().minor(), 1); - assert!(spec.capabilities().is_none()); - assert_eq!(ingress.default_action(), psec_layout::FilterAction::deny); - assert_eq!(ingress.host_loopback(), psec_layout::FilterAction::allow); - assert_eq!( - spec.network_policy() - .and_then(|network| network.allowed_appcontainer_peer()), - Some(crate::process_container_common::base_container_helpers::LOOPBACK_NETWORK_PEER) - ); + assert_eq!(spec.version().minor(), version.minor); + assert_eq!(spec.capabilities(), Some(capabilities)); + assert_eq!( + network.allowed_appcontainer_peer(), + Some( + crate::process_container_common::base_container_helpers::LOOPBACK_NETWORK_PEER + ) + ); + assert_eq!(spec.fs_enumerate().is_some(), version.minor == 1); + assert!(network.ingress().is_none()); + assert!(network.proxy().is_none()); + assert_eq!( + network.egress().unwrap().default_action(), + psec_layout::FilterAction::allow + ); + } + } + } + + #[test] + fn psec_direct_host_loopback_default_and_explicit_deny_do_not_grant_loopback() { + for ingress in [ + None, + Some(crate::mxc_common::models::NetworkIngressPolicy { + default: NetworkAction::Allow, + host_loopback: NetworkAction::Deny, + }), + ] { + let mut request = ExecutionRequest::default(); + request.policy.network_ingress = ingress; + for version in [ + SecurityEnvironmentVersion::V1_0, + SecurityEnvironmentVersion::V1_1, + ] { + let bytes = build_psec_v1_security_environment_spec(&request, version, false); + let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); + + assert!(!spec + .capabilities() + .unwrap_or_default() + .split(',') + .any(|capability| capability == "networkLoopback")); + assert!(spec + .network_policy() + .unwrap() + .allowed_appcontainer_peer() + .is_none()); + } + } } #[test] fn required_psec_contract_uses_v1_1_for_enumeration() { - assert_eq!( - BaseContainerRunner::choose_min_required_psec_version_for_request( - &enumerate_request(), - false - ), - SecurityEnvironmentVersion::V1_1 - ); + for mut request in [host_loopback_request(), identityless_proxy_request()] { + request.policy.enumerate_paths = enumerate_request().policy.enumerate_paths; + assert_eq!( + BaseContainerRunner::choose_min_required_psec_version_for_request(&request), + SecurityEnvironmentVersion::V1_1 + ); + } } #[test] - fn required_psec_contract_uses_v1_1_for_host_loopback() { - assert_eq!( - BaseContainerRunner::choose_min_required_psec_version_for_request( - &host_loopback_request(), - false - ), - SecurityEnvironmentVersion::V1_1 - ); + fn loopback_and_proxy_do_not_raise_minimum_psec_version() { + let _guard = crate::process_container_common::test_env::BcUsableGuard::set(true); + for request in [host_loopback_request(), identityless_proxy_request()] { + assert!( + BaseContainerRunner::can_backend_service_request(&request).can_service_request() + ); + assert_eq!( + BaseContainerRunner::choose_min_required_psec_version_for_request(&request), + SecurityEnvironmentVersion::V1_0 + ); + } } #[test] @@ -3259,13 +3233,12 @@ mod tests { assert_eq!( BaseContainerRunner::choose_min_required_psec_version_for_request( &ExecutionRequest::default(), - false ), SecurityEnvironmentVersion::V1_0 ); } - fn psec_1_0_proxy_request() -> ExecutionRequest { + fn identityless_proxy_request() -> ExecutionRequest { let mut request = host_loopback_request(); request.policy.runtime_network_proxy_specified = true; request.policy.network_proxy = ProxyConfig { @@ -3280,184 +3253,6 @@ mod tests { assert!(!BaseContainerRunner::supports_identityless_loopback_proxy().unwrap()); } - #[test] - fn identityless_loopback_proxy_supports_ingress_or_psec_1_0_only() { - for (supports_1_0, supports_1_1, ingress_supported, expected) in [ - (true, false, false, true), - (true, true, true, true), - (true, true, false, false), - (false, true, true, true), - (false, true, false, false), - (false, false, false, false), - ] { - let supported = BaseContainerRunner::identityless_loopback_proxy_supported( - ingress_supported, - |version| { - Ok(if version == SecurityEnvironmentVersion::V1_1 { - supports_1_1 - } else { - supports_1_0 - }) - }, - ) - .unwrap(); - assert_eq!( - supported, expected, - "PSEC 1.0={supports_1_0}, PSEC 1.1={supports_1_1}, ingress={ingress_supported}" - ); - } - } - - #[test] - fn identityless_loopback_proxy_with_ingress_does_not_need_compatibility_queries() { - assert!( - BaseContainerRunner::identityless_loopback_proxy_supported(true, |_| { - panic!("ingress support is sufficient for an identity-less loopback proxy") - }) - .unwrap() - ); - } - - #[test] - fn identityless_loopback_proxy_propagates_version_query_errors() { - for failing_version in [ - SecurityEnvironmentVersion::V1_0, - SecurityEnvironmentVersion::V1_1, - ] { - let result = - BaseContainerRunner::identityless_loopback_proxy_supported(false, |version| { - if version == failing_version { - Err(LearningModeError::HResultCall { - function: "IsProcessSecurityEnvironmentVersionSupported", - code: E_NOTIMPL.0, - }) - } else { - Ok(false) - } - }); - assert!(matches!(result, Err(LearningModeError::HResultCall { .. }))); - } - } - - #[test] - fn psec_1_0_proxy_workaround_requires_a_1_0_only_host() { - let request = psec_1_0_proxy_request(); - for (supports_1_0, supports_1_1, expected) in [ - (true, false, true), - (true, true, false), - (false, false, false), - ] { - let mut queried = Vec::new(); - let workaround = - BaseContainerRunner::psec_1_0_proxy_loopback_workaround(&request, |version| { - queried.push(version); - Ok(if version == SecurityEnvironmentVersion::V1_1 { - supports_1_1 - } else { - supports_1_0 - }) - }) - .unwrap(); - assert_eq!(workaround, expected); - assert_eq!( - queried, - if supports_1_1 { - vec![SecurityEnvironmentVersion::V1_1] - } else { - vec![ - SecurityEnvironmentVersion::V1_1, - SecurityEnvironmentVersion::V1_0, - ] - } - ); - } - } - - #[test] - fn psec_1_0_proxy_workaround_requires_explicit_identity_less_proxy_and_loopback_allow() { - for case in 0..4 { - let mut request = psec_1_0_proxy_request(); - match case { - 0 => request.policy.runtime_network_proxy_specified = false, - 1 => request.policy.network_proxy = ProxyConfig::default(), - 2 => request.policy.allowed_proxy_peer = Some("Contoso.Proxy_123".to_string()), - 3 => { - request - .policy - .network_ingress - .as_mut() - .unwrap() - .host_loopback = NetworkAction::Deny; - } - _ => unreachable!(), - } - assert!(!BaseContainerRunner::psec_1_0_proxy_loopback_workaround( - &request, - |_| panic!("ineligible policy must not query the OS"), - ) - .unwrap()); - } - } - - #[test] - fn psec_1_0_proxy_workaround_propagates_version_query_errors() { - let request = psec_1_0_proxy_request(); - for failing_version in [ - SecurityEnvironmentVersion::V1_0, - SecurityEnvironmentVersion::V1_1, - ] { - let result = - BaseContainerRunner::psec_1_0_proxy_loopback_workaround(&request, |version| { - if version == failing_version { - Err(LearningModeError::HResultCall { - function: "IsProcessSecurityEnvironmentVersionSupported", - code: E_NOTIMPL.0, - }) - } else { - Ok(false) - } - }); - assert!(matches!(result, Err(LearningModeError::HResultCall { .. }))); - } - } - - #[test] - fn psec_1_0_proxy_workaround_encodes_existing_loopback_capability_and_peer() { - let request = psec_1_0_proxy_request(); - let version = - BaseContainerRunner::choose_min_required_psec_version_for_request(&request, true); - assert_eq!(version, SecurityEnvironmentVersion::V1_0); - let bytes = build_psec_v1_security_environment_spec(&request, version, false, false); - let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); - let network = spec.network_policy().unwrap(); - assert_eq!(spec.version().minor(), 0); - assert!(spec - .capabilities() - .unwrap() - .split(',') - .any(|capability| capability == "networkLoopback")); - assert_eq!( - network.allowed_appcontainer_peer(), - Some(crate::process_container_common::base_container_helpers::LOOPBACK_NETWORK_PEER) - ); - assert_eq!( - network.proxy().unwrap().url(), - Some("http://127.0.0.1:8080") - ); - assert!(network.ingress().is_none()); - assert!(network.egress().is_none()); - } - - #[test] - fn psec_1_0_proxy_workaround_does_not_lower_enumeration_requirement() { - let mut request = psec_1_0_proxy_request(); - request.policy.enumerate_paths = vec!["C:\\tools".to_string()]; - assert_eq!( - BaseContainerRunner::choose_min_required_psec_version_for_request(&request, true), - SecurityEnvironmentVersion::V1_1 - ); - } - #[test] #[should_panic( expected = "build_psec_v1_security_environment_spec only supports PSEC major version 1" @@ -3467,18 +3262,16 @@ mod tests { &ExecutionRequest::default(), SecurityEnvironmentVersion { major: 2, minor: 0 }, false, - false, ); } #[test] - fn psec_enumeration_without_ingress_support_omits_ingress_table() { + fn psec_enumeration_omits_ingress_table() { let request = enumerate_request(); let bytes = build_psec_v1_security_environment_spec( &request, SecurityEnvironmentVersion::V1_1, false, - false, ); let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); @@ -3487,7 +3280,7 @@ mod tests { } #[test] - fn psec_1_0_uses_capability_for_ingress_default_allow() { + fn psec_ingress_default_allow_uses_private_network_capability() { let mut request = ExecutionRequest::default(); request.policy.network_ingress = Some(crate::mxc_common::models::NetworkIngressPolicy { default: NetworkAction::Allow, @@ -3495,24 +3288,24 @@ mod tests { }); request.policy.allowed_proxy_peer = Some("S-1-15-2-1".into()); - let bytes = build_psec_v1_security_environment_spec( - &request, + for version in [ SecurityEnvironmentVersion::V1_0, - false, - false, - ); - let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); - let network = spec.network_policy().expect("network policy"); + SecurityEnvironmentVersion::V1_1, + ] { + let bytes = build_psec_v1_security_environment_spec(&request, version, false); + let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); + let network = spec.network_policy().expect("network policy"); - assert_eq!(spec.version().minor(), 0); - assert_eq!( - spec.capabilities(), - Some( - crate::process_container_common::network_policy_helpers::PRIVATE_NETWORK_CAPABILITY - ) - ); - assert_eq!(network.allowed_appcontainer_peer(), Some("S-1-15-2-1")); - assert!(network.ingress().is_none()); + assert_eq!(spec.version().minor(), version.minor); + assert_eq!( + spec.capabilities(), + Some( + crate::process_container_common::network_policy_helpers::PRIVATE_NETWORK_CAPABILITY + ) + ); + assert_eq!(network.allowed_appcontainer_peer(), Some("S-1-15-2-1")); + assert!(network.ingress().is_none()); + } } #[test] diff --git a/src/mxc-sdk/src/backends/process_container/common/probe.rs b/src/mxc-sdk/src/backends/process_container/common/probe.rs index a6039ffcb..9f3327f32 100644 --- a/src/mxc-sdk/src/backends/process_container/common/probe.rs +++ b/src/mxc-sdk/src/backends/process_container/common/probe.rs @@ -71,8 +71,9 @@ pub struct ProbeFacts { pub base_container_supports_deny_paths: bool, /// Whether PSEC 1.1 can enforce `processContainer.filesystem.enumeratePaths` at Tier 1. pub base_container_supports_enumerate_paths: bool, - /// Whether BaseContainer can honor - /// `network.ingress.hostLoopback = "allow"`. + /// With explicit host-loopback allow, the baseline is policy-permitted stateful outbound + /// loopback. Native Windows ingress support adds bidirectional access, including + /// container-hosted loopback servers. pub base_container_supports_ingress_host_loopback_allow: bool, /// Whether BaseContainer supports an identity-less proxy on loopback. /// Requires explicit host-loopback allow; not general ingress support. diff --git a/src/mxc-sdk/src/core/mxc_engine/probe.rs b/src/mxc-sdk/src/core/mxc_engine/probe.rs index 428aa6207..88e01c07d 100644 --- a/src/mxc-sdk/src/core/mxc_engine/probe.rs +++ b/src/mxc-sdk/src/core/mxc_engine/probe.rs @@ -26,7 +26,9 @@ pub enum BackendCapability { FilesystemDeniedPaths, /// Native `processContainer.filesystem.enumeratePaths` enforcement at the reported tier. FilesystemEnumeratePaths, - /// `network.ingress.hostLoopback = "allow"` at the reported tier. + /// With explicit host-loopback allow, the baseline is policy-permitted stateful outbound + /// loopback. Native Windows ingress support adds bidirectional access, including + /// container-hosted loopback servers. IngressHostLoopbackAllow, /// Bubblewrap proxy-only egress in a private network namespace. ProxyEnforcement, diff --git a/tests/scripts/README.md b/tests/scripts/README.md index 2ccffdb3e..18cc9c4e6 100644 --- a/tests/scripts/README.md +++ b/tests/scripts/README.md @@ -62,11 +62,14 @@ tests\scripts\run_processcontainer_network_proxy_test.ps1 -RequireTier base-cont When the host probe reports `baseContainerSupportsIdentitylessLoopbackProxy`, the proxy area requires workload launch and a successful proxied fetch on both -PSEC 1.0-only hosts and hosts with PSEC 1.1 ingress support. +PSEC 1.0-only hosts and PSEC 1.1 hosts, including those without ingress support. A clean policy rejection is a failure on these hosts. Older binaries that omit this probe fact do not enable the capability-specific assertions; the existing proxy assertions still apply. +The loopback area checks direct access with both ingress defaults. The proxy +area checks that other IPv4 and IPv6 loopback endpoints remain blocked. + Shared helpers live in `tests/scripts/lib/WinProcessContainer.Common.ps1`. It must be **dot-sourced, not imported as a module** — `Initialize-WpcContext` publishes the suite context into the calling script's scope, which only works diff --git a/tests/scripts/lib/WinProcessContainer.Common.ps1 b/tests/scripts/lib/WinProcessContainer.Common.ps1 index ae0be591f..509bc144a 100644 --- a/tests/scripts/lib/WinProcessContainer.Common.ps1 +++ b/tests/scripts/lib/WinProcessContainer.Common.ps1 @@ -488,6 +488,16 @@ function Record-UiTelemetryResult { # Default schema version for generated configs. $Script:SchemaVersion = '1.0.0' +function Write-WpcJsonFile { + param( + [Parameter(Mandatory)] [string]$Path, + [Parameter(Mandatory)] $Object + ) + # Windows PowerShell's utf8 encoding adds a BOM, which MXC does not accept. + [System.IO.File]::WriteAllText($Path, ($Object | ConvertTo-Json -Depth 12), + [System.Text.UTF8Encoding]::new($false)) +} + # Write a config object verbatim. Used by the rejection phase for shapes the # typed generator deliberately cannot produce (an explicitly empty `to: []`, # a hostname where a CIDR belongs), since the property being asserted is that @@ -498,7 +508,7 @@ function New-RawConfig { [Parameter(Mandatory)] $Object ) $path = Join-Path (Join-Path $ScratchRoot 'configs') "$Name.json" - ($Object | ConvertTo-Json -Depth 12) | Out-File -LiteralPath $path -Encoding utf8 -Force + Write-WpcJsonFile -Path $path -Object $Object return $path } @@ -688,7 +698,7 @@ function New-Config { if ($pc.Count -gt 0) { $obj['processContainer'] = $pc } $path = Join-Path (Join-Path $ScratchRoot 'configs') "$Name.json" - ($obj | ConvertTo-Json -Depth 12) | Out-File -LiteralPath $path -Encoding utf8 -Force + Write-WpcJsonFile -Path $path -Object $obj return $path } @@ -1010,18 +1020,14 @@ function Get-HostDnsServers { } catch { return @() } } -# Host-side HTTP listener on 127.0.0.1, used as the host-loopback anchor. -# Returned object carries Url/Port plus a Stop() closure. -# -# A raw TcpListener, not HttpListener: the latter goes through http.sys and -# needs a URL ACL reservation an unelevated account lacks, so it fails to bind -# on exactly the hosts this phase must run on. The accept loop sits on a -# background runspace so the harness thread stays free. +# Raw TCP avoids HTTP.sys URL ACL requirements; a runspace handles requests. function Start-LoopbackListener { - # Bind port 0 and read back what the OS assigned, rather than reserving a - # port up front and re-binding it: another process can take the port in - # between, and the anchor then answers on a socket nobody can reach. - $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + param([System.Net.IPAddress]$BindAddress = [System.Net.IPAddress]::Loopback) + + $listener = [System.Net.Sockets.TcpListener]::new($BindAddress, 0) + if ($BindAddress.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetworkV6) { + $listener.Server.DualMode = $false + } try { $listener.Start() } catch { @@ -1038,8 +1044,7 @@ function Start-LoopbackListener { try { $client = $l.AcceptTcpClient() $stream = $client.GetStream() - # Read whatever request line the client sent before replying; - # curl will not report success if the peer resets first. + # Read before replying so curl does not observe a connection reset. $stream.ReadTimeout = 2000 $buf = New-Object byte[] 1024 try { [void]$stream.Read($buf, 0, $buf.Length) } catch {} @@ -1052,7 +1057,7 @@ function Start-LoopbackListener { $handle = $ps.BeginInvoke() return [pscustomobject]@{ Port = $port - Url = "http://127.0.0.1:$port/" + Url = ([UriBuilder]::new('http', $BindAddress.ToString(), $port)).Uri.AbsoluteUri Stop = { try { $listener.Stop() } catch {} try { [void]$ps.EndInvoke($handle) } catch {} @@ -1061,12 +1066,12 @@ function Start-LoopbackListener { } } -# Contained command line that fetches a host-loopback URL. Same two-branch -# token contract as Get-AnchorFetchCommand. +# Fetch directly so a child-launch failure cannot look like blocked networking. function Get-LoopbackFetchCommand { - param([Parameter(Mandatory)][string]$Url, [int]$TimeoutSec = 5) + param([Parameter(Mandatory)][string]$Url, [int]$TimeoutSec = 5, [switch]$UseProxyEnv) $curl = Join-Path $env:SystemRoot 'System32\curl.exe' - return "$env:SystemRoot\System32\cmd.exe /c `"$curl --silent --show-error --max-time $TimeoutSec --output NUL $Url && echo NET=REACHED || echo NET=BLOCKED`"" + $noProxy = if ($UseProxyEnv) { '' } else { '*' } + return "`"$curl`" --silent --show-error --max-time $TimeoutSec --noproxy `"$noProxy`" $Url" } @@ -1099,6 +1104,10 @@ function Invoke-NetRun { ) $log = Join-Path $ScratchRoot "logs\$Name.log" $r = Invoke-Wxc -Wxc $WxcDebug -ConfigPath $ConfigPath -LogPath $log -TimeoutSec $TimeoutSec + [System.IO.File]::WriteAllText((Join-Path $ScratchRoot "logs\$Name.stdout.txt"), $r.Stdout, + [System.Text.UTF8Encoding]::new($false)) + [System.IO.File]::WriteAllText((Join-Path $ScratchRoot "logs\$Name.stderr.txt"), $r.Stderr, + [System.Text.UTF8Encoding]::new($false)) $logContent = Read-Log $log return [pscustomobject]@{ Result = $r diff --git a/tests/scripts/run_processcontainer_all_tests.ps1 b/tests/scripts/run_processcontainer_all_tests.ps1 index 846c81943..d20e7609a 100644 --- a/tests/scripts/run_processcontainer_all_tests.ps1 +++ b/tests/scripts/run_processcontainer_all_tests.ps1 @@ -68,8 +68,7 @@ Set-StrictMode -Version Latest . (Join-Path $PSScriptRoot 'lib\WinProcessContainer.Common.ps1') -# Area registry: key -> the script that owns it. The keys are unchanged from the -# pre-split harness's phase names, so an existing -Phases list keeps working. +# Area registry: key -> the script that owns it. $AreaScripts = [ordered]@{ 'Probes' = 'run_processcontainer_probes_test.ps1' 'T3Forced' = 'run_processcontainer_filesystem_matrix_test.ps1' @@ -87,7 +86,7 @@ $AreaScripts = [ordered]@{ 'NetworkCapabilityMatrix' = 'run_processcontainer_network_capability_test.ps1' 'NetworkModel3Equivalence' = 'run_processcontainer_network_model3_test.ps1' 'NetworkEgressRules' = 'run_processcontainer_network_egress_test.ps1' - 'NetworkHostLoopback' = 'run_processcontainer_network_loopback_test.ps1' + 'NetworkDirectHostLoopback' = 'run_processcontainer_network_loopback_test.ps1' 'NetworkProxy' = 'run_processcontainer_network_proxy_test.ps1' 'NetworkRejections' = 'run_processcontainer_network_rejections_test.ps1' 'PathAliasing' = 'run_processcontainer_path_aliasing_test.ps1' @@ -103,6 +102,9 @@ if (-not $PSHostExe) { $PSHostExe = 'powershell.exe' } # Validate the selection before anything expensive happens. if ($Areas.Count -gt 0) { + $Areas = @($Areas | ForEach-Object { + if ($_ -eq 'NetworkHostLoopback') { 'NetworkDirectHostLoopback' } else { $_ } + }) $unknown = @($Areas | Where-Object { $_ -notin $AreaScripts.Keys }) if ($unknown.Count -gt 0) { throw "Unknown -Areas value(s): $($unknown -join ', '). Valid: $($AreaScripts.Keys -join ', ')" diff --git a/tests/scripts/run_processcontainer_network_loopback_test.ps1 b/tests/scripts/run_processcontainer_network_loopback_test.ps1 index 1402b2061..41e75d9f6 100644 --- a/tests/scripts/run_processcontainer_network_loopback_test.ps1 +++ b/tests/scripts/run_processcontainer_network_loopback_test.ps1 @@ -32,10 +32,10 @@ Initialize-WpcContext @PSBoundParameters # of getting this wrong are user-visible — an unreachable local dev server, or # a loopback hole — and only a live run distinguishes them. # -# `hostLoopback: "allow"` is PSEC-1.1-only; every other path must reject it -# rather than accept it with partial enforcement. -function Phase-NetworkHostLoopback { - Section 'Phase 8d: host-loopback policy' +# BaseContainer must reach the host listener without a runtime proxy. +# AppContainer tiers must reject hostLoopback=allow. +function Phase-NetworkDirectHostLoopback { + Section 'Phase 8d: direct host-loopback policy' if ($SkipNetwork) { Record-Result -Phase 'P8d' -Name 'host loopback' -Status 'skip' -Detail '-SkipNetwork' @@ -45,7 +45,7 @@ function Phase-NetworkHostLoopback { $listener = Start-LoopbackListener if (-not $listener) { Record-Result -Phase 'P8d' -Name 'host loopback listener' -Pass $false ` - -Detail 'could not bind an HttpListener on 127.0.0.1; cannot assert either direction' + -Detail 'could not bind a TcpListener on 127.0.0.1; cannot assert either direction' return } @@ -69,63 +69,53 @@ function Phase-NetworkHostLoopback { -ReadWrite $fs.ReadWrite -ReadOnly $fs.ReadOnly ` -EgressDefault 'allow' -TimeoutMs 25000 $trap = Invoke-NetRun -Name 'net-loopback-trap' -ConfigPath $cfgTrap + $trapBlocked = (-not $trap.Result.TimedOut) -and + ($trap.Result.ExitCode -in @(7, 28)) -and + ($trap.Result.Stderr -match 'curl: \((7|28)\)') Record-Result -Phase 'P8d' -Name 'omitted hostLoopback defaults to deny even under egress=allow' ` - -Pass ($trap.Verdict -eq 'BLOCKED') ` - -Detail "verdict=$($trap.Verdict); documented default-deny, not an inherit of egress/ingress default" + -Pass $trapBlocked ` + -Detail "exit=$($trap.Result.ExitCode); stderr=$(Format-Snippet $trap.Result.Stderr); documented default-deny" # Explicit deny, spelled out. $cfgDeny = New-Config -Name 'net-loopback-deny' -CommandLine $cmd ` -ReadWrite $fs.ReadWrite -ReadOnly $fs.ReadOnly ` -EgressDefault 'allow' -IngressDefault 'deny' -HostLoopback 'deny' -TimeoutMs 25000 $deny = Invoke-NetRun -Name 'net-loopback-deny' -ConfigPath $cfgDeny + $denyBlocked = (-not $deny.Result.TimedOut) -and + ($deny.Result.ExitCode -in @(7, 28)) -and + ($deny.Result.Stderr -match 'curl: \((7|28)\)') Record-Result -Phase 'P8d' -Name 'explicit hostLoopback=deny blocks container -> host loopback' ` - -Pass ($deny.Verdict -eq 'BLOCKED') -Detail "verdict=$($deny.Verdict)" + -Pass $denyBlocked -Detail "exit=$($deny.Result.ExitCode); stderr=$(Format-Snippet $deny.Result.Stderr)" Record-Result -Phase 'P8d' -Name 'omitted and explicit hostLoopback=deny agree' ` - -Pass ((Test-VerdictsRan @($trap, $deny)) -and ($trap.Verdict -eq $deny.Verdict)) ` - -Detail "omitted=$($trap.Verdict); explicit=$($deny.Verdict)" - - # hostLoopback=allow. `ingress.default: allow` accompanies it because - # the private-network capability is what the doc pairs with the - # loopback grant; the specific value overrides the default for the - # loopback path. - $cfgAllow = New-Config -Name 'net-loopback-allow' -CommandLine $cmd ` - -ReadWrite $fs.ReadWrite -ReadOnly $fs.ReadOnly ` - -EgressDefault 'allow' -IngressDefault 'allow' -HostLoopback 'allow' -TimeoutMs 25000 - $allow = Invoke-NetRun -Name 'net-loopback-allow' -ConfigPath $cfgAllow - - if (Test-PsecEligible) { - # On a PSEC 1.1 host this must work. On a PSEC 1.0 host the - # documented behavior is rejection — so either outcome is - # defensible, but "accepted and silently unenforced" is not. - $accepted = ($allow.Verdict -eq 'REACHED') - $rejected = Test-WasRejected $allow - Record-Result -Phase 'P8d' -Name 'hostLoopback=allow is either enforced or rejected, never silently dropped' ` - -Pass ($accepted -or $rejected) ` - -Detail "verdict=$($allow.Verdict); exit=$($allow.Result.ExitCode); timedOut=$($allow.Result.TimedOut); accepted=$accepted rejected=$rejected" - if ($accepted) { - Record-Result -Phase 'P8d' -Name 'hostLoopback=allow reaches the host loopback anchor (PSEC 1.1)' ` - -Pass $true -Detail 'bidirectional host-loopback grant honored' - } elseif ($rejected) { - Record-Result -Phase 'P8d' -Name 'hostLoopback=allow rejected (PSEC 1.1 ingress contract unavailable)' ` - -Status 'skip' -Detail "exit=$($allow.Result.ExitCode); documented fallback when contract 1.1 is absent" + -Pass ($trapBlocked -and $denyBlocked) ` + -Detail "omittedExit=$($trap.Result.ExitCode); explicitExit=$($deny.Result.ExitCode)" + + # Loopback permission is independent of the private-network ingress default. + foreach ($ingressDefault in @('allow', 'deny')) { + $name = "net-loopback-allow-ingress-$ingressDefault" + $cfgAllow = New-Config -Name $name -CommandLine $cmd ` + -ReadWrite $fs.ReadWrite -ReadOnly $fs.ReadOnly ` + -EgressDefault 'allow' -IngressDefault $ingressDefault -HostLoopback 'allow' -TimeoutMs 25000 + $allow = Invoke-NetRun -Name $name -ConfigPath $cfgAllow + + if (Test-PsecEligible) { + Record-Result -Phase 'P8d' -Name "hostLoopback=allow with ingress.default=$ingressDefault without a runtime proxy stays on BaseContainer" ` + -Pass (Test-SelectedTier $allow.Log) ` + -Detail "selectedTier=$(Get-SelectedTier $allow.Log); expected=base-container" + Record-Result -Phase 'P8d' -Name "hostLoopback=allow with ingress.default=$ingressDefault without a runtime proxy reaches the host loopback anchor" ` + -Pass (($allow.Result.Stdout.Trim() -eq 'MXC-LOOPBACK-ANCHOR') -and ($allow.Result.ExitCode -eq 0) -and -not $allow.Result.TimedOut) ` + -Detail "exit=$($allow.Result.ExitCode); timedOut=$($allow.Result.TimedOut); stderr=$(Format-Snippet $allow.Result.Stderr); rejection is a regression, not a skip" } else { - # Neither enforced nor refused. Calling this a skip would name an - # outcome that did not happen. - Record-Result -Phase 'P8d' -Name 'hostLoopback=allow reaches the host loopback anchor (PSEC 1.1)' ` - -Pass $false ` - -Detail "verdict=$($allow.Verdict); exit=$($allow.Result.ExitCode); neither reached the anchor nor produced a typed rejection" + $rejected = Test-WasRejected $allow + Record-Result -Phase 'P8d' -Name "non-PSEC tier rejects hostLoopback=allow with ingress.default=$ingressDefault" ` + -Pass $rejected ` + -Detail "verdict=$($allow.Verdict); exit=$($allow.Result.ExitCode); timedOut=$($allow.Result.TimedOut); tier=$($Script:ExpectedTier)" } - } else { - $rejected = Test-WasRejected $allow - Record-Result -Phase 'P8d' -Name 'non-PSEC tier rejects hostLoopback=allow' ` - -Pass $rejected ` - -Detail "verdict=$($allow.Verdict); exit=$($allow.Result.ExitCode); timedOut=$($allow.Result.TimedOut); tier=$($Script:ExpectedTier)" } } finally { & $listener.Stop } } -Invoke-WpcPhase -Key 'NetworkHostLoopback' -Body { Phase-NetworkHostLoopback } +Invoke-WpcPhase -Key 'NetworkDirectHostLoopback' -Body { Phase-NetworkDirectHostLoopback } Complete-WpcChild - diff --git a/tests/scripts/run_processcontainer_network_proxy_test.ps1 b/tests/scripts/run_processcontainer_network_proxy_test.ps1 index 6f610ea70..190fe6562 100644 --- a/tests/scripts/run_processcontainer_network_proxy_test.ps1 +++ b/tests/scripts/run_processcontainer_network_proxy_test.ps1 @@ -55,10 +55,43 @@ function Phase-NetworkProxy { -Detail 'could not bind a listener on 127.0.0.1; cannot assert the proxy endpoint is reachable' return } + $directListeners = @() + $directLoopbackUrls = @() try { - Invoke-NetworkProxyAssertions -ProxyPort $proxy.Port -Fs $fs -Psec $psec + if ($psec) { + foreach ($address in @('127.0.0.1', '::1')) { + $directListener = Start-LoopbackListener -BindAddress ([Net.IPAddress]::Parse($address)) + if (-not $directListener) { + Record-Result -Phase 'P8e' -Name "direct loopback listener on $address" -Pass $false ` + -Detail 'could not bind a second listener; cannot assert proxy endpoint isolation' + return + } + $directListeners += $directListener + $directLoopbackUrls += $directListener.Url + $hostReach = $false + $hostError = '' + try { + $response = Invoke-WebRequest -Uri $directListener.Url -TimeoutSec 5 -UseBasicParsing + $hostReach = ($response.Content -match 'MXC-LOOPBACK-ANCHOR') + } catch { + $hostError = $_.Exception.Message + } + Record-Result -Phase 'P8e' -Name "prerequisite: host reaches the second loopback endpoint on $address" ` + -Pass $hostReach ` + -Detail "endpoint=$($directListener.Url); error=$hostError" + if (-not $hostReach) { return } + } + } + Invoke-NetworkProxyAssertions -ProxyPort $proxy.Port -DirectLoopbackUrls $directLoopbackUrls -Fs $fs -Psec $psec } finally { - & $proxy.Stop + foreach ($listener in ($directListeners + @($proxy))) { + try { + & $listener.Stop + } catch { + Record-Result -Phase 'P8e' -Name 'listener cleanup' -Pass $false ` + -Detail "endpoint=$($listener.Url); error=$($_.Exception.Message)" + } + } } } @@ -66,6 +99,7 @@ function Phase-NetworkProxy { function Invoke-NetworkProxyAssertions { param( [Parameter(Mandatory)] [int]$ProxyPort, + [string[]]$DirectLoopbackUrls = @(), [Parameter(Mandatory)] $Fs, [Parameter(Mandatory)] [bool]$Psec ) @@ -88,9 +122,7 @@ function Invoke-NetworkProxyAssertions { $out = $envRun.Result.Stdout $ran = [bool]($out -match '(?im)^SystemRoot=') $identitylessProxyAvailable = $Script:Caps.BaseContainerSupportsIdentitylessLoopbackProxy - # PSEC 1.0-only hosts use the explicit identity-less proxy workaround. - # PSEC 1.1+ still requires the ingress support flag. A bare OS error - # is never a valid substitute for successful execution or rejection. + # A bare OS error is not successful execution or a policy rejection. $rejectedCleanly = Test-WasRejected $envRun Record-Result -Phase 'P8e' -Name 'identity-less proxy config either runs or is rejected cleanly (never a bare OS error)' ` -Pass ($ran -or $rejectedCleanly) ` @@ -124,14 +156,14 @@ function Invoke-NetworkProxyAssertions { # can only mean the proxy endpoint was reachable, and the # assertion needs no external connectivity. $cfgVia = New-Config -Name 'net-proxy-via-endpoint' ` - -CommandLine (Get-AnchorFetchCommand -Url 'http://mxc-proxy-probe.invalid/') ` + -CommandLine (Get-LoopbackFetchCommand -Url 'http://mxc-proxy-probe.invalid/' -UseProxyEnv) ` -ReadWrite $fs.ReadWrite -ReadOnly $fs.ReadOnly ` -EgressDefault 'deny' -IngressDefault 'allow' -HostLoopback 'allow' ` -NetworkProxy $proxyUrl -TimeoutMs 25000 $via = Invoke-NetRun -Name 'net-proxy-via-endpoint' -ConfigPath $cfgVia Record-Result -Phase 'P8e' -Name 'a proxied fetch reaches the configured proxy endpoint' ` - -Pass ($via.Verdict -eq 'REACHED') ` - -Detail ("verdict=$($via.Verdict); endpoint=127.0.0.1:$port; " + + -Pass (($via.Result.Stdout.Trim() -eq 'MXC-LOOPBACK-ANCHOR') -and ($via.Result.ExitCode -eq 0) -and -not $via.Result.TimedOut) ` + -Detail ("exit=$($via.Result.ExitCode); stderr=$(Format-Snippet $via.Result.Stderr); endpoint=127.0.0.1:$port; " + 'target is unresolvable, so REACHED is attributable to the proxy alone') } @@ -143,16 +175,34 @@ function Invoke-NetworkProxyAssertions { if (-not $ran) { Record-Result -Phase 'P8e' -Name 'direct egress blocked while runtime proxy is configured' -Status 'skip' ` -Detail 'the identity-less proxy shape does not start on this host; a NORUN cannot prove egress was blocked' + foreach ($url in $DirectLoopbackUrls) { + Record-Result -Phase 'P8e' -Name "direct access to a second loopback endpoint is blocked ($url)" -Status 'skip' ` + -Detail 'the identity-less proxy shape does not start on this host; a NORUN cannot prove endpoint isolation' + } } else { + foreach ($url in $DirectLoopbackUrls) { + $family = if (([Uri]$url).Host -match ':') { 'ipv6' } else { 'ipv4' } + $name = "net-proxy-direct-loopback-blocked-$family" + $cfgLoopback = New-Config -Name $name ` + -CommandLine (Get-LoopbackFetchCommand -Url $url) ` + -ReadWrite $fs.ReadWrite -ReadOnly $fs.ReadOnly ` + -EgressDefault 'deny' -IngressDefault 'allow' -HostLoopback 'allow' ` + -NetworkProxy $proxyUrl -TimeoutMs 25000 + $loopback = Invoke-NetRun -Name $name -ConfigPath $cfgLoopback + Record-Result -Phase 'P8e' -Name "direct access to a second $family loopback endpoint is blocked" ` + -Pass ((-not $loopback.Result.TimedOut) -and ($loopback.Result.ExitCode -in @(7, 28)) -and ($loopback.Result.Stderr -match 'curl: \((7|28)\)')) ` + -Detail "exit=$($loopback.Result.ExitCode); stderr=$(Format-Snippet $loopback.Result.Stderr); endpoint=$url; host reachability verified" + } + $cfgDirect = New-Config -Name 'net-proxy-direct-blocked' ` - -CommandLine (Get-AnchorFetchCommand -IgnoreProxyEnv) ` + -CommandLine (Get-LoopbackFetchCommand -Url $ExternalAnchorUrl) ` -ReadWrite $fs.ReadWrite -ReadOnly $fs.ReadOnly ` -EgressDefault 'deny' -IngressDefault 'allow' -HostLoopback 'allow' ` -NetworkProxy $proxyUrl -TimeoutMs 25000 $direct = Invoke-NetRun -Name 'net-proxy-direct-blocked' -ConfigPath $cfgDirect Record-Result -Phase 'P8e' -Name 'direct egress blocked while runtime proxy is configured' ` - -Pass ($direct.Verdict -eq 'BLOCKED') ` - -Detail "verdict=$($direct.Verdict); WFP scopes egress to the proxy endpoint only" + -Pass ((-not $direct.Result.TimedOut) -and ($direct.Result.ExitCode -in @(6, 7, 28)) -and ($direct.Result.Stderr -match 'curl: \((6|7|28)\)')) ` + -Detail "exit=$($direct.Result.ExitCode); stderr=$(Format-Snippet $direct.Result.Stderr); WFP scopes egress to the proxy endpoint only" } } else { # "runtime proxy requests do not fall back because