Harden image URL validation against followed redirects

This commit is contained in:
2026-02-07 00:46:03 +01:00
parent 714914bb33
commit b86343a59d
3 changed files with 47 additions and 1 deletions

View File

@@ -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<bool> IsSafePublicHostAsync(Uri uri, CancellationToken ct)
{
try

View File

@@ -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<HttpResponseMessage> 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);
}
}
}

View File

@@ -36,7 +36,7 @@ builder.Services.AddDbContext<AppDbContext>(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 =>