From b86343a59daaa45422762c6755c21fa6945c9314 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sat, 7 Feb 2026 00:46:03 +0100 Subject: [PATCH] Harden image URL validation against followed redirects --- Endpoints/EndpointHelpers.cs | 13 +++++++++++++ GameList.Tests/HelperTests.cs | 33 +++++++++++++++++++++++++++++++++ Program.cs | 2 +- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/Endpoints/EndpointHelpers.cs b/Endpoints/EndpointHelpers.cs index e4b993d..97ff085 100644 --- a/Endpoints/EndpointHelpers.cs +++ b/Endpoints/EndpointHelpers.cs @@ -122,6 +122,8 @@ internal static class EndpointHelpers { using var head = new HttpRequestMessage(HttpMethod.Head, uri); var headResp = await client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead, cts.Token); + if (WasRedirected(uri, headResp)) + return false; if (headResp is { IsSuccessStatusCode: true, StatusCode: not System.Net.HttpStatusCode.Redirect }) { if (headResp.Content.Headers.ContentLength is > MaxImageBytes) @@ -142,6 +144,8 @@ internal static class EndpointHelpers using var get = new HttpRequestMessage(HttpMethod.Get, uri); get.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 1023); var resp = await client.SendAsync(get, HttpCompletionOption.ResponseHeadersRead, cts.Token); + if (WasRedirected(uri, resp)) + return false; if (!resp.IsSuccessStatusCode) return false; if (resp.StatusCode is System.Net.HttpStatusCode.Redirect) @@ -179,6 +183,15 @@ internal static class EndpointHelpers private const long MaxImageBytes = 5 * 1024 * 1024; // 5 MB guard + private static bool WasRedirected(Uri requestedUri, HttpResponseMessage response) + { + var finalUri = response.RequestMessage?.RequestUri; + if (finalUri is null) + return false; + + return !requestedUri.Equals(finalUri); + } + private static async Task IsSafePublicHostAsync(Uri uri, CancellationToken ct) { try diff --git a/GameList.Tests/HelperTests.cs b/GameList.Tests/HelperTests.cs index 4f8d487..8afefa8 100644 --- a/GameList.Tests/HelperTests.cs +++ b/GameList.Tests/HelperTests.cs @@ -113,6 +113,14 @@ public class HelperTests Assert.False(tooLarge); } + [Fact] + public async Task IsReachableImageAsync_rejects_followed_redirect_chain() + { + var handler = new RedirectFollowingHandler(); + var reachable = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(new StubHttpMessageHandler()), handler); + Assert.False(reachable); + } + [Fact] public async Task IsReachableImageAsync_rejects_non_image_content() { @@ -195,4 +203,29 @@ public class HelperTests public string ContentRootPath { get; set; } = ""; public IFileProvider ContentRootFileProvider { get; set; } = null!; } + + private sealed class RedirectFollowingHandler : HttpMessageHandler + { + private static readonly Uri Source = new("http://example.com/img.png"); + private static readonly Uri Destination = new("http://example.com/final.png"); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.RequestUri != Source) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) { RequestMessage = request }); + } + + var redirectedRequest = new HttpRequestMessage(request.Method, Destination); + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = redirectedRequest, + Content = new ByteArrayContent("PNG"u8.ToArray()) + }; + response.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + response.Content.Headers.ContentLength = 3; + + return Task.FromResult(response); + } + } } diff --git a/Program.cs b/Program.cs index 0e9058d..39b3062 100644 --- a/Program.cs +++ b/Program.cs @@ -36,7 +36,7 @@ builder.Services.AddDbContext(options => options.UseSqlite(connect builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); -builder.Services.AddHttpClient(); +builder.Services.AddHttpClient("imageValidation").ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false }); builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(dataProtectionDirectory)); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options =>