summaryrefslogtreecommitdiff
path: root/src/Agent.Windows/Network
diff options
context:
space:
mode:
Diffstat (limited to 'src/Agent.Windows/Network')
-rw-r--r--src/Agent.Windows/Network/LoopbackExemption.cs123
-rw-r--r--src/Agent.Windows/Network/TaskNetworkPolicy.cs73
-rw-r--r--src/Agent.Windows/Network/WfpDynamicSession.cs208
3 files changed, 404 insertions, 0 deletions
diff --git a/src/Agent.Windows/Network/LoopbackExemption.cs b/src/Agent.Windows/Network/LoopbackExemption.cs
new file mode 100644
index 0000000..a3c68ba
--- /dev/null
+++ b/src/Agent.Windows/Network/LoopbackExemption.cs
@@ -0,0 +1,123 @@
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using System.Security.Principal;
+using Windows.Win32;
+using Windows.Win32.Foundation;
+using Windows.Win32.Security;
+using Windows.Win32.System.Memory;
+
+namespace Agent.Windows.Network;
+
+internal static unsafe class LoopbackExemption
+{
+ private const string MutexName = "Local\\AgentSandboxMcpLoopbackExemption";
+
+ public static bool Contains(SecurityIdentifier sid)
+ {
+ using MutexLease mutex = AcquireMutex();
+ return Read().Any(item => item.Sid.Equals(sid));
+ }
+
+ public static bool Ensure(SecurityIdentifier sid)
+ {
+ using MutexLease mutex = AcquireMutex();
+ List<Entry> entries = Read();
+ if (entries.Any(item => item.Sid.Equals(sid))) return false;
+ entries.Add(new(sid, 0));
+ Write(entries);
+ return true;
+ }
+
+ public static void Remove(SecurityIdentifier sid)
+ {
+ using MutexLease mutex = AcquireMutex();
+ List<Entry> entries = Read();
+ if (entries.RemoveAll(item => item.Sid.Equals(sid)) == 0) return;
+ Write(entries);
+ }
+
+ private static MutexLease AcquireMutex()
+ {
+ var mutex = new Mutex(false, MutexName);
+ try
+ {
+ try
+ {
+ if (!mutex.WaitOne(TimeSpan.FromSeconds(30))) throw new TimeoutException("Timed out waiting for the loopback exemption lock.");
+ }
+ catch (AbandonedMutexException)
+ {
+ // Ownership is granted to this thread when the prior owner died.
+ }
+ return new(mutex);
+ }
+ catch
+ {
+ mutex.Dispose();
+ throw;
+ }
+ }
+
+ private static List<Entry> Read()
+ {
+ uint result = PInvoke.NetworkIsolationGetAppContainerConfig(out uint count, out SID_AND_ATTRIBUTES* native);
+ ThrowIfError(result, "NetworkIsolationGetAppContainerConfig");
+ try
+ {
+ var entries = new List<Entry>(checked((int)count));
+ for (uint index = 0; index < count; index++)
+ {
+ SID_AND_ATTRIBUTES item = native[index];
+ entries.Add(new(new SecurityIdentifier((nint)item.Sid.Value), item.Attributes));
+ }
+ return entries;
+ }
+ finally
+ {
+ HANDLE heap = PInvoke.GetProcessHeap();
+ for (uint index = 0; index < count; index++)
+ if (native[index].Sid.Value is not null) _ = PInvoke.HeapFree(heap, HEAP_FLAGS.HEAP_NONE, native[index].Sid.Value);
+ if (native is not null) _ = PInvoke.HeapFree(heap, HEAP_FLAGS.HEAP_NONE, native);
+ }
+ }
+
+ private static void Write(IReadOnlyList<Entry> entries)
+ {
+ var native = new SID_AND_ATTRIBUTES[entries.Count];
+ var allocations = new List<nint>(entries.Count);
+ try
+ {
+ for (int index = 0; index < entries.Count; index++)
+ {
+ byte[] bytes = new byte[entries[index].Sid.BinaryLength];
+ entries[index].Sid.GetBinaryForm(bytes, 0);
+ void* allocation = NativeMemory.Alloc((nuint)bytes.Length);
+ Marshal.Copy(bytes, 0, (nint)allocation, bytes.Length);
+ allocations.Add((nint)allocation);
+ native[index].Sid = new PSID(allocation);
+ native[index].Attributes = entries[index].Attributes;
+ }
+ ThrowIfError(PInvoke.NetworkIsolationSetAppContainerConfig(native), "NetworkIsolationSetAppContainerConfig");
+ }
+ finally
+ {
+ foreach (nint allocation in allocations) NativeMemory.Free((void*)allocation);
+ }
+ }
+
+ private static void ThrowIfError(uint result, string operation)
+ {
+ if (result != 0) throw new Win32Exception(unchecked((int)result), $"{operation} failed with Win32 error 0x{result:X8}.");
+ }
+
+ private sealed record Entry(SecurityIdentifier Sid, uint Attributes);
+
+ private sealed class MutexLease(Mutex mutex) : IDisposable
+ {
+ public void Dispose()
+ {
+ mutex.ReleaseMutex();
+ mutex.Dispose();
+ }
+ }
+}
diff --git a/src/Agent.Windows/Network/TaskNetworkPolicy.cs b/src/Agent.Windows/Network/TaskNetworkPolicy.cs
new file mode 100644
index 0000000..59b2602
--- /dev/null
+++ b/src/Agent.Windows/Network/TaskNetworkPolicy.cs
@@ -0,0 +1,73 @@
+using System.Security.Principal;
+using Agent.Windows.Recovery;
+using Agent.Windows.Security;
+
+namespace Agent.Windows.Network;
+
+public sealed class TaskNetworkPolicy : IDisposable
+{
+ private readonly WfpDynamicSession _session;
+ private readonly TaskResourceJournal _journal;
+ private readonly ISecurityAuditSink _audit;
+ private readonly Guid _entryId;
+ private readonly SecurityIdentifier _appContainerSid;
+ private readonly Guid _loopbackEntryId;
+ private readonly bool _loopbackAdded;
+ private bool _disposed;
+
+ private TaskNetworkPolicy(WfpDynamicSession session, TaskResourceJournal journal, ISecurityAuditSink audit, Guid entryId,
+ SecurityIdentifier appContainerSid, Guid loopbackEntryId, bool loopbackAdded)
+ {
+ _session = session; _journal = journal; _audit = audit; _entryId = entryId;
+ _appContainerSid = appContainerSid; _loopbackEntryId = loopbackEntryId; _loopbackAdded = loopbackAdded;
+ }
+
+ public static TaskNetworkPolicy Apply(TaskResourceJournal journal, SecurityIdentifier appContainerSid, IReadOnlyList<NetworkEndpoint> allowances, ISecurityAuditSink audit)
+ {
+ bool originallyExempt = LoopbackExemption.Contains(appContainerSid);
+ Guid loopbackEntryId = journal.Prepare("network.loopback_exemption", appContainerSid.Value,
+ new Dictionary<string, string> { ["originally_present"] = originallyExempt.ToString() });
+ bool loopbackAdded = false;
+ Guid entryId = journal.Prepare("wfp.dynamic", appContainerSid.Value, new Dictionary<string, string>
+ {
+ ["session"] = "dynamic", ["allowances"] = string.Join(',', allowances.Select(static item => $"{item.Address}:{item.Port}"))
+ });
+ WfpDynamicSession session = WfpDynamicSession.Open();
+ try
+ {
+ loopbackAdded = LoopbackExemption.Ensure(appContainerSid);
+ journal.MarkApplied(loopbackEntryId, new Dictionary<string, string> { ["added"] = loopbackAdded.ToString() });
+ IReadOnlyList<ulong> ids = session.ApplyDefaultBlock(appContainerSid, allowances);
+ journal.MarkApplied(entryId, new Dictionary<string, string> { ["filter_ids"] = string.Join(',', ids) });
+ audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "wfp.apply", "applied", new Dictionary<string, string>
+ {
+ ["ipv4_ipv6_default_block"] = "true", ["filter_count"] = ids.Count.ToString(), ["session"] = "dynamic"
+ }));
+ audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.loopback.apply", loopbackAdded ? "added" : "preserved",
+ new Dictionary<string, string> { ["app_container_sid"] = appContainerSid.Value }));
+ return new(session, journal, audit, entryId, appContainerSid, loopbackEntryId, loopbackAdded);
+ }
+ catch
+ {
+ session.Dispose();
+ journal.MarkCleaned(entryId);
+ if (loopbackAdded) LoopbackExemption.Remove(appContainerSid);
+ journal.MarkCleaned(loopbackEntryId);
+ throw;
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _session.RemovePolicy();
+ _session.Dispose();
+ _journal.MarkCleaned(_entryId);
+ if (_loopbackAdded) LoopbackExemption.Remove(_appContainerSid);
+ _journal.MarkCleaned(_loopbackEntryId);
+ _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "wfp.remove", "removed", new Dictionary<string, string> { ["residual_filters"] = "0" }));
+ _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.loopback.remove", "removed",
+ new Dictionary<string, string> { ["residual_exemptions"] = LoopbackExemption.Contains(_appContainerSid) ? "preexisting" : "0" }));
+ _disposed = true;
+ }
+}
diff --git a/src/Agent.Windows/Network/WfpDynamicSession.cs b/src/Agent.Windows/Network/WfpDynamicSession.cs
new file mode 100644
index 0000000..ef8ba8e
--- /dev/null
+++ b/src/Agent.Windows/Network/WfpDynamicSession.cs
@@ -0,0 +1,208 @@
+using System.ComponentModel;
+using System.Buffers.Binary;
+using System.Net;
+using System.Runtime.InteropServices;
+using System.Security.Principal;
+using Windows.Win32;
+using Windows.Win32.Foundation;
+using Windows.Win32.NetworkManagement.WindowsFilteringPlatform;
+using Windows.Win32.Security;
+
+namespace Agent.Windows.Network;
+
+public sealed record NetworkEndpoint(IPAddress Address, ushort Port);
+
+public static class NetworkAllowanceSnapshot
+{
+ public static async Task<IReadOnlyList<NetworkEndpoint>> ResolveAsync(IEnumerable<(string Host, IReadOnlyList<int> Ports)> allowances, CancellationToken cancellationToken)
+ {
+ var endpoints = new HashSet<NetworkEndpoint>();
+ foreach ((string host, IReadOnlyList<int> ports) in allowances)
+ {
+ IPAddress[] addresses = await Dns.GetHostAddressesAsync(host, cancellationToken).ConfigureAwait(false);
+ foreach (int port in ports)
+ {
+ if (port is < 1 or > 65535) throw new ArgumentOutOfRangeException(nameof(allowances), $"Invalid network port {port}.");
+ foreach (IPAddress address in addresses.Where(static item => item.AddressFamily is System.Net.Sockets.AddressFamily.InterNetwork or System.Net.Sockets.AddressFamily.InterNetworkV6))
+ endpoints.Add(new(address, (ushort)port));
+ }
+ }
+ return endpoints.OrderBy(static item => item.Address.ToString(), StringComparer.Ordinal).ThenBy(static item => item.Port).ToArray();
+ }
+}
+
+public sealed unsafe class WfpDynamicSession : IDisposable
+{
+ private const FWPM_FILTER_FLAGS FwpmFilterFlagClearActionRight = (FWPM_FILTER_FLAGS)0x00000008;
+ private readonly FwpmEngineClose0SafeHandle _engine;
+ private Guid? _subLayerKey;
+ private readonly List<ulong> _filterIds = [];
+
+ private WfpDynamicSession(FwpmEngineClose0SafeHandle engine) => _engine = engine;
+ public static WfpDynamicSession Open(string displayName = "Agent Sandbox MCP dynamic task session")
+ {
+ FWPM_SESSION0 session = default;
+ session.flags = PInvoke.FWPM_SESSION_FLAG_DYNAMIC;
+ uint result = PInvoke.FwpmEngineOpen0(10, null, session, out FwpmEngineClose0SafeHandle engine);
+ ThrowIfError(result, "FwpmEngineOpen0");
+ return new(engine);
+ }
+
+ public void ProbeWriteTransaction()
+ {
+ uint result = PInvoke.FwpmTransactionBegin0(_engine, 0);
+ ThrowIfError(result, "FwpmTransactionBegin0");
+ result = PInvoke.FwpmTransactionAbort0(_engine);
+ ThrowIfError(result, "FwpmTransactionAbort0");
+ }
+
+ public IReadOnlyList<ulong> ApplyDefaultBlock(SecurityIdentifier appContainerSid, IReadOnlyList<NetworkEndpoint>? allowedEndpoints = null)
+ {
+ if (_subLayerKey is not null) throw new InvalidOperationException("WFP policy is already applied in this session.");
+ byte[] sidBytes = new byte[appContainerSid.BinaryLength];
+ appContainerSid.GetBinaryForm(sidBytes, 0);
+ uint result = PInvoke.FwpmTransactionBegin0(_engine, 0);
+ ThrowIfError(result, "FwpmTransactionBegin0");
+ bool committed = false;
+ try
+ {
+ Guid subLayerKey = Guid.NewGuid();
+ fixed (char* subLayerName = "Agent Sandbox MCP task sublayer")
+ {
+ FWPM_SUBLAYER0 subLayer = default;
+ subLayer.subLayerKey = subLayerKey;
+ subLayer.displayData.name = new PWSTR(subLayerName);
+ subLayer.weight = 0x7FFF;
+ ThrowIfError(PInvoke.FwpmSubLayerAdd0(_engine, in subLayer, default), "FwpmSubLayerAdd0");
+ }
+
+ fixed (byte* sidPointer = sidBytes)
+ {
+ foreach (NetworkEndpoint endpoint in allowedEndpoints ?? []) _filterIds.Add(AddPackagePermit(endpoint, subLayerKey, (SID*)sidPointer));
+ _filterIds.Add(AddPackageBlock(PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, subLayerKey, (SID*)sidPointer, "Agent Sandbox MCP IPv4 default block"));
+ _filterIds.Add(AddPackageBlock(PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, subLayerKey, (SID*)sidPointer, "Agent Sandbox MCP IPv6 default block"));
+ }
+ ThrowIfError(PInvoke.FwpmTransactionCommit0(_engine), "FwpmTransactionCommit0");
+ committed = true;
+ _subLayerKey = subLayerKey;
+ return _filterIds.ToArray();
+ }
+ finally
+ {
+ if (!committed)
+ {
+ _filterIds.Clear();
+ _ = PInvoke.FwpmTransactionAbort0(_engine);
+ }
+ }
+ }
+
+ public void RemovePolicy()
+ {
+ if (_subLayerKey is null) return;
+ ThrowIfError(PInvoke.FwpmTransactionBegin0(_engine, 0), "FwpmTransactionBegin0(remove)");
+ bool committed = false;
+ try
+ {
+ foreach (ulong filterId in _filterIds.AsEnumerable().Reverse()) ThrowIfError(PInvoke.FwpmFilterDeleteById0(_engine, filterId), "FwpmFilterDeleteById0");
+ Guid subLayerKey = _subLayerKey.Value;
+ ThrowIfError(PInvoke.FwpmSubLayerDeleteByKey0(_engine, in subLayerKey), "FwpmSubLayerDeleteByKey0");
+ ThrowIfError(PInvoke.FwpmTransactionCommit0(_engine), "FwpmTransactionCommit0(remove)");
+ committed = true;
+ _filterIds.Clear();
+ _subLayerKey = null;
+ }
+ finally
+ {
+ if (!committed) _ = PInvoke.FwpmTransactionAbort0(_engine);
+ }
+ }
+
+ public void Dispose()
+ {
+ _engine.Dispose();
+ }
+
+ private ulong AddPackageBlock(Guid layerKey, Guid subLayerKey, SID* sid, string displayName)
+ {
+ FWPM_FILTER_CONDITION0 condition = default;
+ condition.fieldKey = PInvoke.FWPM_CONDITION_ALE_PACKAGE_ID;
+ condition.matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL;
+ condition.conditionValue.type = FWP_DATA_TYPE.FWP_SID;
+ condition.conditionValue.sid = sid;
+ fixed (char* name = displayName)
+ {
+ FWPM_FILTER0 filter = default;
+ filter.filterKey = Guid.NewGuid();
+ filter.displayData.name = new PWSTR(name);
+ filter.layerKey = layerKey;
+ filter.subLayerKey = subLayerKey;
+ filter.weight.type = FWP_DATA_TYPE.FWP_UINT8;
+ filter.weight.uint8 = 8;
+ filter.numFilterConditions = 1;
+ filter.filterCondition = &condition;
+ filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_BLOCK;
+ ThrowIfError(PInvoke.FwpmFilterAdd0(_engine, in filter, default, out ulong id), "FwpmFilterAdd0");
+ return id;
+ }
+ }
+
+ private ulong AddPackagePermit(NetworkEndpoint endpoint, Guid subLayerKey, SID* sid)
+ {
+ FWPM_FILTER_CONDITION0* conditions = stackalloc FWPM_FILTER_CONDITION0[3];
+ conditions[0].fieldKey = PInvoke.FWPM_CONDITION_ALE_PACKAGE_ID;
+ conditions[0].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL;
+ conditions[0].conditionValue.type = FWP_DATA_TYPE.FWP_SID;
+ conditions[0].conditionValue.sid = sid;
+ conditions[1].fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_ADDRESS;
+ conditions[1].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL;
+ Guid layer;
+ FWP_BYTE_ARRAY16 ipv6 = default;
+ if (endpoint.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
+ {
+ layer = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4;
+ conditions[1].conditionValue.type = FWP_DATA_TYPE.FWP_UINT32;
+ // FWP_UINT32 uses host-order numeric values. For 127.0.0.1 the
+ // numeric value is 0x7F000001; parse the network-order octets into
+ // that value rather than reinterpreting their in-memory byte order.
+ conditions[1].conditionValue.uint32 = BinaryPrimitives.ReadUInt32BigEndian(endpoint.Address.GetAddressBytes());
+ }
+ else
+ {
+ layer = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6;
+ ipv6.byteArray16 = endpoint.Address.GetAddressBytes();
+ conditions[1].conditionValue.type = FWP_DATA_TYPE.FWP_BYTE_ARRAY16_TYPE;
+ conditions[1].conditionValue.byteArray16 = &ipv6;
+ }
+ conditions[2].fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT;
+ conditions[2].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL;
+ conditions[2].conditionValue.type = FWP_DATA_TYPE.FWP_UINT16;
+ conditions[2].conditionValue.uint16 = endpoint.Port;
+ string displayName = $"Agent Sandbox MCP allow {endpoint.Address}:{endpoint.Port}";
+ fixed (char* name = displayName)
+ {
+ FWPM_FILTER0 filter = default;
+ filter.filterKey = Guid.NewGuid();
+ filter.displayData.name = new PWSTR(name);
+ filter.layerKey = layer;
+ filter.subLayerKey = subLayerKey;
+ filter.weight.type = FWP_DATA_TYPE.FWP_UINT8;
+ filter.weight.uint8 = 15;
+ filter.numFilterConditions = 3;
+ filter.filterCondition = conditions;
+ // A plain permit is a soft permit and can be overridden by a block
+ // from another sub-layer (for example Windows Firewall). The exact
+ // task allowance must be a hard permit; our higher-weight match in
+ // this sub-layer still prevents the task default-block from running.
+ filter.flags = FwpmFilterFlagClearActionRight;
+ filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_PERMIT;
+ ThrowIfError(PInvoke.FwpmFilterAdd0(_engine, in filter, default, out ulong id), "FwpmFilterAdd0(permit)");
+ return id;
+ }
+ }
+
+ private static void ThrowIfError(uint result, string operation)
+ {
+ if (result != 0) throw new Win32Exception(unchecked((int)result), $"{operation} failed with WFP/Win32 error 0x{result:X8}.");
+ }
+}