w4c-workflows-api/Services/Security/EgressPinning.cs
2026-09-13 11:35:17 +03:00

84 lines
3.1 KiB
C#

using System.Net;
using System.Net.Sockets;
namespace w4c_workflows.Services.Security;
/// <summary>
/// Builds the <see cref="SocketsHttpHandler"/> used for workflow egress with a
/// <see cref="SocketsHttpHandler.ConnectCallback"/> that resolves the target and
/// re-validates every address against the <see cref="EgressPolicy"/> at the moment
/// of connecting.
///
/// This closes the DNS-rebinding (TOCTOU) hole: the pre-flight
/// <see cref="EgressGuard.AuthorizeAsync"/> check and the actual socket connect no
/// longer rely on two independent DNS resolutions that an attacker-controlled
/// name could answer differently. The socket is opened against a vetted IP while
/// the request keeps its original <c>Host</c>/SNI, and a host that resolves to any
/// blocked address is refused outright — matching
/// <see cref="EgressPolicy.CheckAddresses"/> semantics.
/// </summary>
public static class EgressPinning
{
public static SocketsHttpHandler CreateHandler(
EgressPolicy policy,
IHostAddressResolver resolver,
bool allowAutoRedirect = false,
bool allowInsecureTls = false)
{
var handler = new SocketsHttpHandler
{
AllowAutoRedirect = allowAutoRedirect,
ConnectCallback = async (context, ct) =>
{
var endpoint = context.DnsEndPoint;
IReadOnlyList<IPAddress> addresses;
if (IPAddress.TryParse(endpoint.Host, out var literal))
addresses = new[] { literal };
else
addresses = await resolver.ResolveAsync(endpoint.Host, ct);
// Re-validate at connect time; an empty set fails closed.
if (!policy.SkipsAddressChecks(endpoint.Host))
{
var decision = policy.CheckAddresses(endpoint.Host, addresses);
if (!decision.Allowed)
throw new HttpRequestException($"egress blocked: {decision.Reason}");
}
Exception? last = null;
foreach (var address in addresses)
{
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp)
{
NoDelay = true,
};
try
{
await socket.ConnectAsync(new IPEndPoint(address, endpoint.Port), ct);
return new NetworkStream(socket, ownsSocket: true);
}
catch (Exception ex)
{
socket.Dispose();
last = ex;
}
}
throw new HttpRequestException(
$"egress blocked: could not connect to a permitted address for '{endpoint.Host}'", last);
},
};
if (allowInsecureTls)
{
handler.SslOptions = new System.Net.Security.SslClientAuthenticationOptions
{
RemoteCertificateValidationCallback = (_, _, _, _) => true,
};
}
return handler;
}
}