Harden app security controls from audit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user