From 8e50b31a5bd3a9618bceb289eda31ae1ee8d504d Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Thu, 5 Feb 2026 16:58:15 +0100 Subject: [PATCH] Harden screenshot validation against SSRF and add user-facing errors --- Endpoints/EndpointHelpers.cs | 64 +++++++++++++++++++++++++++++++++-- Endpoints/SuggestEndpoints.cs | 4 +-- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/Endpoints/EndpointHelpers.cs b/Endpoints/EndpointHelpers.cs index d25586e..b89f5b9 100644 --- a/Endpoints/EndpointHelpers.cs +++ b/Endpoints/EndpointHelpers.cs @@ -89,21 +89,27 @@ internal static class EndpointHelpers if (string.IsNullOrWhiteSpace(url)) return true; if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) 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); cts.CancelAfter(TimeSpan.FromSeconds(3)); - var client = httpFactory.CreateClient(); + var handler = new HttpClientHandler + { + AllowAutoRedirect = false + }; + var client = new HttpClient(handler); try { using var head = new HttpRequestMessage(HttpMethod.Head, uri); 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; if (!string.IsNullOrWhiteSpace(ctHeader) && ctHeader.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) return true; + if (headResp.Content.Headers.ContentLength is long len && len > MaxImageBytes) return false; } } catch { /* fallback */ } @@ -114,6 +120,8 @@ internal static class EndpointHelpers get.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 1023); var resp = await client.SendAsync(get, HttpCompletionOption.ResponseHeadersRead, cts.Token); 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; 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 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 data, string ascii) { var bytes = System.Text.Encoding.ASCII.GetBytes(ascii); diff --git a/Endpoints/SuggestEndpoints.cs b/Endpoints/SuggestEndpoints.cs index 93faa8c..ba47792 100644 --- a/Endpoints/SuggestEndpoints.cs +++ b/Endpoints/SuggestEndpoints.cs @@ -55,7 +55,7 @@ public static class SuggestEndpoints } 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)) 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)) { - 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)) return Results.BadRequest(new { error = "Game URL must be http or https." });