Harden app security controls from audit

This commit is contained in:
2026-02-08 18:40:13 +01:00
parent a6364b0802
commit 42e60d2a5a
20 changed files with 689 additions and 109 deletions

View File

@@ -1,6 +1,8 @@
using GameList.Data;
using GameList.Domain;
using Microsoft.EntityFrameworkCore;
using System.Net;
using System.Net.Sockets;
using System.Security.Claims;
namespace GameList.Endpoints;
@@ -140,6 +142,36 @@ internal static class EndpointHelpers
|| path.EndsWith(".avif", StringComparison.Ordinal);
}
public static HttpMessageHandler CreateImageValidationHandler()
{
return new SocketsHttpHandler
{
AllowAutoRedirect = false,
ConnectCallback = async (context, cancellationToken) =>
{
var addresses = await ResolveSafePublicAddressesAsync(context.DnsEndPoint.Host, cancellationToken);
if (addresses.Count == 0)
throw new HttpRequestException("No safe public IPs found for host.");
foreach (var ip in addresses)
{
var socket = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
try
{
await socket.ConnectAsync(new IPEndPoint(ip, context.DnsEndPoint.Port), cancellationToken);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
}
}
throw new HttpRequestException("Unable to connect to validated public IP for host.");
}
};
}
public static async Task<bool> IsReachableImageAsync(string? url, IHttpClientFactory httpFactory, HttpMessageHandler? handler = null, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(url))
@@ -148,13 +180,21 @@ internal static class EndpointHelpers
return false;
if (uri.Scheme is not ("http" or "https"))
return false;
if (!await IsSafePublicHostAsync(uri, ct))
if (handler is null)
{
if (!await IsSafePublicHostAsync(uri, ct))
return false;
}
else if (IPAddress.TryParse(uri.Host, out var literal) && IsBlockedAddress(literal))
{
return false;
}
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(3));
var client = handler is null ? httpFactory.CreateClient("imageValidation") : new HttpClient(handler, disposeHandler: false);
using var fallbackClient = handler is null ? null : new HttpClient(handler, disposeHandler: false);
var client = fallbackClient ?? httpFactory.CreateClient("imageValidation");
try
{
@@ -234,24 +274,8 @@ internal static class EndpointHelpers
{
try
{
var host = uri.Host;
if (Uri.CheckHostName(host) == UriHostNameType.Dns || Uri.CheckHostName(host) == UriHostNameType.IPv4 || Uri.CheckHostName(host) == UriHostNameType.IPv6)
{
var addresses = await System.Net.Dns.GetHostAddressesAsync(host, ct);
foreach (var ip in addresses)
{
if (System.Net.IPAddress.IsLoopback(ip))
return false;
if (IsPrivate(ip))
return false;
}
}
else
{
return false;
}
return true;
var addresses = await ResolveSafePublicAddressesAsync(uri.Host, ct);
return addresses.Count > 0;
}
catch
{
@@ -259,26 +283,90 @@ internal static class EndpointHelpers
}
}
private static bool IsPrivate(System.Net.IPAddress ip)
private static async Task<IReadOnlyList<IPAddress>> ResolveSafePublicAddressesAsync(string host, CancellationToken ct)
{
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
if (!IsSupportedHostType(host))
return [];
IPAddress[] resolved;
if (IPAddress.TryParse(host, out var literal))
{
var bytes = ip.GetAddressBytes();
return bytes[0] switch
{
10 => true,
172 when bytes[1] >= 16 && bytes[1] <= 31 => true,
192 when bytes[1] == 168 => true,
127 => true,
_ => false
};
resolved = [literal];
}
else
{
resolved = await Dns.GetHostAddressesAsync(host, ct);
}
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
var safe = new List<IPAddress>(resolved.Length);
foreach (var ip in resolved)
{
return ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal || ip.IsIPv6Multicast || System.Net.IPAddress.IsLoopback(ip);
if (!IsBlockedAddress(ip))
safe.Add(ip);
}
return safe.Distinct().ToArray();
}
private static bool IsSupportedHostType(string host)
{
var type = Uri.CheckHostName(host);
return type is UriHostNameType.Dns or UriHostNameType.IPv4 or UriHostNameType.IPv6;
}
private static bool IsBlockedAddress(IPAddress ip)
{
if (IPAddress.IsLoopback(ip))
return true;
if (ip.IsIPv4MappedToIPv6)
return IsBlockedAddress(ip.MapToIPv4());
if (ip.AddressFamily == AddressFamily.InterNetwork)
return IsBlockedIpv4(ip);
if (ip.AddressFamily == AddressFamily.InterNetworkV6)
return IsBlockedIpv6(ip);
return true;
}
private static bool IsBlockedIpv4(IPAddress ip)
{
var b = ip.GetAddressBytes();
return b[0] switch
{
0 => true, // "This network"
10 => true, // private
100 when b[1] >= 64 && b[1] <= 127 => true, // CGNAT
127 => true, // loopback
169 when b[1] == 254 => true, // link local
172 when b[1] >= 16 && b[1] <= 31 => true, // private
192 when b[1] == 0 && b[2] == 0 => true, // IETF protocol assignments
192 when b[1] == 0 && b[2] == 2 => true, // documentation
192 when b[1] == 88 && b[2] == 99 => true, // 6to4 relay anycast
192 when b[1] == 168 => true, // private
198 when b[1] is 18 or 19 => true, // benchmarking
198 when b[1] == 51 && b[2] == 100 => true, // documentation
203 when b[1] == 0 && b[2] == 113 => true, // documentation
>= 224 => true, // multicast/reserved/broadcast
_ => false
};
}
private static bool IsBlockedIpv6(IPAddress ip)
{
if (ip.Equals(IPAddress.IPv6None))
return true;
if (ip.IsIPv6Multicast || ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal)
return true;
var bytes = ip.GetAddressBytes();
if ((bytes[0] & 0xFE) == 0xFC) // fc00::/7 unique local
return true;
if (bytes[0] == 0x20 && bytes[1] == 0x01 && bytes[2] == 0x0D && bytes[3] == 0xB8) // 2001:db8::/32 docs
return true;
return false;
}