diff --git a/docs/process-container/networking.md b/docs/process-container/networking.md index 10d7bcb27..0db9ab1a2 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,25 @@ 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 +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 -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. +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 @@ -157,6 +162,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. 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 Code inside the ProcessContainer should use WinHTTP or an HTTP library that queries the system for proxy information. @@ -169,16 +180,19 @@ 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` and requires `ingress.hostLoopback: "allow"`. 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`. 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"` 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 8c7587a73..89017fddd 100644 --- a/docs/process-container/os-version-support.md +++ b/docs/process-container/os-version-support.md @@ -156,6 +156,17 @@ 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` 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. 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 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 63af72271..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. @@ -426,8 +427,11 @@ 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` 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. @@ -440,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 c2e5ac5f0..eed70c5ed 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -49,10 +49,16 @@ omitted, and `hostLoopback` resolves independently of `ingress.default` rather 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 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. +identity-less host proxies omit `allowedProxyPeer` and choose either +`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 4bb6b7224..04300bd67 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`. 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() +} + 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..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 @@ -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` 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(); + 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(); @@ -227,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] @@ -236,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()); @@ -281,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(), @@ -289,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}" ); } diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 47710aa5b..7f6602b2b 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -4617,11 +4617,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() @@ -4649,6 +4644,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 aafb7a45d..5f6332324 100644 --- a/src/core/wxc_common/src/network_parser.rs +++ b/src/core/wxc_common/src/network_parser.rs @@ -598,19 +598,21 @@ 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 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 \ 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)] @@ -707,6 +709,55 @@ 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` 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() { + 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..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 @@ -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. @@ -223,4 +232,3 @@ function Invoke-NetworkProxyAssertions { Invoke-WpcPhase -Key 'NetworkProxy' -Body { Phase-NetworkProxy } Complete-WpcChild -