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