Harden screenshot validation against SSRF and add user-facing errors
This commit is contained in:
@@ -89,21 +89,27 @@ internal static class EndpointHelpers
|
|||||||
if (string.IsNullOrWhiteSpace(url)) return true;
|
if (string.IsNullOrWhiteSpace(url)) return true;
|
||||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
|
||||||
if (uri.Scheme is not ("http" or "https")) return false;
|
if (uri.Scheme is not ("http" or "https")) return false;
|
||||||
|
if (!await IsSafePublicHostAsync(uri, httpFactory, ct)) return false;
|
||||||
|
|
||||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
cts.CancelAfter(TimeSpan.FromSeconds(3));
|
cts.CancelAfter(TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
var client = httpFactory.CreateClient();
|
var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
AllowAutoRedirect = false
|
||||||
|
};
|
||||||
|
var client = new HttpClient(handler);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var head = new HttpRequestMessage(HttpMethod.Head, uri);
|
using var head = new HttpRequestMessage(HttpMethod.Head, uri);
|
||||||
var headResp = await client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
var headResp = await client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
||||||
if (headResp.IsSuccessStatusCode)
|
if (headResp.IsSuccessStatusCode && headResp.StatusCode is not System.Net.HttpStatusCode.Redirect)
|
||||||
{
|
{
|
||||||
var ctHeader = headResp.Content.Headers.ContentType?.MediaType;
|
var ctHeader = headResp.Content.Headers.ContentType?.MediaType;
|
||||||
if (!string.IsNullOrWhiteSpace(ctHeader) && ctHeader.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
|
if (!string.IsNullOrWhiteSpace(ctHeader) && ctHeader.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
|
||||||
return true;
|
return true;
|
||||||
|
if (headResp.Content.Headers.ContentLength is long len && len > MaxImageBytes) return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { /* fallback */ }
|
catch { /* fallback */ }
|
||||||
@@ -114,6 +120,8 @@ internal static class EndpointHelpers
|
|||||||
get.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 1023);
|
get.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 1023);
|
||||||
var resp = await client.SendAsync(get, HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
var resp = await client.SendAsync(get, HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
||||||
if (!resp.IsSuccessStatusCode) return false;
|
if (!resp.IsSuccessStatusCode) return false;
|
||||||
|
if (resp.StatusCode is System.Net.HttpStatusCode.Redirect) return false;
|
||||||
|
if (resp.Content.Headers.ContentLength is long len && len > MaxImageBytes) return false;
|
||||||
|
|
||||||
var ctHeader = resp.Content.Headers.ContentType?.MediaType;
|
var ctHeader = resp.Content.Headers.ContentType?.MediaType;
|
||||||
if (!string.IsNullOrWhiteSpace(ctHeader) && ctHeader.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
|
if (!string.IsNullOrWhiteSpace(ctHeader) && ctHeader.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -138,6 +146,58 @@ internal static class EndpointHelpers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const long MaxImageBytes = 5 * 1024 * 1024; // 5 MB guard
|
||||||
|
|
||||||
|
private static async Task<bool> IsSafePublicHostAsync(Uri uri, IHttpClientFactory httpFactory, CancellationToken ct)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPrivate(System.Net.IPAddress ip)
|
||||||
|
{
|
||||||
|
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||||
|
{
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
|
||||||
|
{
|
||||||
|
return ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal || ip.IsIPv6Multicast || System.Net.IPAddress.IsLoopback(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsMagic(ReadOnlySpan<byte> data, string ascii)
|
private static bool IsMagic(ReadOnlySpan<byte> data, string ascii)
|
||||||
{
|
{
|
||||||
var bytes = System.Text.Encoding.ASCII.GetBytes(ascii);
|
var bytes = System.Text.Encoding.ASCII.GetBytes(ascii);
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ public static class SuggestEndpoints
|
|||||||
}
|
}
|
||||||
if (!await EndpointHelpers.IsReachableImageAsync(request.ScreenshotUrl, http))
|
if (!await EndpointHelpers.IsReachableImageAsync(request.ScreenshotUrl, http))
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { error = "Screenshot URL could not be validated as an image." });
|
return Results.BadRequest(new { error = "Screenshot URL could not be validated as an image. Use a public image link (http/https, no redirects, max 5 MB)." });
|
||||||
}
|
}
|
||||||
if (!EndpointHelpers.IsValidHttpUrl(request.GameUrl))
|
if (!EndpointHelpers.IsValidHttpUrl(request.GameUrl))
|
||||||
return Results.BadRequest(new { error = "Game URL must be http or https." });
|
return Results.BadRequest(new { error = "Game URL must be http or https." });
|
||||||
@@ -162,7 +162,7 @@ public static class SuggestEndpoints
|
|||||||
}
|
}
|
||||||
if (!await EndpointHelpers.IsReachableImageAsync(request.ScreenshotUrl, http))
|
if (!await EndpointHelpers.IsReachableImageAsync(request.ScreenshotUrl, http))
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { error = "Screenshot URL could not be validated as an image." });
|
return Results.BadRequest(new { error = "Screenshot URL could not be validated as an image. Use a public image link (http/https, no redirects, max 5 MB)." });
|
||||||
}
|
}
|
||||||
if (!EndpointHelpers.IsValidHttpUrl(request.GameUrl))
|
if (!EndpointHelpers.IsValidHttpUrl(request.GameUrl))
|
||||||
return Results.BadRequest(new { error = "Game URL must be http or https." });
|
return Results.BadRequest(new { error = "Game URL must be http or https." });
|
||||||
|
|||||||
Reference in New Issue
Block a user