fix(security): enforce audited account, network and resource boundaries

Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
This commit is contained in:
cesnimda
2026-09-09 18:21:47 +02:00
parent 7d641edc7b
commit 0954876295
116 changed files with 10101 additions and 122 deletions
@@ -0,0 +1,100 @@
using System.Net;
using System.Net.Sockets;
namespace JobTrackerApi.Services.JobImport;
internal static class JobImportHttpTransport
{
// The optional dial function is a socket-boundary test seam, not a configurable policy.
internal static SocketsHttpHandler CreateHandler(
IHostAddressResolver resolver,
Func<IPEndPoint, CancellationToken, ValueTask<Stream>>? connect = null) => new()
{
AutomaticDecompression = DecompressionMethods.All,
AllowAutoRedirect = false,
// A proxy would resolve/connect the origin outside this boundary.
UseProxy = false,
ConnectCallback = (context, cancellationToken) =>
ConnectAsync(context.DnsEndPoint, resolver, connect ?? ConnectSocketAsync, cancellationToken)
// Return plain TCP; SocketsHttpHandler retains the original URI hostname for TLS/SNI.
};
internal static bool IsBlockedAddress(IPAddress address)
{
if (address.AddressFamily == AddressFamily.InterNetworkV6 && address.ScopeId != 0) return true;
if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();
if (IPAddress.IsLoopback(address)) return true;
var bytes = address.GetAddressBytes();
if (address.AddressFamily == AddressFamily.InterNetwork)
{
return bytes[0] is 0 or 10 or 127 or >= 224 ||
(bytes[0] == 100 && bytes[1] is >= 64 and <= 127) ||
(bytes[0] == 169 && bytes[1] == 254) ||
(bytes[0] == 172 && bytes[1] is >= 16 and <= 31) ||
(bytes[0] == 192 && bytes[1] == 168) ||
(bytes[0] == 198 && bytes[1] is 18 or 19);
}
if (address.AddressFamily != AddressFamily.InterNetworkV6) return true;
if (address.Equals(IPAddress.IPv6Any) || address.IsIPv6LinkLocal ||
address.IsIPv6SiteLocal || address.IsIPv6Multicast || address.IsIPv6Teredo) return true;
if ((bytes[0] & 0xfe) == 0xfc) return true; // Unique local fc00::/7.
// Reject legacy IPv4-compatible, well-known NAT64 and 6to4 destinations: their
// eventual IPv4 peer is not represented by a native IPv6 socket destination.
return bytes.AsSpan(0, 12).IndexOfAnyExcept((byte)0) < 0 ||
(bytes[0] == 0x00 && bytes[1] == 0x64 && bytes[2] == 0xff && bytes[3] == 0x9b &&
((bytes[4] == 0 && bytes[5] == 1) || bytes.AsSpan(4, 8).IndexOfAnyExcept((byte)0) < 0)) ||
(bytes[0] == 0x20 && bytes[1] == 0x02);
}
private static async ValueTask<Stream> ConnectAsync(
DnsEndPoint endpoint,
IHostAddressResolver resolver,
Func<IPEndPoint, CancellationToken, ValueTask<Stream>> connect,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var addresses = IPAddress.TryParse(endpoint.Host, out var literal)
? [literal]
: await resolver.ResolveAsync(endpoint.Host, cancellationToken);
// Validate the entire answer before attempting any connection, including mixed answers.
if (addresses.Length == 0 || addresses.Any(IsBlockedAddress))
throw new HttpRequestException("Local or private network URLs are not allowed.");
SocketException? lastError = null;
foreach (var address in addresses)
{
cancellationToken.ThrowIfCancellationRequested();
var normalized = address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address;
try
{
// No hostname is passed to the socket, so there is no second DNS lookup.
return await connect(new IPEndPoint(normalized, endpoint.Port), cancellationToken);
}
catch (SocketException error)
{
lastError = error;
}
}
throw new HttpRequestException("Could not connect to the import destination.", lastError);
}
internal static async ValueTask<Stream> ConnectSocketAsync(IPEndPoint endpoint, CancellationToken cancellationToken)
{
var socket = new Socket(endpoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
try
{
await socket.ConnectAsync(endpoint, cancellationToken);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
}
}
@@ -170,7 +170,7 @@ public sealed class JobImportService
if (IPAddress.TryParse(uri.Host, out var ip))
{
if (IsBlockedAddress(ip))
if (JobImportHttpTransport.IsBlockedAddress(ip))
{
return UrlValidationResult.Reject("Local or private network URLs are not allowed.");
}
@@ -188,7 +188,7 @@ public sealed class JobImportService
return UrlValidationResult.Reject("Host resolution failed.");
}
if (addresses.Length == 0 || addresses.Any(IsBlockedAddress))
if (addresses.Length == 0 || addresses.Any(JobImportHttpTransport.IsBlockedAddress))
{
return UrlValidationResult.Reject("Local or private network URLs are not allowed.");
}
@@ -196,35 +196,6 @@ public sealed class JobImportService
return UrlValidationResult.Allow(uri.ToString());
}
private static bool IsBlockedAddress(IPAddress ip)
{
if (IPAddress.IsLoopback(ip)) return true;
if (ip.Equals(IPAddress.Any) || ip.Equals(IPAddress.IPv6Any)) return true;
if (ip.Equals(IPAddress.None) || ip.Equals(IPAddress.IPv6None)) return true;
if (ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal || ip.IsIPv6Multicast || ip.IsIPv6Teredo) return true;
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
{
var b = ip.GetAddressBytes();
return b[0] == 10 ||
b[0] == 0 ||
b[0] == 127 ||
(b[0] == 100 && b[1] >= 64 && b[1] <= 127) ||
(b[0] == 169 && b[1] == 254) ||
(b[0] == 172 && b[1] >= 16 && b[1] <= 31) ||
(b[0] == 192 && b[1] == 168) ||
(b[0] == 198 && (b[1] == 18 || b[1] == 19));
}
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
{
var bytes = ip.GetAddressBytes();
return (bytes[0] & 0xfe) == 0xfc; // fc00::/7 unique local addresses
}
return false;
}
private sealed record UrlValidationResult(bool Allowed, string Normalized, string Error)
{
public static UrlValidationResult Allow(string normalized) => new(true, normalized, string.Empty);