Harden image URL validation against followed redirects
This commit is contained in:
@@ -122,6 +122,8 @@ internal static class EndpointHelpers
|
|||||||
{
|
{
|
||||||
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 (WasRedirected(uri, headResp))
|
||||||
|
return false;
|
||||||
if (headResp is { IsSuccessStatusCode: true, StatusCode: not System.Net.HttpStatusCode.Redirect })
|
if (headResp is { IsSuccessStatusCode: true, StatusCode: not System.Net.HttpStatusCode.Redirect })
|
||||||
{
|
{
|
||||||
if (headResp.Content.Headers.ContentLength is > MaxImageBytes)
|
if (headResp.Content.Headers.ContentLength is > MaxImageBytes)
|
||||||
@@ -142,6 +144,8 @@ internal static class EndpointHelpers
|
|||||||
using var get = new HttpRequestMessage(HttpMethod.Get, uri);
|
using var get = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||||
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 (WasRedirected(uri, resp))
|
||||||
|
return false;
|
||||||
if (!resp.IsSuccessStatusCode)
|
if (!resp.IsSuccessStatusCode)
|
||||||
return false;
|
return false;
|
||||||
if (resp.StatusCode is System.Net.HttpStatusCode.Redirect)
|
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 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)
|
private static async Task<bool> IsSafePublicHostAsync(Uri uri, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ public class HelperTests
|
|||||||
Assert.False(tooLarge);
|
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]
|
[Fact]
|
||||||
public async Task IsReachableImageAsync_rejects_non_image_content()
|
public async Task IsReachableImageAsync_rejects_non_image_content()
|
||||||
{
|
{
|
||||||
@@ -195,4 +203,29 @@ public class HelperTests
|
|||||||
public string ContentRootPath { get; set; } = "";
|
public string ContentRootPath { get; set; } = "";
|
||||||
public IFileProvider ContentRootFileProvider { get; set; } = null!;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlite(connect
|
|||||||
|
|
||||||
builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); });
|
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.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(dataProtectionDirectory));
|
||||||
|
|
||||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options =>
|
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options =>
|
||||||
|
|||||||
Reference in New Issue
Block a user