From 469be9fb38106c1ea12131bf4de8700978e085ed Mon Sep 17 00:00:00 2001 From: Branden Bonaby <105318831+bbonaby@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:19:11 -0700 Subject: [PATCH 1/8] fix psec 1.0 loopback restriction --- .../common/base_container_runner.rs | 155 +++--------------- 1 file changed, 19 insertions(+), 136 deletions(-) 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..40aa8a895 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 @@ -258,7 +258,6 @@ pub(crate) enum BaseContainerRequestDecision { PolicyIncompatible, DeniedPathsUnsupported, EnumeratePathsUnsupported, - IngressUnsupported, NativeCaptureUnavailable, } @@ -429,28 +428,10 @@ impl BaseContainerRunner { 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) - } - /// 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,7 +441,15 @@ impl BaseContainerRunner { version = version.max(SecurityEnvironmentSupport::FileSystemEnumerate.required_version()); } - if unrestricted_host_loopback_allowed(&request.policy) && !proxy_loopback_workaround { + + // PSEC 1.1 supports unrestricted host loopback, including hosting a loopback + // server in the base container and communicating with low IL listeners. + // PSEC 1.0 only supports host loopback access to medium IL listeners, so those + // requests can still be considered serviceable on PSEC 1.0. Only upgrade to PSEC 1.1 + // when the host supports the network ingress flag. + if unrestricted_host_loopback_allowed(&request.policy) + && Self::supports_ingress_host_loopback_allow() + { version = version.max(SecurityEnvironmentSupport::NetworkIngress.required_version()); } version @@ -502,15 +491,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 +521,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, @@ -2329,7 +2300,7 @@ 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, @@ -3235,10 +3206,7 @@ mod tests { #[test] fn required_psec_contract_uses_v1_1_for_enumeration() { assert_eq!( - BaseContainerRunner::choose_min_required_psec_version_for_request( - &enumerate_request(), - false - ), + BaseContainerRunner::choose_min_required_psec_version_for_request(&enumerate_request()), SecurityEnvironmentVersion::V1_1 ); } @@ -3247,8 +3215,7 @@ mod tests { fn required_psec_contract_uses_v1_1_for_host_loopback() { assert_eq!( BaseContainerRunner::choose_min_required_psec_version_for_request( - &host_loopback_request(), - false + &host_loopback_request() ), SecurityEnvironmentVersion::V1_1 ); @@ -3258,8 +3225,7 @@ mod tests { fn required_psec_contract_defaults_to_v1_0() { assert_eq!( BaseContainerRunner::choose_min_required_psec_version_for_request( - &ExecutionRequest::default(), - false + &ExecutionRequest::default() ), SecurityEnvironmentVersion::V1_0 ); @@ -3340,92 +3306,9 @@ mod tests { } #[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() { + fn psec_1_0_proxy_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); + let version = BaseContainerRunner::choose_min_required_psec_version_for_request(&request); 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(); @@ -3449,11 +3332,11 @@ mod tests { } #[test] - fn psec_1_0_proxy_workaround_does_not_lower_enumeration_requirement() { + fn psec_1_0_proxy_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), + BaseContainerRunner::choose_min_required_psec_version_for_request(&request), SecurityEnvironmentVersion::V1_1 ); } From 3433906f2f46fbe31480c9ce1372dcba2b31a812 Mon Sep 17 00:00:00 2001 From: Branden Bonaby <105318831+bbonaby@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:32:41 -0700 Subject: [PATCH 2/8] test: make PSEC 1.0 proxy encoding independent of host Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa1ea5a5-f8e6-4a00-9b94-a8b0192e3466 --- .../backends/process_container/common/base_container_runner.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 40aa8a895..abd12f304 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 @@ -3308,8 +3308,7 @@ mod tests { #[test] fn psec_1_0_proxy_encodes_existing_loopback_capability_and_peer() { let request = psec_1_0_proxy_request(); - let version = BaseContainerRunner::choose_min_required_psec_version_for_request(&request); - assert_eq!(version, SecurityEnvironmentVersion::V1_0); + let 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(); From 40e91ce38e463a64976d6113555d4017c66aa813 Mon Sep 17 00:00:00 2001 From: Branden Bonaby <105318831+bbonaby@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:58:54 -0700 Subject: [PATCH 3/8] test: strengthen BaseContainer loopback coverage and fix CI assumptions Verify direct and identity-less proxy loopback access, avoid shell-launch false positives, and document Windows build support. Make loopback unit tests independent of host ingress support. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa1ea5a5-f8e6-4a00-9b94-a8b0192e3466 --- docs/backends/process-container/networking.md | 13 ++--- .../ci-validation-infrastructure.md | 4 ++ .../common/base_container_runner.rs | 12 +++-- tests/scripts/README.md | 5 ++ .../lib/WinProcessContainer.Common.ps1 | 8 +-- ...processcontainer_network_loopback_test.ps1 | 50 ++++++++----------- ...un_processcontainer_network_proxy_test.ps1 | 16 +++--- 7 files changed, 56 insertions(+), 52 deletions(-) diff --git a/docs/backends/process-container/networking.md b/docs/backends/process-container/networking.md index 6bc04a87d..0b1d8a117 100644 --- a/docs/backends/process-container/networking.md +++ b/docs/backends/process-container/networking.md @@ -115,12 +115,13 @@ PSEC rejects it alongside a proxy. Their ingress default still selects `privateN 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. +The public ingress policy remains required. `hostLoopback: "allow"` supports +connections to host listeners both with an identity-less runtime proxy and +without a proxy. On Windows 11 25H2, the initial ProcessContainer release +(build 26200.9278) supports connections to medium-integrity host listeners. +Build 26200.9550 adds 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. 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. diff --git a/docs/development/build-and-test/ci-validation-infrastructure.md b/docs/development/build-and-test/ci-validation-infrastructure.md index 9834ea6d6..6b5282296 100644 --- a/docs/development/build-and-test/ci-validation-infrastructure.md +++ b/docs/development/build-and-test/ci-validation-infrastructure.md @@ -170,6 +170,10 @@ across the system drive on every run and again on teardown — acceptable on a disposable runner, which is why the switch is off by default and only CI opts in. Temporary until pwsh 7.7 leaves preview. +`NetworkHostLoopback` requires direct host-loopback access on BaseContainer +without a runtime proxy, including older supported Windows hosts. Rejection +or tier fallback fails the check; omitted and explicit deny are negative controls. + The suite's git workloads also restamp the ownership of the repo they build. The 1ES agent runs elevated, and an elevated token's *default owner* is `BUILTIN\Administrators`, so a fixture created there is Administrators-owned; 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 abd12f304..a3f260b55 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 @@ -3185,7 +3185,12 @@ mod tests { host_loopback: NetworkAction::Allow, }); - let bytes = BaseContainerRunner::build_process_security_environment_spec(&request); + 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 ingress = spec .network_policy() @@ -3212,12 +3217,13 @@ mod tests { } #[test] - fn required_psec_contract_uses_v1_1_for_host_loopback() { + fn required_psec_contract_uses_v1_0_for_host_loopback_without_ingress_support() { + let _guard = crate::process_container_common::test_env::BcUsableGuard::set(false); assert_eq!( BaseContainerRunner::choose_min_required_psec_version_for_request( &host_loopback_request() ), - SecurityEnvironmentVersion::V1_1 + SecurityEnvironmentVersion::V1_0 ); } diff --git a/tests/scripts/README.md b/tests/scripts/README.md index 2ccffdb3e..9e9b07daf 100644 --- a/tests/scripts/README.md +++ b/tests/scripts/README.md @@ -67,6 +67,11 @@ 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 `NetworkHostLoopback` area requires direct host-loopback access on +BaseContainer with `hostLoopback: "allow"` and no runtime proxy, including +older supported Windows hosts. Rejection or tier fallback fails the check; +omitted and explicit `hostLoopback: "deny"` remain negative controls. + 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..28bcedace 100644 --- a/tests/scripts/lib/WinProcessContainer.Common.ps1 +++ b/tests/scripts/lib/WinProcessContainer.Common.ps1 @@ -1061,12 +1061,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" } diff --git a/tests/scripts/run_processcontainer_network_loopback_test.ps1 b/tests/scripts/run_processcontainer_network_loopback_test.ps1 index 1402b2061..f41e40f09 100644 --- a/tests/scripts/run_processcontainer_network_loopback_test.ps1 +++ b/tests/scripts/run_processcontainer_network_loopback_test.ps1 @@ -32,8 +32,8 @@ 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. +# BaseContainer must reach the host listener without a runtime proxy. +# AppContainer tiers must reject hostLoopback=allow. function Phase-NetworkHostLoopback { Section 'Phase 8d: host-loopback policy' @@ -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,20 +69,26 @@ 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)" + -Pass ($trapBlocked -and $denyBlocked) ` + -Detail "omittedExit=$($trap.Result.ExitCode); explicitExit=$($deny.Result.ExitCode)" # hostLoopback=allow. `ingress.default: allow` accompanies it because # the private-network capability is what the doc pairs with the @@ -94,27 +100,12 @@ function Phase-NetworkHostLoopback { $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" - } 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" - } + Record-Result -Phase 'P8d' -Name 'hostLoopback=allow 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 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 { $rejected = Test-WasRejected $allow Record-Result -Phase 'P8d' -Name 'non-PSEC tier rejects hostLoopback=allow' ` @@ -128,4 +119,3 @@ function Phase-NetworkHostLoopback { Invoke-WpcPhase -Key 'NetworkHostLoopback' -Body { Phase-NetworkHostLoopback } Complete-WpcChild - diff --git a/tests/scripts/run_processcontainer_network_proxy_test.ps1 b/tests/scripts/run_processcontainer_network_proxy_test.ps1 index 6f610ea70..7d58b8315 100644 --- a/tests/scripts/run_processcontainer_network_proxy_test.ps1 +++ b/tests/scripts/run_processcontainer_network_proxy_test.ps1 @@ -88,9 +88,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 +122,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') } @@ -145,14 +143,14 @@ function Invoke-NetworkProxyAssertions { -Detail 'the identity-less proxy shape does not start on this host; a NORUN cannot prove egress was blocked' } else { $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 @(7, 28)) -and ($direct.Result.Stderr -match 'curl: \((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 From ce5d09139738bf172255a1960f7a108c2c3ecda8 Mon Sep 17 00:00:00 2001 From: Branden Bonaby <105318831+bbonaby@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:18:53 -0700 Subject: [PATCH 4/8] test: refine loopback validation and Windows support guidance Name the direct loopback validation area explicitly while preserving its previous selector as an alias. Accept policy-blocked DNS in proxy egress checks and clarify Windows build-dependent loopback support. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa1ea5a5-f8e6-4a00-9b94-a8b0192e3466 --- docs/backends/process-container/networking.md | 16 +++++++++++----- .../ci-validation-infrastructure.md | 2 +- tests/scripts/README.md | 3 ++- tests/scripts/run_processcontainer_all_tests.ps1 | 8 +++++--- ...un_processcontainer_network_loopback_test.ps1 | 6 +++--- .../run_processcontainer_network_proxy_test.ps1 | 2 +- 6 files changed, 23 insertions(+), 14 deletions(-) diff --git a/docs/backends/process-container/networking.md b/docs/backends/process-container/networking.md index 0b1d8a117..8dc0630bf 100644 --- a/docs/backends/process-container/networking.md +++ b/docs/backends/process-container/networking.md @@ -102,7 +102,8 @@ 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. @@ -115,13 +116,18 @@ PSEC rejects it alongside a proxy. Their ingress default still selects `privateN 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. `hostLoopback: "allow"` supports connections to host listeners both with an identity-less runtime proxy and -without a proxy. On Windows 11 25H2, the initial ProcessContainer release -(build 26200.9278) supports connections to medium-integrity host listeners. -Build 26200.9550 adds support for hosting loopback servers inside the container -and connecting to low-integrity listeners. See +without a proxy. + +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. + 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. diff --git a/docs/development/build-and-test/ci-validation-infrastructure.md b/docs/development/build-and-test/ci-validation-infrastructure.md index 6b5282296..d049077c0 100644 --- a/docs/development/build-and-test/ci-validation-infrastructure.md +++ b/docs/development/build-and-test/ci-validation-infrastructure.md @@ -170,7 +170,7 @@ across the system drive on every run and again on teardown — acceptable on a disposable runner, which is why the switch is off by default and only CI opts in. Temporary until pwsh 7.7 leaves preview. -`NetworkHostLoopback` requires direct host-loopback access on BaseContainer +`NetworkDirectHostLoopback` requires direct host-loopback access on BaseContainer without a runtime proxy, including older supported Windows hosts. Rejection or tier fallback fails the check; omitted and explicit deny are negative controls. diff --git a/tests/scripts/README.md b/tests/scripts/README.md index 9e9b07daf..a23044546 100644 --- a/tests/scripts/README.md +++ b/tests/scripts/README.md @@ -67,10 +67,11 @@ 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 `NetworkHostLoopback` area requires direct host-loopback access on +The `NetworkDirectHostLoopback` area requires direct host-loopback access on BaseContainer with `hostLoopback: "allow"` and no runtime proxy, including older supported Windows hosts. Rejection or tier fallback fails the check; omitted and explicit `hostLoopback: "deny"` remain negative controls. +The previous `NetworkHostLoopback` area selector remains an alias. Shared helpers live in `tests/scripts/lib/WinProcessContainer.Common.ps1`. It must be **dot-sourced, not imported as a module** — `Initialize-WpcContext` 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 f41e40f09..7da241d66 100644 --- a/tests/scripts/run_processcontainer_network_loopback_test.ps1 +++ b/tests/scripts/run_processcontainer_network_loopback_test.ps1 @@ -34,8 +34,8 @@ Initialize-WpcContext @PSBoundParameters # # BaseContainer must reach the host listener without a runtime proxy. # AppContainer tiers must reject hostLoopback=allow. -function Phase-NetworkHostLoopback { - Section 'Phase 8d: host-loopback policy' +function Phase-NetworkDirectHostLoopback { + Section 'Phase 8d: direct host-loopback policy' if ($SkipNetwork) { Record-Result -Phase 'P8d' -Name 'host loopback' -Status 'skip' -Detail '-SkipNetwork' @@ -117,5 +117,5 @@ function Phase-NetworkHostLoopback { } } -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 7d58b8315..533d226f7 100644 --- a/tests/scripts/run_processcontainer_network_proxy_test.ps1 +++ b/tests/scripts/run_processcontainer_network_proxy_test.ps1 @@ -149,7 +149,7 @@ function Invoke-NetworkProxyAssertions { -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 ((-not $direct.Result.TimedOut) -and ($direct.Result.ExitCode -in @(7, 28)) -and ($direct.Result.Stderr -match 'curl: \((7|28)\)')) ` + -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 { From 2d78f460e309923dd97ba9a48f03774988288cb4 Mon Sep 17 00:00:00 2001 From: Branden Bonaby <105318831+bbonaby@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:30:33 -0700 Subject: [PATCH 5/8] fix: grant direct host loopback without native ingress support Add the loopback capability alongside the peer for direct requests when native ingress support is unavailable. Cover allow and deny encoding independently of host capabilities and document the compatibility mapping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa1ea5a5-f8e6-4a00-9b94-a8b0192e3466 --- docs/backends/process-container/networking.md | 1 + .../common/base_container_helpers.rs | 6 +- .../common/base_container_runner.rs | 74 +++++++++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/docs/backends/process-container/networking.md b/docs/backends/process-container/networking.md index 8dc0630bf..7dcc7936b 100644 --- a/docs/backends/process-container/networking.md +++ b/docs/backends/process-container/networking.md @@ -116,6 +116,7 @@ PSEC rejects it alongside a proxy. Their ingress default still selects `privateN 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. +Direct requests use the same capability and peer when native ingress support is unavailable. The public ingress policy remains required. `hostLoopback: "allow"` supports connections to host listeners both with an identity-less runtime proxy and 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..068132fdf 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 @@ -39,10 +39,10 @@ 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) + if unrestricted_host_loopback_allowed(&request.policy) + && (request.policy.network_proxy.is_enabled() || !supports_network_ingress) { - // Proxy mode cannot carry the native ingress table that grants loopback. + // Without native ingress, loopback needs both the capability and peer. ensure_capability(&mut capabilities, "networkLoopback"); } let ui_restrictions = crate::process_container_common::job_object::to_job_object_uilimit_mask( 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 a3f260b55..ba637e1bc 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 @@ -3208,6 +3208,80 @@ mod tests { ); } + #[test] + fn psec_direct_host_loopback_without_ingress_support_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() + }); + + for version in [ + SecurityEnvironmentVersion::V1_0, + SecurityEnvironmentVersion::V1_1, + ] { + 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(), version.minor); + assert_eq!( + spec.capabilities(), + Some("internetClient,privateNetworkClientServer,networkLoopback") + ); + assert_eq!( + network.allowed_appcontainer_peer(), + Some( + crate::process_container_common::base_container_helpers::LOOPBACK_NETWORK_PEER + ) + ); + 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, supports_network_ingress) in [ + (SecurityEnvironmentVersion::V1_0, false), + (SecurityEnvironmentVersion::V1_1, false), + (SecurityEnvironmentVersion::V1_1, true), + ] { + let bytes = build_psec_v1_security_environment_spec( + &request, + version, + supports_network_ingress, + 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!( From bba9d7b676d6ad3aabb9852626617be2c4c6a239 Mon Sep 17 00:00:00 2001 From: Branden Bonaby <105318831+bbonaby@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:26:24 -0700 Subject: [PATCH 6/8] fix: align ProcessContainer loopback compatibility and discovery Always grant networkLoopback when host loopback is allowed. Preserve ingress-query failures, align proxy discovery with compatible PSEC support, and strengthen version-selection and loopback endpoint regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bfb39885-bfc3-4c08-911e-c98dc5daa379 --- docs/backends/process-container/networking.md | 15 +- .../ci-validation-infrastructure.md | 5 + docs/examples.md | 7 +- .../common/base_container_helpers.rs | 6 +- .../common/base_container_runner.rs | 231 ++++++++++++------ tests/scripts/README.md | 5 +- ...un_processcontainer_network_proxy_test.ps1 | 38 ++- 7 files changed, 211 insertions(+), 96 deletions(-) diff --git a/docs/backends/process-container/networking.md b/docs/backends/process-container/networking.md index 7dcc7936b..3c1355e19 100644 --- a/docs/backends/process-container/networking.md +++ b/docs/backends/process-container/networking.md @@ -116,7 +116,9 @@ PSEC rejects it alongside a proxy. Their ingress default still selects `privateN 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. -Direct requests use the same capability and peer when native ingress support is unavailable. +Direct requests always use the same capability and peer when `hostLoopback` is `"allow"`, +including when the native ingress table is present. This keeps capability-based host-loopback +access available independently of native ingress support. The public ingress policy remains required. `hostLoopback: "allow"` supports connections to host listeners both with an identity-less runtime proxy and @@ -138,12 +140,13 @@ 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 +without a request. Support is available when the host accepts PSEC 1.0 or +advertises native ingress support. This includes PSEC 1.1 hosts with native +ingress disabled that still accept PSEC 1.0. 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. +loopback proxy. Ingress- or version-query failures omit the capability, set the +probe fact to `false`, and add a warning. Execution also reports ingress-query +failures rather than treating an unknown support state as a compatibility fallback. 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 d049077c0..17bc84031 100644 --- a/docs/development/build-and-test/ci-validation-infrastructure.md +++ b/docs/development/build-and-test/ci-validation-infrastructure.md @@ -174,6 +174,11 @@ in. Temporary until pwsh 7.7 leaves preview. without a runtime proxy, including older supported Windows hosts. Rejection or tier fallback fails the check; omitted and explicit deny are negative controls. +The runtime proxy area verifies that the host can reach a second loopback +listener, then requires direct container access to that listener to fail while +the configured proxy remains reachable. The literal-IP check cannot pass on +DNS-resolution failure. + The suite's git workloads also restamp the ownership of the repo they build. The 1ES agent runs elevated, and an elevated token's *default owner* is `BUILTIN\Administrators`, so a fixture created there is Administrators-owned; diff --git a/docs/examples.md b/docs/examples.md index c6a523a5b..50497647f 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -134,9 +134,10 @@ 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 +endpoint. It supports hosts that accept PSEC 1.0 or advertise native ingress +support, including PSEC 1.1 hosts with ingress disabled that still accept +PSEC 1.0. Capability-query failures are reported rather than treated as absent +ingress support. 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). 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 068132fdf..2fd1f1c65 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 @@ -39,10 +39,8 @@ 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 unrestricted_host_loopback_allowed(&request.policy) - && (request.policy.network_proxy.is_enabled() || !supports_network_ingress) - { - // Without native ingress, loopback needs both the capability and peer. + if unrestricted_host_loopback_allowed(&request.policy) { + // Keep capability-based loopback available independently of native ingress support. ensure_capability(&mut capabilities, "networkLoopback"); } let ui_restrictions = crate::process_container_common::job_object::to_job_object_uilimit_mask( 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 ba637e1bc..e1529b031 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 @@ -150,9 +150,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)"; @@ -388,8 +385,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,29 +398,18 @@ impl BaseContainerRunner { return Ok(false); } Self::identityless_loopback_proxy_supported( - Self::supports_ingress_host_loopback_allow(), + || secenv::try_query_support(SecurityEnvironmentSupport::NetworkIngress), secenv::supports_version, ) } fn identityless_loopback_proxy_supported( - ingress_supported: bool, - supports_version: impl FnMut(SecurityEnvironmentVersion) -> Result, + query_ingress_support: impl FnOnce() -> Result, + supports_version: impl FnOnce(SecurityEnvironmentVersion) -> Result, ) -> Result { - if ingress_supported { + if query_ingress_support()? { 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) } @@ -432,6 +417,7 @@ impl BaseContainerRunner { /// the highest version required by any requested native feature. fn choose_min_required_psec_version_for_request( request: &ExecutionRequest, + supports_network_ingress: bool, ) -> SecurityEnvironmentVersion { let mut version = SecurityEnvironmentVersion::V1_0; if !request.policy.denied_paths.is_empty() { @@ -447,9 +433,7 @@ impl BaseContainerRunner { // PSEC 1.0 only supports host loopback access to medium IL listeners, so those // requests can still be considered serviceable on PSEC 1.0. Only upgrade to PSEC 1.1 // when the host supports the network ingress flag. - if unrestricted_host_loopback_allowed(&request.policy) - && Self::supports_ingress_host_loopback_allow() - { + if unrestricted_host_loopback_allowed(&request.policy) && supports_network_ingress { version = version.max(SecurityEnvironmentSupport::NetworkIngress.required_version()); } version @@ -521,7 +505,20 @@ impl BaseContainerRunner { logger, ); - let psec_version = Self::choose_min_required_psec_version_for_request(request); + let supports_network_ingress = if request.policy.network_ingress.is_some() { + secenv::try_query_support(SecurityEnvironmentSupport::NetworkIngress).map_err( + |error| ScriptResponse { + failure_phase: FailurePhase::BackendUnavailable, + ..ScriptResponse::error(&format!( + "failed to query Process Security Environment ingress support: {error}" + )) + }, + )? + } else { + false + }; + let psec_version = + Self::choose_min_required_psec_version_for_request(request, supports_network_ingress); let version_supported = secenv::supports_version(psec_version).map_err(|error| ScriptResponse { failure_phase: FailurePhase::BackendUnavailable, @@ -533,8 +530,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" }, @@ -555,8 +550,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 supports_network_ingress = + psec_version >= SecurityEnvironmentVersion::V1_1 && supports_network_ingress; let process_security_environment_spec = self .build_capture_aware_security_environment_spec( request, @@ -2300,7 +2295,7 @@ mod tests { impl BaseContainerRunner { fn build_process_security_environment_spec(request: &ExecutionRequest) -> Vec { - let version = Self::choose_min_required_psec_version_for_request(request); + let version = Self::choose_min_required_psec_version_for_request(request, true); Self::with_capture_factory(fake_capture_factory()) .build_capture_aware_security_environment_spec( request, @@ -3198,7 +3193,7 @@ mod tests { .expect("PSEC 1.1 must carry host-loopback policy"); assert_eq!(spec.version().minor(), 1); - assert!(spec.capabilities().is_none()); + assert_eq!(spec.capabilities(), Some("networkLoopback")); assert_eq!(ingress.default_action(), psec_layout::FilterAction::deny); assert_eq!(ingress.host_loopback(), psec_layout::FilterAction::allow); assert_eq!( @@ -3209,18 +3204,29 @@ mod tests { } #[test] - fn psec_direct_host_loopback_without_ingress_support_encodes_capability_and_peer() { + fn psec_direct_host_loopback_encodes_capability_and_peer_regardless_of_ingress_support() { let mut request = host_loopback_request(); request.policy.network_egress = Some(crate::mxc_common::models::NetworkEgressPolicy { default: NetworkAction::Allow, ..Default::default() }); - for version in [ - SecurityEnvironmentVersion::V1_0, - SecurityEnvironmentVersion::V1_1, + for (version, supports_network_ingress) in [ + (SecurityEnvironmentVersion::V1_0, false), + (SecurityEnvironmentVersion::V1_1, false), + (SecurityEnvironmentVersion::V1_1, true), ] { - let bytes = build_psec_v1_security_environment_spec(&request, version, false, false); + 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, + supports_network_ingress, + false, + ); let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); let network = spec.network_policy().unwrap(); @@ -3235,7 +3241,14 @@ mod tests { crate::process_container_common::base_container_helpers::LOOPBACK_NETWORK_PEER ) ); - assert!(network.ingress().is_none()); + assert_eq!(spec.fs_enumerate().is_some(), version.minor == 1); + if supports_network_ingress { + let ingress = network.ingress().expect("native ingress policy"); + assert_eq!(ingress.default_action(), psec_layout::FilterAction::allow); + assert_eq!(ingress.host_loopback(), psec_layout::FilterAction::allow); + } else { + assert!(network.ingress().is_none()); + } assert!(network.proxy().is_none()); assert_eq!( network.egress().unwrap().default_action(), @@ -3284,28 +3297,62 @@ mod tests { #[test] fn required_psec_contract_uses_v1_1_for_enumeration() { - assert_eq!( - BaseContainerRunner::choose_min_required_psec_version_for_request(&enumerate_request()), - SecurityEnvironmentVersion::V1_1 - ); + for supports_network_ingress in [false, true] { + let mut request = host_loopback_request(); + request.policy.enumerate_paths = enumerate_request().policy.enumerate_paths; + assert_eq!( + BaseContainerRunner::choose_min_required_psec_version_for_request( + &request, + supports_network_ingress + ), + SecurityEnvironmentVersion::V1_1 + ); + } } #[test] - fn required_psec_contract_uses_v1_0_for_host_loopback_without_ingress_support() { - let _guard = crate::process_container_common::test_env::BcUsableGuard::set(false); - assert_eq!( - BaseContainerRunner::choose_min_required_psec_version_for_request( - &host_loopback_request() - ), - SecurityEnvironmentVersion::V1_0 - ); + fn required_psec_contract_uses_available_base_container_ingress_support() { + let _guard = crate::process_container_common::test_env::BcUsableGuard::set(true); + for request in [host_loopback_request(), psec_1_0_proxy_request()] { + assert!( + BaseContainerRunner::can_backend_service_request(&request).can_service_request() + ); + for (supports_network_ingress, expected) in [ + (false, SecurityEnvironmentVersion::V1_0), + (true, SecurityEnvironmentVersion::V1_1), + ] { + let version = BaseContainerRunner::choose_min_required_psec_version_for_request( + &request, + supports_network_ingress, + ); + assert_eq!(version, expected); + let bytes = build_psec_v1_security_environment_spec( + &request, + version, + supports_network_ingress, + false, + ); + let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); + assert_eq!(spec.version().minor(), expected.minor); + assert!(spec + .capabilities() + .unwrap() + .split(',') + .any(|capability| capability == "networkLoopback")); + assert_eq!( + spec.network_policy().unwrap().ingress().is_some(), + supports_network_ingress && !request.policy.network_proxy.is_enabled() + ); + } + } } #[test] fn required_psec_contract_defaults_to_v1_0() { assert_eq!( BaseContainerRunner::choose_min_required_psec_version_for_request( - &ExecutionRequest::default() + &ExecutionRequest::default(), + true ), SecurityEnvironmentVersion::V1_0 ); @@ -3327,23 +3374,20 @@ mod tests { } #[test] - fn identityless_loopback_proxy_supports_ingress_or_psec_1_0_only() { + fn identityless_loopback_proxy_support_matches_required_psec_contract() { for (supports_1_0, supports_1_1, ingress_supported, expected) in [ (true, false, false, true), (true, true, true, true), - (true, true, false, false), + (true, true, false, true), (false, true, true, true), (false, true, false, false), (false, false, false, false), ] { let supported = BaseContainerRunner::identityless_loopback_proxy_supported( - ingress_supported, + || Ok(ingress_supported), |version| { - Ok(if version == SecurityEnvironmentVersion::V1_1 { - supports_1_1 - } else { - supports_1_0 - }) + assert_eq!(version, SecurityEnvironmentVersion::V1_0); + Ok(supports_1_0) }, ) .unwrap(); @@ -3351,38 +3395,63 @@ mod tests { supported, expected, "PSEC 1.0={supports_1_0}, PSEC 1.1={supports_1_1}, ingress={ingress_supported}" ); + let version = BaseContainerRunner::choose_min_required_psec_version_for_request( + &psec_1_0_proxy_request(), + ingress_supported, + ); + assert_eq!( + supported, + if version == SecurityEnvironmentVersion::V1_1 { + supports_1_1 + } else { + supports_1_0 + } + ); } } #[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() - ); + assert!(BaseContainerRunner::identityless_loopback_proxy_supported( + || Ok(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 { .. }))); - } + let result = BaseContainerRunner::identityless_loopback_proxy_supported( + || Ok(false), + |version| { + assert_eq!(version, SecurityEnvironmentVersion::V1_0); + Err(LearningModeError::HResultCall { + function: "IsProcessSecurityEnvironmentVersionSupported", + code: E_NOTIMPL.0, + }) + }, + ); + assert!(matches!(result, Err(LearningModeError::HResultCall { .. }))); + } + + #[test] + fn identityless_loopback_proxy_propagates_ingress_query_errors() { + let result = BaseContainerRunner::identityless_loopback_proxy_supported( + || { + Err(LearningModeError::HResultCall { + function: "QueryProcessSecurityEnvironmentSupport", + code: E_NOTIMPL.0, + }) + }, + |_| panic!("an ingress query failure must not become a compatibility fallback"), + ); + assert!(matches!( + result, + Err(LearningModeError::HResultCall { + function: "QueryProcessSecurityEnvironmentSupport", + code + }) if code == E_NOTIMPL.0 + )); } #[test] @@ -3415,7 +3484,7 @@ mod tests { 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), + BaseContainerRunner::choose_min_required_psec_version_for_request(&request, false), SecurityEnvironmentVersion::V1_1 ); } diff --git a/tests/scripts/README.md b/tests/scripts/README.md index a23044546..c88142a46 100644 --- a/tests/scripts/README.md +++ b/tests/scripts/README.md @@ -62,10 +62,13 @@ 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. +hosts that accept PSEC 1.0 or advertise native 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 area also verifies that the host can reach a second loopback endpoint and +that the proxy-configured container cannot reach it directly. This literal-IP +negative check requires a connection failure or curl timeout, not a DNS failure. The `NetworkDirectHostLoopback` area requires direct host-loopback access on BaseContainer with `hostLoopback: "allow"` and no runtime proxy, including diff --git a/tests/scripts/run_processcontainer_network_proxy_test.ps1 b/tests/scripts/run_processcontainer_network_proxy_test.ps1 index 533d226f7..842dd5448 100644 --- a/tests/scripts/run_processcontainer_network_proxy_test.ps1 +++ b/tests/scripts/run_processcontainer_network_proxy_test.ps1 @@ -55,9 +55,32 @@ function Phase-NetworkProxy { -Detail 'could not bind a listener on 127.0.0.1; cannot assert the proxy endpoint is reachable' return } + $directListener = $null + $directLoopbackUrl = '' try { - Invoke-NetworkProxyAssertions -ProxyPort $proxy.Port -Fs $fs -Psec $psec + if ($psec) { + $directListener = Start-LoopbackListener + if (-not $directListener) { + Record-Result -Phase 'P8e' -Name 'direct loopback listener' -Pass $false ` + -Detail 'could not bind a second listener on 127.0.0.1; cannot assert proxy endpoint isolation' + return + } + $directLoopbackUrl = $directListener.Url + $hostReach = $false + $hostError = '' + try { + $response = Invoke-WebRequest -Uri $directLoopbackUrl -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' ` + -Pass $hostReach -Detail "endpoint=$directLoopbackUrl; error=$hostError" + if (-not $hostReach) { return } + } + Invoke-NetworkProxyAssertions -ProxyPort $proxy.Port -DirectLoopbackUrl $directLoopbackUrl -Fs $fs -Psec $psec } finally { + if ($directListener) { & $directListener.Stop } & $proxy.Stop } } @@ -66,6 +89,7 @@ function Phase-NetworkProxy { function Invoke-NetworkProxyAssertions { param( [Parameter(Mandatory)] [int]$ProxyPort, + [string]$DirectLoopbackUrl, [Parameter(Mandatory)] $Fs, [Parameter(Mandatory)] [bool]$Psec ) @@ -141,7 +165,19 @@ 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' + Record-Result -Phase 'P8e' -Name 'direct access to a second loopback endpoint is blocked' -Status 'skip' ` + -Detail 'the identity-less proxy shape does not start on this host; a NORUN cannot prove endpoint isolation' } else { + $cfgLoopback = New-Config -Name 'net-proxy-direct-loopback-blocked' ` + -CommandLine (Get-LoopbackFetchCommand -Url $DirectLoopbackUrl) ` + -ReadWrite $fs.ReadWrite -ReadOnly $fs.ReadOnly ` + -EgressDefault 'deny' -IngressDefault 'allow' -HostLoopback 'allow' ` + -NetworkProxy $proxyUrl -TimeoutMs 25000 + $loopback = Invoke-NetRun -Name 'net-proxy-direct-loopback-blocked' -ConfigPath $cfgLoopback + Record-Result -Phase 'P8e' -Name 'direct access to a second 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=$DirectLoopbackUrl; host reachability verified" + $cfgDirect = New-Config -Name 'net-proxy-direct-blocked' ` -CommandLine (Get-LoopbackFetchCommand -Url $ExternalAnchorUrl) ` -ReadWrite $fs.ReadWrite -ReadOnly $fs.ReadOnly ` From ce040460c29588054a470711638fa381315173ce Mon Sep 17 00:00:00 2001 From: Branden Bonaby <105318831+bbonaby@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:09:09 -0700 Subject: [PATCH 7/8] docs: simplify ProcessContainer loopback guidance Keep proxy discovery and examples focused on caller requirements, and describe the validation expectations in plain language. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bfb39885-bfc3-4c08-911e-c98dc5daa379 --- docs/backends/process-container/networking.md | 8 +------- docs/examples.md | 6 +----- tests/scripts/README.md | 8 ++++---- 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/docs/backends/process-container/networking.md b/docs/backends/process-container/networking.md index 3c1355e19..4630e9351 100644 --- a/docs/backends/process-container/networking.md +++ b/docs/backends/process-container/networking.md @@ -140,13 +140,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 when the host accepts PSEC 1.0 or -advertises native ingress support. This includes PSEC 1.1 hosts with native -ingress disabled that still accept PSEC 1.0. A host that reports -`baseContainerSupportsIngressHostLoopbackAllow` also supports an identity-less -loopback proxy. Ingress- or version-query failures omit the capability, set the -probe fact to `false`, and add a warning. Execution also reports ingress-query -failures rather than treating an unknown support state as a compatibility fallback. +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/examples.md b/docs/examples.md index 50497647f..cd72ad4aa 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -134,11 +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 hosts that accept PSEC 1.0 or advertise native ingress -support, including PSEC 1.1 hosts with ingress disabled that still accept -PSEC 1.0. Capability-query failures are reported rather than treated as absent -ingress support. 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/tests/scripts/README.md b/tests/scripts/README.md index c88142a46..908108209 100644 --- a/tests/scripts/README.md +++ b/tests/scripts/README.md @@ -60,10 +60,10 @@ built tree, which is the fastest way to iterate on one area: tests\scripts\run_processcontainer_network_proxy_test.ps1 -RequireTier base-container ``` -When the host probe reports `baseContainerSupportsIdentitylessLoopbackProxy`, -the proxy area requires workload launch and a successful proxied fetch on both -hosts that accept PSEC 1.0 or advertise native ingress support. -A clean policy rejection is a failure on these hosts. +When the host probe reports +`baseContainerSupportsIdentitylessLoopbackProxy` as `true`, the proxy test +must start the workload and reach the configured proxy. Rejecting the request +counts as a failure. Older binaries that omit this probe fact do not enable the capability-specific assertions; the existing proxy assertions still apply. The area also verifies that the host can reach a second loopback endpoint and From 0bd5c3d93bab18d1c3f3ce65bca5891de001a244 Mon Sep 17 00:00:00 2001 From: Branden Bonaby <105318831+bbonaby@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:52:03 -0700 Subject: [PATCH 8/8] Refine ProcessContainer host-loopback requirements Use the loopback behavior available on each Windows build, preserve proxy endpoint restrictions, and align discovery descriptions and regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bfb39885-bfc3-4c08-911e-c98dc5daa379 --- docs/api-reference/dotnet/v1/types.md | 10 + docs/api-reference/node/v1/types.md | 10 + docs/api-reference/rust/v1/types.md | 6 + docs/backends/process-container/networking.md | 30 +- .../ci-validation-infrastructure.md | 11 +- .../Microsoft.Mxc.Sdk/V1/PlatformDiscovery.cs | 9 +- sdk/node/src/v1/types.ts | 10 + .../common/base_container_helpers.rs | 40 +- .../common/base_container_helpers/tests.rs | 95 +++-- .../common/base_container_runner.rs | 370 ++++-------------- .../process_container/common/probe.rs | 5 +- src/mxc-sdk/src/core/mxc_engine/probe.rs | 4 +- tests/scripts/README.md | 20 +- .../lib/WinProcessContainer.Common.ps1 | 41 +- ...processcontainer_network_loopback_test.ps1 | 42 +- ...un_processcontainer_network_proxy_test.ps1 | 80 ++-- 16 files changed, 278 insertions(+), 505 deletions(-) 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 4630e9351..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 @@ -109,29 +116,6 @@ non-AppContainer proxy lacks an accepted peer identity and requires `hostLoopbac 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. -Direct requests always use the same capability and peer when `hostLoopback` is `"allow"`, -including when the native ingress table is present. This keeps capability-based host-loopback -access available independently of native ingress support. - -The public ingress policy remains required. `hostLoopback: "allow"` supports -connections to host listeners both with an identity-less runtime proxy and -without a proxy. - -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. - -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 diff --git a/docs/development/build-and-test/ci-validation-infrastructure.md b/docs/development/build-and-test/ci-validation-infrastructure.md index 17bc84031..87ed24a9e 100644 --- a/docs/development/build-and-test/ci-validation-infrastructure.md +++ b/docs/development/build-and-test/ci-validation-infrastructure.md @@ -170,15 +170,6 @@ across the system drive on every run and again on teardown — acceptable on a disposable runner, which is why the switch is off by default and only CI opts in. Temporary until pwsh 7.7 leaves preview. -`NetworkDirectHostLoopback` requires direct host-loopback access on BaseContainer -without a runtime proxy, including older supported Windows hosts. Rejection -or tier fallback fails the check; omitted and explicit deny are negative controls. - -The runtime proxy area verifies that the host can reach a second loopback -listener, then requires direct container access to that listener to fail while -the configured proxy remains reachable. The literal-IP check cannot pass on -DNS-resolution failure. - The suite's git workloads also restamp the ownership of the repo they build. The 1ES agent runs elevated, and an elevated token's *default owner* is `BUILTIN\Administrators`, so a fixture created there is Administrators-owned; @@ -235,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/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 2fd1f1c65..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!( @@ -40,7 +39,6 @@ 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 unrestricted_host_loopback_allowed(&request.policy) { - // Keep capability-based loopback available independently of native ingress support. ensure_capability(&mut capabilities, "networkLoopback"); } let ui_restrictions = crate::process_container_common::job_object::to_job_object_uilimit_mask( @@ -62,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); @@ -101,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. @@ -131,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 e1529b031..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, @@ -310,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 @@ -327,7 +325,6 @@ impl BaseContainerRunner { Ok(build_psec_v1_security_environment_spec( request, version, - supports_network_ingress, capture_network_decisions, )) } @@ -397,27 +394,13 @@ impl BaseContainerRunner { if !Self::is_base_container_api_present() { return Ok(false); } - Self::identityless_loopback_proxy_supported( - || secenv::try_query_support(SecurityEnvironmentSupport::NetworkIngress), - secenv::supports_version, - ) - } - - fn identityless_loopback_proxy_supported( - query_ingress_support: impl FnOnce() -> Result, - supports_version: impl FnOnce(SecurityEnvironmentVersion) -> Result, - ) -> Result { - if query_ingress_support()? { - return Ok(true); - } - supports_version(SecurityEnvironmentVersion::V1_0) + 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, - supports_network_ingress: bool, ) -> SecurityEnvironmentVersion { let mut version = SecurityEnvironmentVersion::V1_0; if !request.policy.denied_paths.is_empty() { @@ -428,14 +411,6 @@ impl BaseContainerRunner { version.max(SecurityEnvironmentSupport::FileSystemEnumerate.required_version()); } - // PSEC 1.1 supports unrestricted host loopback, including hosting a loopback - // server in the base container and communicating with low IL listeners. - // PSEC 1.0 only supports host loopback access to medium IL listeners, so those - // requests can still be considered serviceable on PSEC 1.0. Only upgrade to PSEC 1.1 - // when the host supports the network ingress flag. - if unrestricted_host_loopback_allowed(&request.policy) && supports_network_ingress { - version = version.max(SecurityEnvironmentSupport::NetworkIngress.required_version()); - } version } @@ -505,20 +480,7 @@ impl BaseContainerRunner { logger, ); - let supports_network_ingress = if request.policy.network_ingress.is_some() { - secenv::try_query_support(SecurityEnvironmentSupport::NetworkIngress).map_err( - |error| ScriptResponse { - failure_phase: FailurePhase::BackendUnavailable, - ..ScriptResponse::error(&format!( - "failed to query Process Security Environment ingress support: {error}" - )) - }, - )? - } else { - false - }; - let psec_version = - Self::choose_min_required_psec_version_for_request(request, supports_network_ingress); + 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, @@ -550,14 +512,8 @@ impl BaseContainerRunner { let _ = writeln!(logger, "{EMOJI_SECTION} SECTION: captureDenials"); } - let supports_network_ingress = - psec_version >= SecurityEnvironmentVersion::V1_1 && supports_network_ingress; - 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)", @@ -2295,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, true); + 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() } } @@ -3007,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(); @@ -3173,87 +3124,50 @@ 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, - }); - - 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 ingress = spec - .network_policy() - .and_then(|network| network.ingress()) - .expect("PSEC 1.1 must carry host-loopback policy"); - - assert_eq!(spec.version().minor(), 1); - assert_eq!(spec.capabilities(), Some("networkLoopback")); - 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) - ); - } - - #[test] - fn psec_direct_host_loopback_encodes_capability_and_peer_regardless_of_ingress_support() { + 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() }); - for (version, supports_network_ingress) in [ - (SecurityEnvironmentVersion::V1_0, false), - (SecurityEnvironmentVersion::V1_1, false), - (SecurityEnvironmentVersion::V1_1, true), + for version in [ + SecurityEnvironmentVersion::V1_0, + SecurityEnvironmentVersion::V1_1, ] { - 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, - supports_network_ingress, - false, - ); - let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); - let network = spec.network_policy().unwrap(); + 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(), version.minor); - assert_eq!( - spec.capabilities(), - Some("internetClient,privateNetworkClientServer,networkLoopback") - ); - assert_eq!( + 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); - if supports_network_ingress { - let ingress = network.ingress().expect("native ingress policy"); - assert_eq!(ingress.default_action(), psec_layout::FilterAction::allow); - assert_eq!(ingress.host_loopback(), psec_layout::FilterAction::allow); - } else { + 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 + ); } - assert!(network.proxy().is_none()); - assert_eq!( - network.egress().unwrap().default_action(), - psec_layout::FilterAction::allow - ); } } @@ -3268,17 +3182,11 @@ mod tests { ] { let mut request = ExecutionRequest::default(); request.policy.network_ingress = ingress; - for (version, supports_network_ingress) in [ - (SecurityEnvironmentVersion::V1_0, false), - (SecurityEnvironmentVersion::V1_1, false), - (SecurityEnvironmentVersion::V1_1, true), + for version in [ + SecurityEnvironmentVersion::V1_0, + SecurityEnvironmentVersion::V1_1, ] { - let bytes = build_psec_v1_security_environment_spec( - &request, - version, - supports_network_ingress, - false, - ); + let bytes = build_psec_v1_security_environment_spec(&request, version, false); let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); assert!(!spec @@ -3297,53 +3205,26 @@ mod tests { #[test] fn required_psec_contract_uses_v1_1_for_enumeration() { - for supports_network_ingress in [false, true] { - let mut request = host_loopback_request(); + 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, - supports_network_ingress - ), + BaseContainerRunner::choose_min_required_psec_version_for_request(&request), SecurityEnvironmentVersion::V1_1 ); } } #[test] - fn required_psec_contract_uses_available_base_container_ingress_support() { + 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(), psec_1_0_proxy_request()] { + for request in [host_loopback_request(), identityless_proxy_request()] { assert!( BaseContainerRunner::can_backend_service_request(&request).can_service_request() ); - for (supports_network_ingress, expected) in [ - (false, SecurityEnvironmentVersion::V1_0), - (true, SecurityEnvironmentVersion::V1_1), - ] { - let version = BaseContainerRunner::choose_min_required_psec_version_for_request( - &request, - supports_network_ingress, - ); - assert_eq!(version, expected); - let bytes = build_psec_v1_security_environment_spec( - &request, - version, - supports_network_ingress, - false, - ); - let spec = psec_layout::root_as_process_security_environment(&bytes).unwrap(); - assert_eq!(spec.version().minor(), expected.minor); - assert!(spec - .capabilities() - .unwrap() - .split(',') - .any(|capability| capability == "networkLoopback")); - assert_eq!( - spec.network_policy().unwrap().ingress().is_some(), - supports_network_ingress && !request.policy.network_proxy.is_enabled() - ); - } + assert_eq!( + BaseContainerRunner::choose_min_required_psec_version_for_request(&request), + SecurityEnvironmentVersion::V1_0 + ); } } @@ -3352,13 +3233,12 @@ mod tests { assert_eq!( BaseContainerRunner::choose_min_required_psec_version_for_request( &ExecutionRequest::default(), - true ), 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 { @@ -3373,122 +3253,6 @@ mod tests { assert!(!BaseContainerRunner::supports_identityless_loopback_proxy().unwrap()); } - #[test] - fn identityless_loopback_proxy_support_matches_required_psec_contract() { - for (supports_1_0, supports_1_1, ingress_supported, expected) in [ - (true, false, false, true), - (true, true, true, true), - (true, true, false, true), - (false, true, true, true), - (false, true, false, false), - (false, false, false, false), - ] { - let supported = BaseContainerRunner::identityless_loopback_proxy_supported( - || Ok(ingress_supported), - |version| { - assert_eq!(version, SecurityEnvironmentVersion::V1_0); - Ok(supports_1_0) - }, - ) - .unwrap(); - assert_eq!( - supported, expected, - "PSEC 1.0={supports_1_0}, PSEC 1.1={supports_1_1}, ingress={ingress_supported}" - ); - let version = BaseContainerRunner::choose_min_required_psec_version_for_request( - &psec_1_0_proxy_request(), - ingress_supported, - ); - assert_eq!( - supported, - if version == SecurityEnvironmentVersion::V1_1 { - supports_1_1 - } else { - supports_1_0 - } - ); - } - } - - #[test] - fn identityless_loopback_proxy_with_ingress_does_not_need_compatibility_queries() { - assert!(BaseContainerRunner::identityless_loopback_proxy_supported( - || Ok(true), - |_| { panic!("ingress support is sufficient for an identity-less loopback proxy") } - ) - .unwrap()); - } - - #[test] - fn identityless_loopback_proxy_propagates_version_query_errors() { - let result = BaseContainerRunner::identityless_loopback_proxy_supported( - || Ok(false), - |version| { - assert_eq!(version, SecurityEnvironmentVersion::V1_0); - Err(LearningModeError::HResultCall { - function: "IsProcessSecurityEnvironmentVersionSupported", - code: E_NOTIMPL.0, - }) - }, - ); - assert!(matches!(result, Err(LearningModeError::HResultCall { .. }))); - } - - #[test] - fn identityless_loopback_proxy_propagates_ingress_query_errors() { - let result = BaseContainerRunner::identityless_loopback_proxy_supported( - || { - Err(LearningModeError::HResultCall { - function: "QueryProcessSecurityEnvironmentSupport", - code: E_NOTIMPL.0, - }) - }, - |_| panic!("an ingress query failure must not become a compatibility fallback"), - ); - assert!(matches!( - result, - Err(LearningModeError::HResultCall { - function: "QueryProcessSecurityEnvironmentSupport", - code - }) if code == E_NOTIMPL.0 - )); - } - - #[test] - fn psec_1_0_proxy_encodes_existing_loopback_capability_and_peer() { - let request = psec_1_0_proxy_request(); - let 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_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, false), - SecurityEnvironmentVersion::V1_1 - ); - } - #[test] #[should_panic( expected = "build_psec_v1_security_environment_spec only supports PSEC major version 1" @@ -3498,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(); @@ -3518,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, @@ -3526,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 908108209..18cc9c4e6 100644 --- a/tests/scripts/README.md +++ b/tests/scripts/README.md @@ -60,21 +60,15 @@ built tree, which is the fastest way to iterate on one area: tests\scripts\run_processcontainer_network_proxy_test.ps1 -RequireTier base-container ``` -When the host probe reports -`baseContainerSupportsIdentitylessLoopbackProxy` as `true`, the proxy test -must start the workload and reach the configured proxy. Rejecting the request -counts as a failure. +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 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 area also verifies that the host can reach a second loopback endpoint and -that the proxy-configured container cannot reach it directly. This literal-IP -negative check requires a connection failure or curl timeout, not a DNS failure. - -The `NetworkDirectHostLoopback` area requires direct host-loopback access on -BaseContainer with `hostLoopback: "allow"` and no runtime proxy, including -older supported Windows hosts. Rejection or tier fallback fails the check; -omitted and explicit `hostLoopback: "deny"` remain negative controls. -The previous `NetworkHostLoopback` area selector remains an alias. + +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` diff --git a/tests/scripts/lib/WinProcessContainer.Common.ps1 b/tests/scripts/lib/WinProcessContainer.Common.ps1 index 28bcedace..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 {} @@ -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_network_loopback_test.ps1 b/tests/scripts/run_processcontainer_network_loopback_test.ps1 index 7da241d66..41e75d9f6 100644 --- a/tests/scripts/run_processcontainer_network_loopback_test.ps1 +++ b/tests/scripts/run_processcontainer_network_loopback_test.ps1 @@ -90,27 +90,27 @@ function Phase-NetworkDirectHostLoopback { -Pass ($trapBlocked -and $denyBlocked) ` -Detail "omittedExit=$($trap.Result.ExitCode); explicitExit=$($deny.Result.ExitCode)" - # 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) { - Record-Result -Phase 'P8d' -Name 'hostLoopback=allow 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 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 { - $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)" + # 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 { + $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)" + } } } finally { & $listener.Stop diff --git a/tests/scripts/run_processcontainer_network_proxy_test.ps1 b/tests/scripts/run_processcontainer_network_proxy_test.ps1 index 842dd5448..190fe6562 100644 --- a/tests/scripts/run_processcontainer_network_proxy_test.ps1 +++ b/tests/scripts/run_processcontainer_network_proxy_test.ps1 @@ -55,33 +55,43 @@ function Phase-NetworkProxy { -Detail 'could not bind a listener on 127.0.0.1; cannot assert the proxy endpoint is reachable' return } - $directListener = $null - $directLoopbackUrl = '' + $directListeners = @() + $directLoopbackUrls = @() try { if ($psec) { - $directListener = Start-LoopbackListener - if (-not $directListener) { - Record-Result -Phase 'P8e' -Name 'direct loopback listener' -Pass $false ` - -Detail 'could not bind a second listener on 127.0.0.1; cannot assert proxy endpoint isolation' - return + 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 } } - $directLoopbackUrl = $directListener.Url - $hostReach = $false - $hostError = '' + } + Invoke-NetworkProxyAssertions -ProxyPort $proxy.Port -DirectLoopbackUrls $directLoopbackUrls -Fs $fs -Psec $psec + } finally { + foreach ($listener in ($directListeners + @($proxy))) { try { - $response = Invoke-WebRequest -Uri $directLoopbackUrl -TimeoutSec 5 -UseBasicParsing - $hostReach = ($response.Content -match 'MXC-LOOPBACK-ANCHOR') + & $listener.Stop } catch { - $hostError = $_.Exception.Message + Record-Result -Phase 'P8e' -Name 'listener cleanup' -Pass $false ` + -Detail "endpoint=$($listener.Url); error=$($_.Exception.Message)" } - Record-Result -Phase 'P8e' -Name 'prerequisite: host reaches the second loopback endpoint' ` - -Pass $hostReach -Detail "endpoint=$directLoopbackUrl; error=$hostError" - if (-not $hostReach) { return } } - Invoke-NetworkProxyAssertions -ProxyPort $proxy.Port -DirectLoopbackUrl $directLoopbackUrl -Fs $fs -Psec $psec - } finally { - if ($directListener) { & $directListener.Stop } - & $proxy.Stop } } @@ -89,7 +99,7 @@ function Phase-NetworkProxy { function Invoke-NetworkProxyAssertions { param( [Parameter(Mandatory)] [int]$ProxyPort, - [string]$DirectLoopbackUrl, + [string[]]$DirectLoopbackUrls = @(), [Parameter(Mandatory)] $Fs, [Parameter(Mandatory)] [bool]$Psec ) @@ -165,18 +175,24 @@ 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' - Record-Result -Phase 'P8e' -Name 'direct access to a second loopback endpoint is blocked' -Status 'skip' ` - -Detail 'the identity-less proxy shape does not start on this host; a NORUN cannot prove endpoint isolation' + 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 { - $cfgLoopback = New-Config -Name 'net-proxy-direct-loopback-blocked' ` - -CommandLine (Get-LoopbackFetchCommand -Url $DirectLoopbackUrl) ` - -ReadWrite $fs.ReadWrite -ReadOnly $fs.ReadOnly ` - -EgressDefault 'deny' -IngressDefault 'allow' -HostLoopback 'allow' ` - -NetworkProxy $proxyUrl -TimeoutMs 25000 - $loopback = Invoke-NetRun -Name 'net-proxy-direct-loopback-blocked' -ConfigPath $cfgLoopback - Record-Result -Phase 'P8e' -Name 'direct access to a second 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=$DirectLoopbackUrl; host reachability verified" + 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-LoopbackFetchCommand -Url $ExternalAnchorUrl) `