From e3b7e2d5612a040dccd25a59df4859dbf7380bf5 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 2 Oct 2026 16:25:46 -0300 Subject: [PATCH 1/3] ProcessContainer: let an identity-less proxy run under PSEC 1.0 An unpackaged, non-AppContainer proxy had to send `network.ingress.hostLoopback: "allow"`. That value raises the request to PSEC contract 1.1 with ingress support, so a host whose BaseContainer reports only PSEC 1.0 could not use `runtimeConfig.networkProxy` at all. The parser refused the `"deny"` spelling, and the capability that authorizes the loopback connection was granted only beside `"allow"`, so no caller could reach its proxy on such a host. The grant belongs to the proxy identity, not to the host-loopback setting. `networkLoopback` and the reserved `MXC-Loopback` peer now follow `identity_less_proxy`: a proxy with no `allowedProxyPeer`. `hostLoopback` keeps its own meaning -- `"allow"` additionally requests the bidirectional contract and its PSEC 1.1 host requirement, while `"deny"` keeps the configured endpoint alone under the PSEC 1.0 capability mapping. WFP endpoint scoping is unchanged in both, so the client still reaches only the proxy. The posture an identity-scoped proxy must keep is untouched: `hostLoopback: "allow"` beside `allowedProxyPeer` is still refused, and every proxy still requires `ingress.default: "allow"`. The bidirectional half does not work today in any case: on Windows build 26691.1002, host-to-container listener connections time out in proxy mode, as docs/process-container/networking.md records. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker --- docs/process-container/networking.md | 33 +++++---- docs/process-container/os-version-support.md | 7 ++ .../0.8.0/networking/networking.md | 5 +- docs/schema.md | 10 +-- .../common/src/base_container_helpers.rs | 24 +++++-- .../src/base_container_helpers/tests.rs | 58 +++++++++++++++ src/core/wxc_common/src/config_parser.rs | 33 +++++++-- src/core/wxc_common/src/network_parser.rs | 72 ++++++++++++++++--- ...un_processcontainer_network_proxy_test.ps1 | 25 ++++--- 9 files changed, 222 insertions(+), 45 deletions(-) diff --git a/docs/process-container/networking.md b/docs/process-container/networking.md index b1eee8588..a7411fde8 100644 --- a/docs/process-container/networking.md +++ b/docs/process-container/networking.md @@ -93,7 +93,7 @@ bind only the exact loopback address and port supplied to MXC. |---|---|---|---| | Packaged proxy | Package Family Name | `default: "allow"`; `hostLoopback: "deny"` | Package identity | | Unpackaged AppContainer | Profile | `default: "allow"`; `hostLoopback: "deny"` | AppContainer profile | -| Unpackaged non-AppContainer (development/testing compatibility) | Omit | `default: "allow"`; `hostLoopback: "allow"` | None | +| Unpackaged non-AppContainer (development/testing compatibility) | Omit | `default: "allow"`; `hostLoopback: "allow"` or `"deny"` | None | All deployments retain the base Model 2 client policy. `allowedProxyPeer` adds proxy identity binding on top of the common WFP endpoint enforcement. The proxy endpoint is the loopback address and port in @@ -102,20 +102,21 @@ client policy and package identity are the same; the enforcement table below sep `ingress.hostLoopback` is bidirectional. `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. +non-AppContainer proxy lacks an accepted peer identity. It may keep either host-loopback setting: `"allow"` requests +the bidirectional contract as well, which is a weaker development/testing compatibility deployment rather than the +shared policy's strict host-loopback-closure guarantee, while `"deny"` keeps the configured proxy endpoint alone. +The setting selects which contract the request runs under, not whether the proxy is allowed to exist. 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. +PSEC rejects it alongside a proxy. Their ingress default still selects `privateNetworkClientServer`, and an +identity-less proxy selects both the `networkLoopback` capability and the `MXC-Loopback` peer rather than an +identity-scoped proxy peer, whatever its host-loopback setting. 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, and host-loopback allow retains the PSEC 1.1 host requirement. Requests that do not allow host loopback use the PSEC 1.0 capability mapping; `hostLoopback: "allow"` is rejected when the PSEC 1.1 ingress contract is unavailable. +An identity-less proxy therefore reaches its endpoint on a PSEC 1.0 host by keeping `hostLoopback: "deny"`. Requests the PSEC contract cannot preserve continue to an AppContainer tier. **Unresolved ingress limitation:** on Windows build 26691.1002, this proxy mapping permits unprivileged connections @@ -157,6 +158,12 @@ scoping remains, but Windows cannot verify which host process owns that endpoint development and debugging. PSEC grants `networkLoopback` to authorize the host-loopback connection without MXC installing administrator-owned firewall rules. Independently managed host firewall restrictions can still apply. +Set `"hostLoopback": "deny"` instead on a host whose PSEC contract is older than 1.1, or whose +`QueryProcessSecurityEnvironmentSupport` does not advertise ingress support. The request is otherwise identical: MXC +grants the same `networkLoopback` capability and `MXC-Loopback` peer, and WFP applies the same endpoint scoping, so +the client reaches the configured proxy. It drops only the bidirectional host-loopback grant, which that host cannot +express and which the inbound limitation above does not yet deliver in any case. + #### HTTP client guidance Code inside the ProcessContainer should use WinHTTP or an HTTP library that queries the system for proxy information. @@ -169,7 +176,8 @@ the configured loopback proxy address and port and blocks direct public and priv Model 2 requires `egress.default: "deny"` and `ingress.default: "allow"`. When `allowedProxyPeer` names a package or AppContainer profile, MXC authorizes only that peer and `ingress.hostLoopback` remains denied. An identity-less host -proxy omits `allowedProxyPeer` and requires `ingress.hostLoopback: "allow"`. Direct egress allow and deny rules do not +proxy omits `allowedProxyPeer`; its `ingress.hostLoopback` selects the contract the request runs under rather than +its eligibility. Direct egress allow and deny rules do not apply when `runtimeConfig.networkProxy` is present. The proxy endpoint is runtime metadata, not shared network policy. MXC configures the per-container WinHTTP proxy, @@ -177,8 +185,9 @@ applies WFP endpoint scoping, and grants the private-network capability selected The identity-scoped and host-loopback paths are mutually exclusive. When `allowedProxyPeer` is present, MXC resolves the package family or AppContainer profile and grants the private-network capability selected by `ingress.default`. -When it is omitted, MXC uses the configured proxy endpoint without peer identity binding and requires bidirectional -host-loopback access. MXC configures the per-container WinHTTP proxy for either path. +When it is omitted, MXC uses the configured proxy endpoint without peer identity binding. `hostLoopback: "allow"` +then also requests bidirectional host-loopback access and the PSEC 1.1 ingress contract; `"deny"` keeps the endpoint +alone and runs under PSEC 1.0. MXC configures the per-container WinHTTP proxy for every path. The caller must: diff --git a/docs/process-container/os-version-support.md b/docs/process-container/os-version-support.md index 8c7587a73..fd3e28d12 100644 --- a/docs/process-container/os-version-support.md +++ b/docs/process-container/os-version-support.md @@ -156,6 +156,13 @@ AppContainer fallback rejects `privateNetworkClientServer` is bidirectional and no schema 0.8 WFP filter is available there to block private-network egress. +`ingress.hostLoopback: "allow"` additionally needs PSEC contract 1.1 with +ingress support, which the `BaseContainer` tier does not imply. An identity-less +`runtimeConfig.networkProxy` runs on a PSEC 1.0 host by keeping +`hostLoopback: "deny"`: MXC grants the `networkLoopback` capability and the +`MXC-Loopback` peer for the proxy itself, so only the bidirectional +host-loopback grant is lost. + ## UI restrictions UI restrictions map to Job Object `JOB_OBJECT_UILIMIT_*` flags plus the diff --git a/docs/sandbox-policy/0.8.0/networking/networking.md b/docs/sandbox-policy/0.8.0/networking/networking.md index f30b2fdf0..b380071f1 100644 --- a/docs/sandbox-policy/0.8.0/networking/networking.md +++ b/docs/sandbox-policy/0.8.0/networking/networking.md @@ -426,8 +426,9 @@ they cannot preserve either posture. AppContainer proxy uses its profile name. Windows requires `ingress.default: "allow"` to grant the bidirectional `privateNetworkClientServer` capability. With `allowedProxyPeer`, proxy reachability remains scoped to that peer and endpoint and `ingress.hostLoopback` stays `"deny"`. An identity-less host proxy cannot use peer scoping and is the - documented development/testing compatibility path that requires `ingress.hostLoopback: "allow"`; it does not - provide the strict host-loopback-closure guarantee. + documented development/testing compatibility path; it does not provide the strict host-loopback-closure guarantee. + Its `ingress.hostLoopback` is free: `"allow"` also opens both host-loopback directions and needs PSEC 1.1 with + ingress support, while `"deny"` keeps the configured proxy endpoint alone and runs under PSEC 1.0. - **Model 1:** Grants `internetClient`, allowing direct internet egress under WFP IP/CIDR/port/protocol rules. Private-network outbound also requires `ingress.default: "allow"` and remains subject to the same `egress` rules. - **Model 3:** Grants no `internetClient`, private-network capability, or loopback exemptions. diff --git a/docs/schema.md b/docs/schema.md index 93843e96d..50920e903 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -50,10 +50,12 @@ than inheriting it, so host-loopback access must be requested explicitly. A ProcessContainer proxy requires `ingress.default: "allow"`. Identity-scoped proxies set `allowedProxyPeer` and keep `hostLoopback: "deny"`; identity-less host proxies omit -`allowedProxyPeer` and require `hostLoopback: "allow"`. The identity-less route -is a weaker development/testing compatibility deployment because it opens both -host-loopback directions; it is not the strict proxy-endpoint exception -defined by the shared model-2 policy. +`allowedProxyPeer` and choose either `hostLoopback` setting. `"allow"` also +opens both host-loopback directions, which is a weaker development/testing +compatibility deployment and needs the PSEC 1.1 ingress contract. `"deny"` +keeps the configured proxy endpoint alone and runs under PSEC 1.0. Neither +route is the strict proxy-endpoint exception defined by the shared model-2 +policy, because MXC cannot verify which host process owns the endpoint. ```json { diff --git a/src/backends/process_container/common/src/base_container_helpers.rs b/src/backends/process_container/common/src/base_container_helpers.rs index 4bb6b7224..4b84c3afc 100644 --- a/src/backends/process_container/common/src/base_container_helpers.rs +++ b/src/backends/process_container/common/src/base_container_helpers.rs @@ -36,10 +36,11 @@ pub(super) fn build_psec_v1_security_environment_spec( ); let mut builder = flatbuffers::FlatBufferBuilder::with_capacity(1024); let mut capabilities = effective_capabilities(&request.policy); - 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 identity_less_proxy(&request.policy) { + // Proxy mode cannot carry the native ingress table that grants + // loopback, so an identity-less proxy needs this capability to reach + // its endpoint. WFP still limits client egress to the configured proxy + // address and port, so this does not open general host loopback. ensure_capability(&mut capabilities, "networkLoopback"); } let ui_restrictions = crate::job_object::to_job_object_uilimit_mask( @@ -154,8 +155,21 @@ pub(super) fn unrestricted_host_loopback_allowed(policy: &ContainerPolicy) -> bo .is_some_and(|ingress| ingress.host_loopback == NetworkAction::Allow) } +/// An identity-less proxy: the caller supplied an endpoint but no +/// `allowedProxyPeer`, so MXC knows which address and port to authorize but not +/// which host process owns them. Such a request gets the reserved peer and the +/// loopback capability in place of an identity-scoped peer rule. +/// +/// Independent of `ingress.hostLoopback`. That field selects the native ingress +/// table, which a proxy policy never carries, and requesting `allow` raises the +/// request to PSEC 1.1. Keying the capability on the proxy identity instead lets +/// an identity-less proxy run under the PSEC 1.0 capability mapping. +fn identity_less_proxy(policy: &ContainerPolicy) -> bool { + policy.network_proxy.is_enabled() && policy.allowed_proxy_peer.is_none() +} + fn allowed_appcontainer_peer(policy: &ContainerPolicy) -> Option { - if unrestricted_host_loopback_allowed(policy) { + if unrestricted_host_loopback_allowed(policy) || identity_less_proxy(policy) { Some(LOOPBACK_NETWORK_PEER.to_string()) } else { policy.allowed_proxy_peer.clone() diff --git a/src/backends/process_container/common/src/base_container_helpers/tests.rs b/src/backends/process_container/common/src/base_container_helpers/tests.rs index 7ccdbe536..0e00781bb 100644 --- a/src/backends/process_container/common/src/base_container_helpers/tests.rs +++ b/src/backends/process_container/common/src/base_container_helpers/tests.rs @@ -119,6 +119,64 @@ fn identity_scoped_proxy_does_not_grant_host_loopback() { assert!(network.egress().is_none()); } +/// An identity-less proxy that keeps `hostLoopback: "deny"` stays on the PSEC +/// 1.0 capability mapping. It still needs the reserved peer and the loopback +/// capability to reach its endpoint, because the capability is what authorizes +/// the connection and the native ingress table a proxy policy cannot carry is +/// the only other way to express it. +/// +/// This is the posture a host without PSEC 1.1 ingress support must use. The +/// policy is otherwise identical to the `"allow"` one, so the two tests +/// together pin that `hostLoopback` selects the contract version and nothing +/// else. +#[test] +fn identity_less_proxy_keeps_peer_and_capability_under_psec_1_0() { + let proxy = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let mut request = runtime_proxy_request(&proxy); + request + .policy + .network_ingress + .as_mut() + .unwrap() + .host_loopback = NetworkAction::Deny; + 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(); + + assert_eq!(spec.version().minor(), 0); + assert_eq!( + spec.capabilities().unwrap().split(',').collect::>(), + [PRIVATE_NETWORK_CAPABILITY, "networkLoopback"] + ); + assert_eq!( + network.allowed_appcontainer_peer(), + Some(LOOPBACK_NETWORK_PEER) + ); + assert!(network.proxy().is_some()); + assert!(network.ingress().is_none()); + assert!(network.egress().is_none()); +} + +/// A policy with no proxy at all must not pick up the loopback capability from +/// the identity test above: `hostLoopback` alone travels in the native ingress +/// table there. +#[test] +fn a_policy_without_a_proxy_does_not_take_the_loopback_capability() { + let proxy = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let mut request = runtime_proxy_request(&proxy); + request.policy.runtime_network_proxy_specified = false; + request.policy.network_proxy = ProxyConfig::default(); + let bytes = + build_psec_v1_security_environment_spec(&request, SecurityEnvironmentVersion::V1_1, true); + let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); + let network = spec.network_policy().unwrap(); + + assert_eq!(spec.capabilities(), Some(PRIVATE_NETWORK_CAPABILITY)); + assert!(network.proxy().is_none()); + assert!(network.ingress().is_some()); +} + fn run_proxy_command(request: ExecutionRequest) -> String { run_proxy_command_with_stdout(request, |mut stdout| { let mut output = String::new(); diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index dc6adc24b..f695aeb56 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -4596,11 +4596,6 @@ mod tests { r#"{"default": "allow", "hostLoopback": "allow"}"#, "identity-scoped ProcessContainer proxy", ), - ( - "", - r#"{"default": "allow", "hostLoopback": "deny"}"#, - "without allowedProxyPeer", - ), ] { let process_container = if peer.is_empty() { String::new() @@ -4628,6 +4623,34 @@ mod tests { } } + /// A denied host loopback is the posture an identity-less proxy must use on + /// a host without PSEC 1.1 ingress support, so the parser accepts it. + #[test] + fn schema_v08_accepts_an_identity_less_proxy_with_a_denied_host_loopback() { + let json = r#"{ + "version": "0.8.0-alpha", + "containment": "processcontainer", + "process": {"commandLine": "echo hi"}, + "network": { + "egress": {"default": "deny"}, + "ingress": {"default": "allow", "hostLoopback": "deny"} + }, + "runtimeConfig": {"networkProxy": "http://127.0.0.1:8080"} + }"#; + let MxcRequest::OneShot(request) = + load_mxc(json).expect("a denied host loopback must be accepted") + else { + panic!("expected a one-shot request"); + }; + let policy = &request.policy; + assert!(policy.network_proxy.is_enabled()); + assert!(policy.allowed_proxy_peer.is_none()); + assert_eq!( + policy.network_ingress.as_ref().unwrap().host_loopback, + NetworkAction::Deny + ); + } + #[test] fn schema_v08_rejects_proxy_peer_without_runtime_proxy() { let json = r#"{ diff --git a/src/core/wxc_common/src/network_parser.rs b/src/core/wxc_common/src/network_parser.rs index 3c659141b..1459a0050 100644 --- a/src/core/wxc_common/src/network_parser.rs +++ b/src/core/wxc_common/src/network_parser.rs @@ -591,19 +591,22 @@ fn validate_process_container_proxy_policy( )); } - match (policy.allowed_proxy_peer.is_some(), ingress.host_loopback) { - (true, NetworkAction::Allow) => Err(WxcError::ConfigParse( + // An identity-scoped proxy reaches its peer through that identity, so a + // host-loopback grant on top of it would widen the policy for nothing. + // + // An identity-less proxy accepts either host-loopback setting. `allow` asks + // for the bidirectional contract and needs the PSEC 1.1 ingress table. + // `deny` keeps the proxy endpoint alone, which the PSEC 1.0 capability + // mapping can express, so a host without PSEC 1.1 ingress support still + // reaches its proxy. + if policy.allowed_proxy_peer.is_some() && ingress.host_loopback == NetworkAction::Allow { + return Err(WxcError::ConfigParse( "an identity-scoped ProcessContainer proxy requires \ network.ingress.hostLoopback='deny'" .to_string(), - )), - (false, NetworkAction::Deny) => Err(WxcError::ConfigParse( - "a ProcessContainer proxy without allowedProxyPeer requires \ - network.ingress.hostLoopback='allow'" - .to_string(), - )), - _ => Ok(()), + )); } + Ok(()) } #[cfg(test)] @@ -700,6 +703,57 @@ mod proxy_policy_tests { other => panic!("expected config error, got {other:?}"), } } + + fn proxy_policy(peer: Option<&str>, host_loopback: NetworkAction) -> ContainerPolicy { + ContainerPolicy { + allowed_proxy_peer: peer.map(str::to_owned), + network_ingress: Some(NetworkIngressPolicy { + default: NetworkAction::Allow, + host_loopback, + }), + ..Default::default() + } + } + + /// `hostLoopback` selects the contract an identity-less ProcessContainer + /// proxy runs under, not whether it is allowed to exist. `allow` asks for + /// the bidirectional grant and needs the PSEC 1.1 ingress table; `deny` + /// keeps the proxy endpoint alone, which the PSEC 1.0 capability mapping + /// expresses. Rejecting `deny` left a host without PSEC 1.1 ingress support + /// with no usable proxy at all. + #[test] + fn an_identity_less_proxy_accepts_either_host_loopback_setting() { + for host_loopback in [NetworkAction::Allow, NetworkAction::Deny] { + let policy = proxy_policy(None, host_loopback); + validate_process_container_proxy_policy(&policy, true) + .unwrap_or_else(|error| panic!("{host_loopback:?} must be accepted: {error}")); + } + } + + /// An identity-scoped proxy reaches its peer through that identity, so a + /// host-loopback grant beside it would widen the policy for nothing. + #[test] + fn an_identity_scoped_proxy_still_requires_a_denied_host_loopback() { + let policy = proxy_policy(Some("Contoso.Proxy_123"), NetworkAction::Allow); + let error = validate_process_container_proxy_policy(&policy, true).unwrap_err(); + assert!(error.to_string().contains("hostLoopback='deny'"), "{error}"); + + let policy = proxy_policy(Some("Contoso.Proxy_123"), NetworkAction::Deny); + validate_process_container_proxy_policy(&policy, true).unwrap(); + } + + /// The allowed ingress default is untouched: MXC maps it to + /// `privateNetworkClientServer`, which every proxy posture needs. + #[test] + fn a_proxy_still_requires_an_allowed_ingress_default() { + let mut policy = proxy_policy(None, NetworkAction::Deny); + policy.network_ingress.as_mut().unwrap().default = NetworkAction::Deny; + let error = validate_process_container_proxy_policy(&policy, true).unwrap_err(); + assert!( + error.to_string().contains("ingress.default='allow'"), + "{error}" + ); + } } #[cfg(test)] diff --git a/tests/scripts/run_processcontainer_network_proxy_test.ps1 b/tests/scripts/run_processcontainer_network_proxy_test.ps1 index 44c975b29..277135465 100644 --- a/tests/scripts/run_processcontainer_network_proxy_test.ps1 +++ b/tests/scripts/run_processcontainer_network_proxy_test.ps1 @@ -76,7 +76,10 @@ function Invoke-NetworkProxyAssertions { $proxyUrl = "http://127.0.0.1:$port" # Identity-less deployment: no allowedProxyPeer, so the doc requires - # ingress.default=allow AND hostLoopback=allow. Workload dumps its env. + # ingress.default=allow. hostLoopback=allow additionally requests the + # bidirectional contract, which needs PSEC 1.1 ingress support; the + # hostLoopback=deny shape below is the one a PSEC 1.0 host uses. + # Workload dumps its env. $envDump = "$env:SystemRoot\System32\cmd.exe /c set" $cfgEnv = New-Config -Name 'net-proxy-envvars' -CommandLine $envDump ` -ReadWrite $fs.ReadWrite -ReadOnly $fs.ReadOnly ` @@ -197,17 +200,23 @@ function Invoke-NetworkProxyAssertions { return } - # Identity-less proxy WITHOUT hostLoopback=allow. The doc says this - # deployment requires it, so the configuration is incomplete and must be - # refused rather than run with a proxy the container cannot reach. - $cfgNoLoopback = New-Config -Name 'net-proxy-identityless-no-loopback' -CommandLine $envDump ` + # Identity-less proxy WITHOUT hostLoopback=allow. This is the posture a + # host whose PSEC contract is older than 1.1 must use, so it is a valid + # shape rather than an incomplete one: MXC grants the same networkLoopback + # capability and MXC-Loopback peer, and WFP applies the same endpoint + # scoping. The proof is that the workload reaches the proxy, not merely + # that the config was accepted. The target host is deliberately + # unresolvable, so REACHED is attributable to the proxy endpoint alone. + $cfgNoLoopback = New-Config -Name 'net-proxy-identityless-no-loopback' ` + -CommandLine (Get-AnchorFetchCommand -Url 'http://mxc-proxy-probe.invalid/') ` -ReadWrite $fs.ReadWrite -ReadOnly $fs.ReadOnly ` -EgressDefault 'deny' -IngressDefault 'allow' -HostLoopback 'deny' ` -NetworkProxy $proxyUrl -TimeoutMs 25000 $noLoopback = Invoke-NetRun -Name 'net-proxy-identityless-no-loopback' -ConfigPath $cfgNoLoopback - Record-Result -Phase 'P8e' -Name 'identity-less proxy without hostLoopback=allow is rejected' ` - -Pass (Test-WasRejected $noLoopback) ` - -Detail "exit=$($noLoopback.Result.ExitCode); timedOut=$($noLoopback.Result.TimedOut); doc requires hostLoopback=allow when allowedProxyPeer is omitted" + Record-Result -Phase 'P8e' -Name 'identity-less proxy with hostLoopback=deny reaches the proxy endpoint' ` + -Pass ($noLoopback.Verdict -eq 'REACHED') ` + -Detail ("verdict=$($noLoopback.Verdict); endpoint=127.0.0.1:$port; " + + 'this shape carries the proxy on a host without PSEC 1.1 ingress support') # Model 2 requires ingress.default=allow. Without it the client container # never gets privateNetworkClientServer and cannot reach a loopback proxy. From a893cc8ff07331aad0a2109e2005815f0e7bfc38 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 2 Oct 2026 17:43:51 -0300 Subject: [PATCH 2/3] test(processcontainer): cover denied-loopback proxy confinement Address endpoint-rejection feedback by reusing the live IPv4/IPv6 listener checks with hostLoopback=deny and PSEC 1.0 gating. Preserve explicit access-denied checks and launch failure diagnostics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d20acabb-2367-4261-b346-2d7ed8995896 Signed-off-by: Carlos Alexandro Becker --- .../src/base_container_helpers/tests.rs | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/src/backends/process_container/common/src/base_container_helpers/tests.rs b/src/backends/process_container/common/src/base_container_helpers/tests.rs index 0e00781bb..6d7730598 100644 --- a/src/backends/process_container/common/src/base_container_helpers/tests.rs +++ b/src/backends/process_container/common/src/base_container_helpers/tests.rs @@ -127,8 +127,8 @@ fn identity_scoped_proxy_does_not_grant_host_loopback() { /// /// This is the posture a host without PSEC 1.1 ingress support must use. The /// policy is otherwise identical to the `"allow"` one, so the two tests -/// together pin that `hostLoopback` selects the contract version and nothing -/// else. +/// together pin that `hostLoopback` changes the ingress prerequisite, not +/// the proxy capability or peer. #[test] fn identity_less_proxy_keeps_peer_and_capability_under_psec_1_0() { let proxy = TcpListener::bind(("127.0.0.1", 0)).unwrap(); @@ -285,7 +285,7 @@ fn runtime_proxy_allows_only_its_ipv4_loopback_endpoint() { eprintln!("SKIPPED: native proxy networking requires usable PSEC 1.1 ingress support"); return; } - assert_proxy_endpoint_only("127.0.0.1"); + assert_proxy_endpoint_only("127.0.0.1", NetworkAction::Allow); } #[test] @@ -294,10 +294,24 @@ fn runtime_proxy_blocks_ipv6_loopback_bypass() { eprintln!("SKIPPED: native proxy networking requires usable PSEC 1.1 ingress support"); return; } - assert_proxy_endpoint_only("::1"); + assert_proxy_endpoint_only("::1", NetworkAction::Allow); } -fn assert_proxy_endpoint_only(other_host: &str) { +#[test] +fn runtime_proxy_with_denied_host_loopback_confines_ipv4_and_ipv6() { + if !BaseContainerRunner::is_base_container_api_present() + || !crate::secenv::supports_version(SecurityEnvironmentVersion::V1_0) + .expect("PSEC 1.0 support query failed") + { + eprintln!("SKIPPED: native proxy networking requires usable PSEC 1.0 support"); + return; + } + for other_host in ["127.0.0.1", "::1"] { + assert_proxy_endpoint_only(other_host, NetworkAction::Deny); + } +} + +fn assert_proxy_endpoint_only(other_host: &str, host_loopback: NetworkAction) { let proxy = TcpListener::bind(("127.0.0.1", 0)).unwrap(); let other = TcpListener::bind((other_host, 0)).unwrap(); drop(TcpStream::connect(proxy.local_addr().unwrap()).unwrap()); @@ -339,6 +353,12 @@ fn assert_proxy_endpoint_only(other_host: &str) { let powershell = Path::new(&std::env::var("SystemRoot").unwrap()) .join(r"System32\WindowsPowerShell\v1.0\powershell.exe"); let mut request = runtime_proxy_request(&proxy); + request + .policy + .network_ingress + .as_mut() + .unwrap() + .host_loopback = host_loopback; request.script_code = format!( "\"{}\" -NoLogo -NoProfile -NonInteractive -Command \"{}\"", powershell.display(), @@ -347,7 +367,8 @@ fn assert_proxy_endpoint_only(other_host: &str) { let output = run_proxy_command(request); assert_eq!( output.lines().map(str::trim).collect::>(), - ["proxy-connected", "other-port-denied"] + ["proxy-connected", "other-port-denied"], + "hostLoopback={host_loopback:?}, other_host={other_host}" ); } From 8701818f2ada4ae7dceaa1cd7e58b17b0d3b41e4 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 2 Oct 2026 17:43:51 -0300 Subject: [PATCH 3/3] docs(processcontainer): clarify proxy ingress prerequisites Address setup-guidance feedback across the shared policy, schema, backend and OS-version guides. Distinguish the PSEC 1.1 ingress prerequisite from the identical proxy capability/peer mapping, and retain the documented inbound limitation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d20acabb-2367-4261-b346-2d7ed8995896 Signed-off-by: Carlos Alexandro Becker --- docs/process-container/networking.md | 33 +++++++++++-------- docs/process-container/os-version-support.md | 10 ++++-- .../0.8.0/networking/networking.md | 17 ++++++---- docs/schema.md | 15 +++++---- .../common/src/base_container_helpers.rs | 8 ++--- src/core/wxc_common/src/network_parser.rs | 15 ++++----- ...un_processcontainer_network_proxy_test.ps1 | 5 ++- 7 files changed, 57 insertions(+), 46 deletions(-) diff --git a/docs/process-container/networking.md b/docs/process-container/networking.md index 32304f5e1..0db9ab1a2 100644 --- a/docs/process-container/networking.md +++ b/docs/process-container/networking.md @@ -102,10 +102,10 @@ client policy and package identity are the same; the enforcement table below sep `ingress.hostLoopback` is bidirectional. `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. It may keep either host-loopback setting: `"allow"` requests -the bidirectional contract as well, which is a weaker development/testing compatibility deployment rather than the -shared policy's strict host-loopback-closure guarantee, while `"deny"` keeps the configured proxy endpoint alone. -The setting selects which contract the request runs under, not whether the proxy is allowed to exist. +non-AppContainer proxy lacks an accepted peer identity. It may keep either host-loopback setting. `"allow"` requests +bidirectional access and retains the PSEC 1.1 ingress prerequisite; `"deny"` removes that prerequisite. +Neither setting adds proxy identity binding, so this remains a development/testing compatibility deployment rather +than the shared policy's strict host-loopback-closure guarantee. 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 @@ -114,9 +114,13 @@ identity-less proxy selects both the `networkLoopback` capability and the `MXC-L identity-scoped proxy peer, whatever its host-loopback setting. 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, and host-loopback allow retains the PSEC 1.1 host requirement. -Requests that do not allow host loopback use the -PSEC 1.0 capability mapping; `hostLoopback: "allow"` is rejected when the PSEC 1.1 ingress contract is unavailable. -An identity-less proxy therefore reaches its endpoint on a PSEC 1.0 host by keeping `hostLoopback: "deny"`. +Both host-loopback settings serialize the same proxy capability and peer, without a native ingress table. +`hostLoopback: "deny"` therefore does not remove a separately encoded bidirectional grant. +It permits the PSEC 1.0 capability mapping when no other requested feature requires a newer contract; +for example, enumeration-only filesystem grants still require PSEC 1.1. +`hostLoopback: "allow"` is rejected when the PSEC 1.1 ingress contract is unavailable. +An identity-less proxy can therefore use a PSEC 1.0 host by keeping `hostLoopback: "deny"` and +requesting only features supported by that host. Requests the PSEC contract cannot preserve continue to an AppContainer tier. **Unresolved ingress limitation:** on Windows build 26691.1002, this proxy mapping permits unprivileged connections @@ -161,8 +165,8 @@ installing administrator-owned firewall rules. Independently managed host firewa Set `"hostLoopback": "deny"` instead on a host whose PSEC contract is older than 1.1, or whose `QueryProcessSecurityEnvironmentSupport` does not advertise ingress support. The request is otherwise identical: MXC grants the same `networkLoopback` capability and `MXC-Loopback` peer, and WFP applies the same endpoint scoping, so -the client reaches the configured proxy. It drops only the bidirectional host-loopback grant, which that host cannot -express and which the inbound limitation above does not yet deliver in any case. +the client reaches the configured proxy. This removes the PSEC 1.1 ingress prerequisite, not a separately encoded +grant. Other requested features may still require PSEC 1.1. The inbound limitation above applies to both settings. #### HTTP client guidance @@ -176,9 +180,9 @@ the configured loopback proxy address and port and blocks direct public and priv Model 2 requires `egress.default: "deny"` and `ingress.default: "allow"`. When a non-blank `allowedProxyPeer` names a package or AppContainer profile, MXC authorizes only that peer and `ingress.hostLoopback` remains denied. An -identity-less host proxy omits `allowedProxyPeer`; its `ingress.hostLoopback` selects the contract the request runs -under rather than its eligibility. Direct egress allow and deny rules do not apply when `runtimeConfig.networkProxy` -is present. +identity-less host proxy omits `allowedProxyPeer`; its `ingress.hostLoopback` controls the PSEC 1.1 ingress +prerequisite rather than its eligibility. Direct egress allow and deny rules do not apply when +`runtimeConfig.networkProxy` is present. The proxy endpoint is runtime metadata, not shared network policy. MXC configures the per-container WinHTTP proxy, applies WFP endpoint scoping, and grants the private-network capability selected by `ingress.default`. @@ -186,8 +190,9 @@ applies WFP endpoint scoping, and grants the private-network capability selected The identity-scoped and host-loopback paths are mutually exclusive. When `allowedProxyPeer` is present, MXC resolves the package family or AppContainer profile and grants the private-network capability selected by `ingress.default`. When it is omitted, MXC uses the configured proxy endpoint without peer identity binding. `hostLoopback: "allow"` -then also requests bidirectional host-loopback access and the PSEC 1.1 ingress contract; `"deny"` keeps the endpoint -alone and runs under PSEC 1.0. MXC configures the per-container WinHTTP proxy for every path. +then also requests bidirectional host-loopback access and the PSEC 1.1 ingress contract; `"deny"` removes that +prerequisite and permits PSEC 1.0 when the rest of the request supports it. MXC configures the per-container +WinHTTP proxy for every path. The caller must: diff --git a/docs/process-container/os-version-support.md b/docs/process-container/os-version-support.md index fd3e28d12..89017fddd 100644 --- a/docs/process-container/os-version-support.md +++ b/docs/process-container/os-version-support.md @@ -158,10 +158,14 @@ available there to block private-network egress. `ingress.hostLoopback: "allow"` additionally needs PSEC contract 1.1 with ingress support, which the `BaseContainer` tier does not imply. An identity-less -`runtimeConfig.networkProxy` runs on a PSEC 1.0 host by keeping +`runtimeConfig.networkProxy` can run on a PSEC 1.0 host by keeping `hostLoopback: "deny"`: MXC grants the `networkLoopback` capability and the -`MXC-Loopback` peer for the proxy itself, so only the bidirectional -host-loopback grant is lost. +`MXC-Loopback` peer for the proxy itself. Both host-loopback settings serialize +the same proxy capability and peer and omit native ingress. `"deny"` removes +the PSEC 1.1 ingress prerequisite, not a separately encoded grant; other +features, such as enumeration-only filesystem grants, can still require 1.1. +`"allow"` requests bidirectional access, but the host-to-container limitation +in the [networking guide](./networking.md#proxy-deployment-choices) still applies. ## UI restrictions diff --git a/docs/sandbox-policy/0.8.0/networking/networking.md b/docs/sandbox-policy/0.8.0/networking/networking.md index ce60656c2..7b9d2edf3 100644 --- a/docs/sandbox-policy/0.8.0/networking/networking.md +++ b/docs/sandbox-policy/0.8.0/networking/networking.md @@ -394,8 +394,8 @@ access, after which `egress` rules apply to both public and private outbound des This capability coupling is a backend mapping, not a change to the shared meaning of `ingress.default`. The ProcessContainer section identifies which -proxy deployments retain strict endpoint scoping and which compatibility -deployment additionally requires general host-loopback access. +proxy deployments provide peer identity binding and which compatibility +deployment relies on endpoint scoping without that identity. ### D8: Delegation from the invoking user @@ -414,8 +414,9 @@ already implemented. The strict host-loopback guarantee applies to the identity-scoped ProcessContainer path with OS-scoped proxy enforcement. The identity-less host -proxy path requires broader host-loopback access and is an explicitly documented -PSEC compatibility behavior rather than strict model-2 enforcement. The +proxy path lacks peer identity binding and is an explicitly documented +PSEC compatibility behavior rather than strict model-2 enforcement. It accepts +either host-loopback setting; it does not require general host-loopback access. The AppContainer fallback rejects schema 0.8 runtime proxy requests because they cannot preserve either posture. @@ -427,8 +428,10 @@ they cannot preserve either posture. `privateNetworkClientServer` capability. With `allowedProxyPeer`, proxy reachability remains scoped to that peer and endpoint and `ingress.hostLoopback` stays `"deny"`. An identity-less host proxy cannot use peer scoping and is the documented development/testing compatibility path; it does not provide the strict host-loopback-closure guarantee. - Its `ingress.hostLoopback` is free: `"allow"` also opens both host-loopback directions and needs PSEC 1.1 with - ingress support, while `"deny"` keeps the configured proxy endpoint alone and runs under PSEC 1.0. + Its `ingress.hostLoopback` accepts either setting: `"allow"` requests bidirectional access and requires PSEC 1.1 + with ingress support, while `"deny"` removes that prerequisite. Another feature can still require PSEC 1.1. + Both settings serialize the same proxy capability and peer and omit native ingress, so `"deny"` does not remove + a separately encoded grant. The backend guide records the current host-to-container limitation. - **Model 1:** Grants `internetClient`, allowing direct internet egress under WFP IP/CIDR/port/protocol rules. Private-network outbound also requires `ingress.default: "allow"` and remains subject to the same `egress` rules. - **Model 3:** Grants no `internetClient`, private-network capability, or loopback exemptions. @@ -441,7 +444,7 @@ they cannot preserve either posture. | Port filtering | Port filtering via WFP | Port ranges supported. | | Protocol filtering | Protocol filtering via WFP | Schema values are `tcp`, `udp`, `icmp`, and `any`; WFP maps ICMP by address family. | | Default-deny | PSEC WFP block-all baseline filter at lower precedence than explicit allows. | A non-empty allow list grants `internetClient` only as the capability prerequisite; WFP still limits egress to explicit allows. With no allows, `internetClient` is absent. | -| Proxy (HTTP/S only) | PSEC per-AppContainer WinHTTP configuration, endpoint filtering, and optional scoped peer access | Identity-scoped proxies keep `hostLoopback: "deny"`; the identity-less development/testing compatibility path requires `"allow"`; schema 0.8 proxy requests do not fall back to AppContainer | +| Proxy (HTTP/S only) | PSEC per-AppContainer WinHTTP configuration, endpoint filtering, and optional scoped peer access | Identity-scoped proxies keep `hostLoopback: "deny"`; the identity-less development/testing compatibility path accepts either setting. `"allow"` requires PSEC 1.1 ingress support; `"deny"` removes that prerequisite. Schema 0.8 proxy requests do not fall back to AppContainer | | Per-sandbox scoping | AppContainer SID, unique per sandbox instance | | | Private network | `privateNetworkClientServer` via `ingress.default` | Capability gate; `egress` filters outbound | | Inbound | Capabilities and loopback rules | Private network uses `ingress.default`; loopback is separate | diff --git a/docs/schema.md b/docs/schema.md index 4ceab1edf..eed70c5ed 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -50,12 +50,15 @@ than inheriting it, so host-loopback access must be requested explicitly. A ProcessContainer proxy requires `ingress.default: "allow"`. Identity-scoped proxies set a non-blank `allowedProxyPeer` and keep `hostLoopback: "deny"`; identity-less host proxies omit `allowedProxyPeer` and choose either -`hostLoopback` setting. `"allow"` also -opens both host-loopback directions, which is a weaker development/testing -compatibility deployment and needs the PSEC 1.1 ingress contract. `"deny"` -keeps the configured proxy endpoint alone and runs under PSEC 1.0. Neither -route is the strict proxy-endpoint exception defined by the shared model-2 -policy, because MXC cannot verify which host process owns the endpoint. +`hostLoopback` setting. `"allow"` requests bidirectional host-loopback access +and requires the PSEC 1.1 ingress contract. `"deny"` removes that prerequisite, +so PSEC 1.0 can be used unless another requested feature requires a newer +contract. Both settings serialize the same proxy capability and peer and omit +native ingress; neither changes a separately encoded bidirectional grant. +The [backend guide](process-container/networking.md#proxy-deployment-choices) +records the current host-to-container limitation. Neither route is the strict +proxy-endpoint exception defined by the shared model-2 policy, because MXC +cannot verify which host process owns the endpoint. ```json { diff --git a/src/backends/process_container/common/src/base_container_helpers.rs b/src/backends/process_container/common/src/base_container_helpers.rs index 4b84c3afc..04300bd67 100644 --- a/src/backends/process_container/common/src/base_container_helpers.rs +++ b/src/backends/process_container/common/src/base_container_helpers.rs @@ -160,10 +160,10 @@ pub(super) fn unrestricted_host_loopback_allowed(policy: &ContainerPolicy) -> bo /// which host process owns them. Such a request gets the reserved peer and the /// loopback capability in place of an identity-scoped peer rule. /// -/// Independent of `ingress.hostLoopback`. That field selects the native ingress -/// table, which a proxy policy never carries, and requesting `allow` raises the -/// request to PSEC 1.1. Keying the capability on the proxy identity instead lets -/// an identity-less proxy run under the PSEC 1.0 capability mapping. +/// Independent of `ingress.hostLoopback`. A proxy policy never carries the +/// native ingress table, but requesting `allow` still requires PSEC 1.1 ingress +/// support. Keying the capability on the proxy identity instead permits PSEC +/// 1.0 when no other requested feature requires a newer contract. fn identity_less_proxy(policy: &ContainerPolicy) -> bool { policy.network_proxy.is_enabled() && policy.allowed_proxy_peer.is_none() } diff --git a/src/core/wxc_common/src/network_parser.rs b/src/core/wxc_common/src/network_parser.rs index 111340783..5f6332324 100644 --- a/src/core/wxc_common/src/network_parser.rs +++ b/src/core/wxc_common/src/network_parser.rs @@ -602,10 +602,9 @@ fn validate_process_container_proxy_policy( // host-loopback grant on top of it would widen the policy for nothing. // // An identity-less proxy accepts either host-loopback setting. `allow` asks - // for the bidirectional contract and needs the PSEC 1.1 ingress table. - // `deny` keeps the proxy endpoint alone, which the PSEC 1.0 capability - // mapping can express, so a host without PSEC 1.1 ingress support still - // reaches its proxy. + // for the bidirectional contract and requires PSEC 1.1 ingress support. + // `deny` removes that prerequisite without changing the proxy capability + // or peer. Other requested features can still require PSEC 1.1. if policy.allowed_proxy_peer.is_some() && ingress.host_loopback == NetworkAction::Allow { return Err(WxcError::ConfigParse( "an identity-scoped ProcessContainer proxy requires \ @@ -722,11 +721,9 @@ mod proxy_policy_tests { } } - /// `hostLoopback` selects the contract an identity-less ProcessContainer - /// proxy runs under, not whether it is allowed to exist. `allow` asks for - /// the bidirectional grant and needs the PSEC 1.1 ingress table; `deny` - /// keeps the proxy endpoint alone, which the PSEC 1.0 capability mapping - /// expresses. Rejecting `deny` left a host without PSEC 1.1 ingress support + /// `hostLoopback` controls the PSEC 1.1 ingress prerequisite for an + /// identity-less ProcessContainer proxy, not whether it is allowed to + /// exist. Rejecting `deny` left a host without PSEC 1.1 ingress support /// with no usable proxy at all. #[test] fn an_identity_less_proxy_accepts_either_host_loopback_setting() { diff --git a/tests/scripts/run_processcontainer_network_proxy_test.ps1 b/tests/scripts/run_processcontainer_network_proxy_test.ps1 index 277135465..02c0f371d 100644 --- a/tests/scripts/run_processcontainer_network_proxy_test.ps1 +++ b/tests/scripts/run_processcontainer_network_proxy_test.ps1 @@ -27,8 +27,8 @@ Initialize-WpcContext @PSBoundParameters # # Per docs/process-container/networking.md: HTTP(S)_PROXY (both cases) point # at the loopback endpoint, NO_PROXY must not carry it, direct egress is -# blocked, egress rules do not apply, identity-less proxy requires -# hostLoopback allow, and no fallback to an AppContainer tier. +# blocked, egress rules do not apply, identity-less proxies accept either +# hostLoopback setting, and no fallback to an AppContainer tier. # # The workload prints its own environment: an MXC log line saying a proxy was # configured does not prove the child received it. A host-side listener stands @@ -232,4 +232,3 @@ function Invoke-NetworkProxyAssertions { Invoke-WpcPhase -Key 'NetworkProxy' -Body { Phase-NetworkProxy } Complete-WpcChild -