diff --git a/docs/api-reference/dotnet/v1/types.md b/docs/api-reference/dotnet/v1/types.md index 1c059743b..28ab98ff2 100644 --- a/docs/api-reference/dotnet/v1/types.md +++ b/docs/api-reference/dotnet/v1/types.md @@ -38,6 +38,7 @@ public enum BackendCapability FilesystemDeniedPaths = 3, IngressHostLoopbackAllow = 4, FilesystemEnumeratePaths = 5, + IdentitylessLoopbackProxy = 6, } ``` @@ -954,6 +955,7 @@ public sealed class ProbeFacts public bool BaseContainerSupportsDenyPaths { get; init; } public bool BaseContainerSupportsEnumeratePaths { get; init; } public bool BaseContainerSupportsIngressHostLoopbackAllow { get; init; } + public bool BaseContainerSupportsIdentitylessLoopbackProxy { get; init; } public bool IsolationSessionAvailable { get; init; } public bool HyperlightAvailable { get; init; } public required UiCapabilitySupport UiCapabilities { get; init; } diff --git a/docs/api-reference/node/v1/types.md b/docs/api-reference/node/v1/types.md index 5d8b4ee92..83b11b3eb 100644 --- a/docs/api-reference/node/v1/types.md +++ b/docs/api-reference/node/v1/types.md @@ -31,6 +31,7 @@ export type BackendCapability = | 'filesystemEnumeratePaths' | 'ingressHostLoopbackAllow' | 'proxyEnforcement' + | 'identitylessLoopbackProxy' | 'unknown'; ``` @@ -578,6 +579,7 @@ export interface ProbeFacts { baseContainerSupportsDenyPaths: boolean; baseContainerSupportsEnumeratePaths: boolean; baseContainerSupportsIngressHostLoopbackAllow: boolean; + baseContainerSupportsIdentitylessLoopbackProxy: boolean; isolationSessionAvailable: boolean; hyperlightAvailable: boolean; uiCapabilities: UiCapabilitySupport; diff --git a/docs/api-reference/rust/v1/types.md b/docs/api-reference/rust/v1/types.md index 71dfe4ee3..56dafe324 100644 --- a/docs/api-reference/rust/v1/types.md +++ b/docs/api-reference/rust/v1/types.md @@ -31,6 +31,7 @@ pub enum BackendCapability { FilesystemEnumeratePaths, IngressHostLoopbackAllow, ProxyEnforcement, + IdentitylessLoopbackProxy, } ``` @@ -535,6 +536,7 @@ pub struct ProbeFacts { pub base_container_supports_deny_paths: bool, pub base_container_supports_enumerate_paths: bool, pub base_container_supports_ingress_host_loopback_allow: bool, + pub base_container_supports_identityless_loopback_proxy: bool, pub isolation_session_available: bool, pub hyperlight_available: bool, pub ui_capabilities: UiCapabilitySupport, diff --git a/docs/backends/process-container/networking.md b/docs/backends/process-container/networking.md index 2bbc2a153..0c399440f 100644 --- a/docs/backends/process-container/networking.md +++ b/docs/backends/process-container/networking.md @@ -116,11 +116,36 @@ 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, and host-loopback allow retains the PSEC 1.1 host requirement. -Requests that do not allow host loopback use the -PSEC 1.0 capability mapping; `hostLoopback: "allow"` is rejected when the PSEC 1.1 ingress contract is unavailable. +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 + +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. + +This does not grant general `ingressHostLoopbackAllow` support or report that the +current request uses an identity-less proxy. The request must set +`runtimeConfig.networkProxy`, omit `processContainer.network.allowedProxyPeer`, +and explicitly allow host loopback, while retaining the Model 2 network policy. +Other requested features still require their own host support. +Use the request-aware probe for the complete policy. Neither probe launches the +workload or checks proxy reachability. + **Unresolved ingress limitation:** on Windows build 26691.1002, this proxy mapping permits unprivileged connections to the configured proxy and blocks direct loopback bypasses, but host-to-container listener connections still time out. The same listener succeeds under a non-proxy PSEC ingress policy. The proxy mapping is therefore not yet a diff --git a/docs/examples.md b/docs/examples.md index 00c1aee31..63c34d428 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -135,9 +135,11 @@ 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 requires native PSEC 1.1 ingress/host-loopback support; unsupported -hosts reject the request. For production ProcessContainer deployments, identify -a packaged proxy through `processContainer.network.allowedProxyPeer` instead; see +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 +`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 their supported ingress policies differ. See their backend guides. diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.AotSmokeTest/Program.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.AotSmokeTest/Program.cs index 5fe7cfd67..4c8ffeb2a 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.AotSmokeTest/Program.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.AotSmokeTest/Program.cs @@ -337,7 +337,8 @@ static void CheckRoundTrip(T value, Func check, string name) "bfsCompiledIn": false, "baseContainerSupportsDenyPaths": true, "baseContainerSupportsEnumeratePaths": false, - "baseContainerSupportsIngressHostLoopbackAllow": true, + "baseContainerSupportsIngressHostLoopbackAllow": false, + "baseContainerSupportsIdentitylessLoopbackProxy": true, "isolationSessionAvailable": true, "hyperlightAvailable": false, "uiCapabilities": { @@ -359,6 +360,14 @@ static void CheckRoundTrip(T value, Func check, string name) Check(probe.Tier == IsolationTier.AppContainerDacl, "probe tier parsed"); Check(probe.NeedsDaclAugmentation == true, "probe dacl augmentation parsed"); Check(probe.Probes.BaseContainerApiPresent, "probe facts parsed"); +Check(probe.Probes.BaseContainerSupportsIdentitylessLoopbackProxy, "identity-less loopback proxy parsed"); Check(probe.Probes.UiCapabilities.CanBlockClipboardRead, "probe ui capabilities parsed"); +var nativeProbe = MxcJson.Deserialize(probeJson, MxcJson.ProbeOptions)!; +CheckRoundTrip(nativeProbe.Probes!, + x => x.BaseContainerSupportsIdentitylessLoopbackProxy, "identity-less loopback proxy facts"); +var discovery = MxcPlatform.ParseAvailableBackends( + """[{"backend":"processcontainer","tier":"base-container","capabilities":["identitylessLoopbackProxy"]}]"""); +Check(discovery.Single().Capabilities.SequenceEqual([BackendCapability.IdentitylessLoopbackProxy]), + "identity-less loopback proxy discovery"); Console.WriteLine("AOT smoke test passed: all JSON paths are reflection-free."); diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcContainerTests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcContainerTests.cs index 01fafa729..19587b073 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcContainerTests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcContainerTests.cs @@ -30,6 +30,7 @@ public class MxcContainerTests "baseContainerSupportsDenyPaths": true, "baseContainerSupportsEnumeratePaths": false, "baseContainerSupportsIngressHostLoopbackAllow": true, + "baseContainerSupportsIdentitylessLoopbackProxy": true, "isolationSessionAvailable": true, "hyperlightAvailable": false, "uiCapabilities": { @@ -272,6 +273,41 @@ public void ProbeParser_RejectsMissingRequiredProbeBoolean() json["probes"]!.AsObject().Remove("bfscfgPresent")); } + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(true, true)] + public void ProbeParser_PreservesIdentitylessLoopbackProxy(bool supported, bool ingressSupported) + { + var json = CreateCompleteProbeJson(); + json["tier"] = "base-container"; + json["needsDaclAugmentation"] = false; + json["probes"]!["baseContainerSupportsIngressHostLoopbackAllow"] = ingressSupported; + json["probes"]!["baseContainerSupportsIdentitylessLoopbackProxy"] = supported; + + var output = MxcContainer.ParseProbeOutput(json.ToJsonString()); + + Assert.Equal(supported, output.Probes.BaseContainerSupportsIdentitylessLoopbackProxy); + Assert.Equal(ingressSupported, output.Probes.BaseContainerSupportsIngressHostLoopbackAllow); + } + + [Fact] + public void ProbeParser_RejectsMissingIdentitylessLoopbackProxy() + { + AssertProbeJsonRejected(json => + json["probes"]!.AsObject().Remove("baseContainerSupportsIdentitylessLoopbackProxy")); + } + + [Theory] + [InlineData("null")] + [InlineData("\"true\"")] + [InlineData("1")] + public void ProbeParser_RejectsInvalidIdentitylessLoopbackProxy(string value) + { + AssertProbeJsonRejected(json => + json["probes"]!["baseContainerSupportsIdentitylessLoopbackProxy"] = JsonNode.Parse(value)); + } + [Fact] public void ProbeParser_RejectsMissingRequiredUiBoolean() { @@ -456,6 +492,7 @@ public void Discovery_MapsEveryNativeIsolationTier( [InlineData("filesystemEnumeratePaths", BackendCapability.FilesystemEnumeratePaths)] [InlineData("ingressHostLoopbackAllow", BackendCapability.IngressHostLoopbackAllow)] [InlineData("proxyEnforcement", BackendCapability.ProxyEnforcement)] + [InlineData("identitylessLoopbackProxy", BackendCapability.IdentitylessLoopbackProxy)] public void Discovery_MapsEveryNativeCapability( string wireName, BackendCapability expected) @@ -472,6 +509,7 @@ public void BackendCapability_PreservesReleasedNumericValues() Assert.Equal(3, (int)BackendCapability.FilesystemDeniedPaths); Assert.Equal(4, (int)BackendCapability.IngressHostLoopbackAllow); Assert.Equal(5, (int)BackendCapability.FilesystemEnumeratePaths); + Assert.Equal(6, (int)BackendCapability.IdentitylessLoopbackProxy); } [Theory] diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcJsonSerialization.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcJsonSerialization.cs index beb2b5147..d6eb9acf0 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/MxcJsonSerialization.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/MxcJsonSerialization.cs @@ -82,6 +82,7 @@ public CamelCaseJsonStringEnumConverter() [JsonSerializable(typeof(NativeAvailableBackend[]))] [JsonSerializable(typeof(NativePlatformSupport))] [JsonSerializable(typeof(NativeProbeOutput))] +[JsonSerializable(typeof(NativeProbeFacts))] [JsonSerializable(typeof(ExecutionMetadata))] [JsonSerializable(typeof(CaptureDenialsResult))] [JsonSerializable(typeof(CaptureDenialsError))] diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcContainer.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcContainer.cs index 07b37d411..611da275f 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcContainer.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcContainer.cs @@ -177,6 +177,8 @@ internal static ProbeOutput ParseProbeOutput(string json) probes.BaseContainerSupportsEnumeratePaths, BaseContainerSupportsIngressHostLoopbackAllow = probes.BaseContainerSupportsIngressHostLoopbackAllow, + BaseContainerSupportsIdentitylessLoopbackProxy = + probes.BaseContainerSupportsIdentitylessLoopbackProxy, IsolationSessionAvailable = probes.IsolationSessionAvailable, HyperlightAvailable = probes.HyperlightAvailable, UiCapabilities = new UiCapabilitySupport diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcPlatform.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcPlatform.cs index b61bb9885..53db9685d 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcPlatform.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcPlatform.cs @@ -118,6 +118,7 @@ internal static BackendCapability ParseBackendCapability(string value) => "filesystemEnumeratePaths" => BackendCapability.FilesystemEnumeratePaths, "ingressHostLoopbackAllow" => BackendCapability.IngressHostLoopbackAllow, "proxyEnforcement" => BackendCapability.ProxyEnforcement, + "identitylessLoopbackProxy" => BackendCapability.IdentitylessLoopbackProxy, _ => BackendCapability.Unknown, }; diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/PlatformDiscovery.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/PlatformDiscovery.cs index 640d81657..b7eed9cfc 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/PlatformDiscovery.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/PlatformDiscovery.cs @@ -78,6 +78,12 @@ public enum BackendCapability /// Native filesystem enumeration-only access at the reported tier. /// FilesystemEnumeratePaths = 5, + + /// + /// Identity-less proxy support on loopback. + /// Requires explicit host-loopback allow; not general ingress support. + /// + IdentitylessLoopbackProxy = 6, } /// One host-available backend and its probed capabilities. @@ -146,6 +152,8 @@ public sealed class ProbeFacts public bool BaseContainerSupportsDenyPaths { get; init; } public bool BaseContainerSupportsEnumeratePaths { get; init; } public bool BaseContainerSupportsIngressHostLoopbackAllow { get; init; } + /// Identity-less proxy support on loopback; requires explicit host-loopback allow. + public bool BaseContainerSupportsIdentitylessLoopbackProxy { get; init; } public bool IsolationSessionAvailable { get; init; } public bool HyperlightAvailable { get; init; } public required UiCapabilitySupport UiCapabilities { get; init; } @@ -279,6 +287,10 @@ internal sealed class NativeProbeFacts [JsonPropertyName("baseContainerSupportsIngressHostLoopbackAllow")] public bool BaseContainerSupportsIngressHostLoopbackAllow { get; init; } + [JsonRequired] + [JsonPropertyName("baseContainerSupportsIdentitylessLoopbackProxy")] + public bool BaseContainerSupportsIdentitylessLoopbackProxy { get; init; } + [JsonRequired] [JsonPropertyName("isolationSessionAvailable")] public bool IsolationSessionAvailable { get; init; } diff --git a/sdk/node/src/v1/platform.ts b/sdk/node/src/v1/platform.ts index 00687f4b6..b728b6024 100644 --- a/sdk/node/src/v1/platform.ts +++ b/sdk/node/src/v1/platform.ts @@ -26,6 +26,7 @@ const discoveryBackends: readonly ContainmentBackend[] = [ const discoveryCapabilities: readonly BackendCapability[] = [ 'captureDenials', 'filesystemDeniedPaths', 'filesystemEnumeratePaths', 'ingressHostLoopbackAllow', 'proxyEnforcement', + 'identitylessLoopbackProxy', ]; const discoveryTiers: readonly IsolationTier[] = [ 'base-container', 'appcontainer-bfs', 'appcontainer-dacl', diff --git a/sdk/node/src/v1/probe.ts b/sdk/node/src/v1/probe.ts index b6b7b9a34..e16a603e2 100644 --- a/sdk/node/src/v1/probe.ts +++ b/sdk/node/src/v1/probe.ts @@ -31,6 +31,7 @@ const probeFactBooleanFields: readonly (keyof ProbeFacts)[] = [ 'baseContainerSupportsDenyPaths', 'baseContainerSupportsEnumeratePaths', 'baseContainerSupportsIngressHostLoopbackAllow', + 'baseContainerSupportsIdentitylessLoopbackProxy', 'isolationSessionAvailable', 'hyperlightAvailable', ]; diff --git a/sdk/node/src/v1/types.ts b/sdk/node/src/v1/types.ts index 53e137c21..d8ba22dc6 100644 --- a/sdk/node/src/v1/types.ts +++ b/sdk/node/src/v1/types.ts @@ -711,6 +711,7 @@ export type BackendCapability = | 'filesystemEnumeratePaths' | 'ingressHostLoopbackAllow' | 'proxyEnforcement' + | 'identitylessLoopbackProxy' | 'unknown'; /** One host-available backend and its native capabilities. */ @@ -784,6 +785,8 @@ export interface ProbeFacts { baseContainerSupportsDenyPaths: boolean; baseContainerSupportsEnumeratePaths: boolean; baseContainerSupportsIngressHostLoopbackAllow: boolean; + /** Identity-less proxy support on loopback; requires explicit host-loopback allow. */ + baseContainerSupportsIdentitylessLoopbackProxy: boolean; /** True when this executor includes IsolationSession and the host can activate it. */ isolationSessionAvailable: boolean; /** True when this executor includes Hyperlight and its host runtime is available. */ diff --git a/sdk/node/tests/unit/platform.test.ts b/sdk/node/tests/unit/platform.test.ts index f43ad1440..b3375fa53 100644 --- a/sdk/node/tests/unit/platform.test.ts +++ b/sdk/node/tests/unit/platform.test.ts @@ -71,6 +71,19 @@ describe('native backend discovery', () => { }]); }); + it('reports identity-less loopback proxy support without general ingress support', () => { + assert.deepStrictEqual(parseAvailableBackends(JSON.stringify([{ + backend: 'processcontainer', + tier: 'base-container', + capabilities: ['identitylessLoopbackProxy'], + }])), [{ + backend: 'processcontainer', + tier: 'base-container', + capabilities: ['identitylessLoopbackProxy'], + warnings: [], + }]); + }); + it('rejects malformed discovery results instead of returning no backends', () => { for (const payload of [ null, {}, [null], [[]], [{}], [{ backend: 1 }], diff --git a/sdk/node/tests/unit/probe.test.ts b/sdk/node/tests/unit/probe.test.ts index ba1d0b856..ddddd93ee 100644 --- a/sdk/node/tests/unit/probe.test.ts +++ b/sdk/node/tests/unit/probe.test.ts @@ -30,6 +30,7 @@ const completeProbe = { baseContainerSupportsDenyPaths: false, baseContainerSupportsEnumeratePaths: false, baseContainerSupportsIngressHostLoopbackAllow: false, + baseContainerSupportsIdentitylessLoopbackProxy: false, isolationSessionAvailable: false, hyperlightAvailable: false, uiCapabilities: { @@ -192,6 +193,35 @@ describe('probe', () => { assert.equal(output.needsDaclAugmentation, true); }); + it('preserves identity-less proxy support with or without general ingress', () => { + for (const [supported, ingressSupported] of [[false, false], [true, false], [true, true]]) { + const output = runProbeOutput({ + ...completeProbe, + tier: 'base-container', + needsDaclAugmentation: false, + probes: { + ...completeProbe.probes, + baseContainerSupportsIdentitylessLoopbackProxy: supported, + baseContainerSupportsIngressHostLoopbackAllow: ingressSupported, + }, + }); + assert.equal(output.probes.baseContainerSupportsIdentitylessLoopbackProxy, supported); + assert.equal(output.probes.baseContainerSupportsIngressHostLoopbackAllow, ingressSupported); + } + }); + + it('rejects missing or invalid identity-less loopback proxy facts', () => { + for (const value of [undefined, null, 'true', 1]) { + assertProbeOutputRejected({ + ...completeProbe, + probes: { + ...completeProbe.probes, + baseContainerSupportsIdentitylessLoopbackProxy: value, + }, + }); + } + }); + it('rejects an unknown envelope field', () => { const payload = cloneCompleteProbe(); payload.unknownEnvelopeField = true; diff --git a/src/ffi/mxc_ffi/tests/ffi.rs b/src/ffi/mxc_ffi/tests/ffi.rs index 7a96aa1c7..d7c5525e0 100644 --- a/src/ffi/mxc_ffi/tests/ffi.rs +++ b/src/ffi/mxc_ffi/tests/ffi.rs @@ -78,6 +78,7 @@ fn extern_discovery_returns_owned_json() { | "filesystemEnumeratePaths" | "ingressHostLoopbackAllow" | "proxyEnforcement" + | "identitylessLoopbackProxy" ) )); } 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 0556e58f7..56e565657 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 @@ -14,7 +14,7 @@ use std::ptr; use std::sync::Arc; use crate::learning_mode_core::DenialAnalyzer; -use crate::learning_mode_windows::{EtlDenialAnalyzer, LEARNING_MODE_API_SET}; +use crate::learning_mode_windows::{EtlDenialAnalyzer, LearningModeError, LEARNING_MODE_API_SET}; use windows::Win32::Foundation::{ CloseHandle, GetLastError, SetHandleInformation, ERROR_CALL_NOT_IMPLEMENTED, E_NOTIMPL, HANDLE, HANDLE_FLAG_INHERIT, WAIT_OBJECT_0, WAIT_TIMEOUT, @@ -260,7 +260,7 @@ impl BaseContainerRunner { #[cfg(test)] 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, false); build_psec_v1_security_environment_spec( request, version, @@ -328,10 +328,61 @@ impl BaseContainerRunner { && secenv::query_support(SecurityEnvironmentSupport::NetworkIngress) } + /// Whether this host supports an identity-less proxy on loopback. + /// This is not general host-loopback ingress support. + pub(crate) fn supports_identityless_loopback_proxy() -> Result { + 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) + } + /// 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() { @@ -341,7 +392,7 @@ impl BaseContainerRunner { version = version.max(SecurityEnvironmentSupport::FileSystemEnumerate.required_version()); } - if unrestricted_host_loopback_allowed(&request.policy) { + if unrestricted_host_loopback_allowed(&request.policy) && !proxy_loopback_workaround { version = version.max(SecurityEnvironmentSupport::NetworkIngress.required_version()); } version @@ -385,6 +436,10 @@ impl BaseContainerRunner { } 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; } @@ -417,7 +472,17 @@ impl BaseContainerRunner { logger, ); - let psec_version = Self::choose_min_required_psec_version_for_request(request); + 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 version_supported = secenv::supports_version(psec_version).map_err(|error| ScriptResponse { failure_phase: FailurePhase::BackendUnavailable, @@ -2853,7 +2918,10 @@ mod tests { #[test] fn required_psec_contract_uses_v1_1_for_enumeration() { assert_eq!( - BaseContainerRunner::choose_min_required_psec_version_for_request(&enumerate_request()), + BaseContainerRunner::choose_min_required_psec_version_for_request( + &enumerate_request(), + false + ), SecurityEnvironmentVersion::V1_1 ); } @@ -2862,7 +2930,8 @@ 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() + &host_loopback_request(), + false ), SecurityEnvironmentVersion::V1_1 ); @@ -2872,12 +2941,207 @@ mod tests { fn required_psec_contract_defaults_to_v1_0() { assert_eq!( BaseContainerRunner::choose_min_required_psec_version_for_request( - &ExecutionRequest::default() + &ExecutionRequest::default(), + false ), SecurityEnvironmentVersion::V1_0 ); } + fn psec_1_0_proxy_request() -> ExecutionRequest { + let mut request = host_loopback_request(); + request.policy.runtime_network_proxy_specified = true; + request.policy.network_proxy = ProxyConfig { + address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), + builtin_test_server: false, + }; + request + } + + #[test] + fn identityless_loopback_proxy_requires_base_container_api() { + let _guard = crate::process_container_common::test_env::BcUsableGuard::set(false); + 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); + 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" 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 7cdc519eb..a6039ffcb 100644 --- a/src/mxc-sdk/src/backends/process_container/common/probe.rs +++ b/src/mxc-sdk/src/backends/process_container/common/probe.rs @@ -74,6 +74,9 @@ pub struct ProbeFacts { /// Whether BaseContainer can honor /// `network.ingress.hostLoopback = "allow"`. 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. + pub base_container_supports_identityless_loopback_proxy: bool, /// Whether the in-proc IsolationSession service can be activated on this /// host. Always `false` here — `process_container_common` has no dependency on /// the isolation-session backend; `wxc-exec --probe` overrides it when @@ -137,6 +140,7 @@ impl From for UiCapabilitySupport { pub fn run_probe(request: &ExecutionRequest, guarded_capture_available: bool) -> ProbeOutput { use crate::process_container_common::base_container_runner::BaseContainerRunner; + let identityless_loopback_proxy = BaseContainerRunner::supports_identityless_loopback_proxy(); let probes = ProbeFacts { base_container_api_present: BaseContainerRunner::is_base_container_api_present(), native_capture_available: BaseContainerRunner::is_native_capture_available(), @@ -150,17 +154,27 @@ pub fn run_probe(request: &ExecutionRequest, guarded_capture_available: bool) -> base_container_supports_enumerate_paths: BaseContainerRunner::supports_enumerate_paths(), base_container_supports_ingress_host_loopback_allow: BaseContainerRunner::supports_ingress_host_loopback_allow(), + base_container_supports_identityless_loopback_proxy: matches!( + identityless_loopback_proxy, + Ok(true) + ), isolation_session_available: false, hyperlight_available: false, ui_capabilities: crate::process_container_common::job_object::supported_ui_restrictions() .into(), }; - run_probe_with_tier_decision( + let mut output = run_probe_with_tier_decision( request, probes, fallback_detector::choose_backend_tier(request), - ) + ); + if let Err(error) = identityless_loopback_proxy { + output.warnings.push(format!( + "failed to query identity-less loopback proxy support: {error}" + )); + } + output } fn run_probe_with_tier_decision( @@ -269,6 +283,7 @@ mod tests { base_container_supports_deny_paths: false, base_container_supports_enumerate_paths: false, base_container_supports_ingress_host_loopback_allow: false, + base_container_supports_identityless_loopback_proxy: false, isolation_session_available: false, hyperlight_available: false, ui_capabilities: all_ui_capabilities(), @@ -290,6 +305,7 @@ mod tests { base_container_supports_deny_paths: false, base_container_supports_enumerate_paths: false, base_container_supports_ingress_host_loopback_allow: false, + base_container_supports_identityless_loopback_proxy: true, isolation_session_available: true, hyperlight_available: false, ui_capabilities: all_ui_capabilities(), @@ -312,6 +328,10 @@ mod tests { v["probes"]["baseContainerSupportsIngressHostLoopbackAllow"], false ); + assert_eq!( + v["probes"]["baseContainerSupportsIdentitylessLoopbackProxy"], + true + ); assert_eq!(v["probes"]["isolationSessionAvailable"], true); assert_eq!(v["probes"]["uiCapabilities"]["canBlockClipboardRead"], true); assert_eq!( @@ -340,6 +360,7 @@ mod tests { base_container_supports_deny_paths: false, base_container_supports_enumerate_paths: false, base_container_supports_ingress_host_loopback_allow: false, + base_container_supports_identityless_loopback_proxy: false, isolation_session_available: false, hyperlight_available: false, ui_capabilities: UiCapabilitySupport { diff --git a/src/mxc-sdk/src/core/mxc_engine/probe.rs b/src/mxc-sdk/src/core/mxc_engine/probe.rs index 96c983aa2..86ee60b36 100644 --- a/src/mxc-sdk/src/core/mxc_engine/probe.rs +++ b/src/mxc-sdk/src/core/mxc_engine/probe.rs @@ -30,6 +30,9 @@ pub enum BackendCapability { IngressHostLoopbackAllow, /// Bubblewrap proxy-only egress in a private network namespace. ProxyEnforcement, + /// Identity-less proxy support on loopback. + /// Requires explicit host-loopback allow; not general ingress support. + IdentitylessLoopbackProxy, } /// One host-available backend, plus its effective isolation tier (if any). @@ -96,18 +99,24 @@ pub fn available_backends() -> Vec { BaseContainerRunner::is_base_container_api_present(), cfg!(feature = "tier2_bfs"), ); - windows_backends( - tier, - ProcessContainerCapabilities { - capture_denials: capture_denials_available( - crate::process_container_common::base_container_runner::BaseContainerRunner::is_native_capture_available(), - guarded_capture::is_available(), - ), - filesystem_denied_paths: crate::process_container_common::base_container_runner::BaseContainerRunner::supports_native_denied_paths(), - filesystem_enumerate_paths: crate::process_container_common::base_container_runner::BaseContainerRunner::supports_enumerate_paths(), - ingress_host_loopback_allow: crate::process_container_common::base_container_runner::BaseContainerRunner::supports_ingress_host_loopback_allow(), - }, - ) + let mut support = ProcessContainerCapabilities { + capture_denials: capture_denials_available( + BaseContainerRunner::is_native_capture_available(), + guarded_capture::is_available(), + ), + filesystem_denied_paths: BaseContainerRunner::supports_native_denied_paths(), + filesystem_enumerate_paths: BaseContainerRunner::supports_enumerate_paths(), + ingress_host_loopback_allow: BaseContainerRunner::supports_ingress_host_loopback_allow( + ), + ..Default::default() + }; + match BaseContainerRunner::supports_identityless_loopback_proxy() { + Ok(supported) => support.identityless_loopback_proxy = supported, + Err(error) => support.warnings.push(format!( + "failed to query identity-less loopback proxy support: {error}" + )), + } + windows_backends(tier, support) } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] { @@ -165,12 +174,14 @@ fn bubblewrap_backend(proxy_enforcement: Result<(), String>) -> AvailableBackend } #[cfg(target_os = "windows")] -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Default)] struct ProcessContainerCapabilities { capture_denials: bool, filesystem_denied_paths: bool, filesystem_enumerate_paths: bool, ingress_host_loopback_allow: bool, + identityless_loopback_proxy: bool, + warnings: Vec, } #[cfg(target_os = "windows")] @@ -199,12 +210,15 @@ fn windows_backends( if support.ingress_host_loopback_allow { capabilities.push(BackendCapability::IngressHostLoopbackAllow); } + if support.identityless_loopback_proxy { + capabilities.push(BackendCapability::IdentitylessLoopbackProxy); + } } let process_container = AvailableBackend { backend: ContainmentBackend::ProcessContainer.wire_name().to_string(), tier: Some(tier.as_str().to_string()), capabilities, - warnings: Vec::new(), + warnings: support.warnings, }; let mut backends = vec![process_container]; @@ -397,6 +411,11 @@ mod tests { .expect("serializes"), r#""ingressHostLoopbackAllow""# ); + assert_eq!( + serde_json::to_string(&BackendCapability::IdentitylessLoopbackProxy) + .expect("serializes"), + r#""identitylessLoopbackProxy""# + ); } #[test] @@ -531,6 +550,7 @@ mod tests { filesystem_denied_paths: true, filesystem_enumerate_paths: true, ingress_host_loopback_allow: true, + identityless_loopback_proxy: true, ..Default::default() }, ); @@ -543,6 +563,55 @@ mod tests { } } + #[cfg(target_os = "windows")] + #[test] + fn windows_reports_identityless_proxy_with_or_without_general_ingress_support() { + use crate::process_container_common::fallback_detector::IsolationTier; + + for (supported, ingress_supported) in [(false, false), (true, false), (true, true)] { + let backends = windows_backends( + IsolationTier::BaseContainer, + ProcessContainerCapabilities { + identityless_loopback_proxy: supported, + ingress_host_loopback_allow: ingress_supported, + ..Default::default() + }, + ); + let process_container = &backends[0]; + assert_eq!( + process_container + .capabilities + .contains(&BackendCapability::IdentitylessLoopbackProxy), + supported + ); + assert_eq!( + process_container + .capabilities + .contains(&BackendCapability::IngressHostLoopbackAllow), + ingress_supported + ); + } + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_preserves_capability_probe_warnings() { + use crate::process_container_common::fallback_detector::IsolationTier; + + let warning = "failed to query identity-less loopback proxy support".to_string(); + let backends = windows_backends( + IsolationTier::BaseContainer, + ProcessContainerCapabilities { + warnings: vec![warning.clone()], + ..Default::default() + }, + ); + assert_eq!(backends[0].warnings, vec![warning]); + assert!(!backends[0] + .capabilities + .contains(&BackendCapability::IdentitylessLoopbackProxy)); + } + #[cfg(target_os = "windows")] #[test] fn tier_precedence_prefers_the_strongest_reachable_rung() { diff --git a/tests/scripts/README.md b/tests/scripts/README.md index d8ad79e3f..c0e2d04a0 100644 --- a/tests/scripts/README.md +++ b/tests/scripts/README.md @@ -60,6 +60,13 @@ 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 +PSEC 1.0-only hosts and hosts with PSEC 1.1 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. + 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 151a426b7..28dfab954 100644 --- a/tests/scripts/lib/WinProcessContainer.Common.ps1 +++ b/tests/scripts/lib/WinProcessContainer.Common.ps1 @@ -351,6 +351,9 @@ function Get-HostCapabilities { $enumBit = if ($p.probes.PSObject.Properties['baseContainerSupportsEnumeratePaths']) { [bool]$p.probes.baseContainerSupportsEnumeratePaths } else { $false } + $identitylessProxyBit = if ($p.probes.PSObject.Properties['baseContainerSupportsIdentitylessLoopbackProxy']) { + [bool]$p.probes.baseContainerSupportsIdentitylessLoopbackProxy + } else { $false } # uiCapabilities is absent on older binaries / when the detector errored. $canInject = $false if ($p.probes.PSObject.Properties['uiCapabilities'] -and @@ -387,6 +390,7 @@ function Get-HostCapabilities { # denied tests auto-enable when it ships. SupportsDeniedPaths = (($tier -eq 'appcontainer-dacl') -or $denyBit) BaseContainerSupportsEnumeratePaths = $enumBit + BaseContainerSupportsIdentitylessLoopbackProxy = $identitylessProxyBit # enumeratePaths has NO fallback: it needs PSEC 1.1 plus # PSE_SUPPORT_FS_ENUMERATE, and the detector refuses the request # outright on every AppContainer tier (FallbackError:: diff --git a/tests/scripts/run_processcontainer_network_proxy_test.ps1 b/tests/scripts/run_processcontainer_network_proxy_test.ps1 index 2ea433c52..1de6acdb1 100644 --- a/tests/scripts/run_processcontainer_network_proxy_test.ps1 +++ b/tests/scripts/run_processcontainer_network_proxy_test.ps1 @@ -87,16 +87,20 @@ function Invoke-NetworkProxyAssertions { if ($psec) { $out = $envRun.Result.Stdout $ran = [bool]($out -match '(?im)^SystemRoot=') - # networking.md: hostLoopback=allow needs the PSEC 1.1 ingress contract - # and "is rejected when the PSEC 1.1 ingress contract is unavailable". - # Only two outcomes are documented, so a bare OS error is a failure even - # on a 1.0-only host -- the caller cannot act on E_INVALIDARG. + $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. $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) ` -Detail ("ran=$ran; rejectedAtValidation=$rejectedCleanly; exit=$($envRun.Result.ExitCode); " + "stderr=$(Format-Snippet $envRun.Result.Stderr)") - if ($ran) { + if ($identitylessProxyAvailable) { + Record-Result -Phase 'P8e' -Name 'identity-less loopback proxy-capable host launches the workload' ` + -Pass $ran -Detail "exit=$($envRun.Result.ExitCode); ran=$ran" + } + if ($ran -or $identitylessProxyAvailable) { foreach ($v in @('HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy')) { # cmd.exe `set` upper-cases nothing, but Windows env lookup is # case-insensitive and duplicate-insensitive, so a variable set