Add remote image validation for screenshot URLs

This commit is contained in:
2026-01-29 01:43:02 +01:00
parent 5b62640b76
commit f713756ece
3 changed files with 92 additions and 2 deletions

View File

@@ -44,6 +44,86 @@ internal static class EndpointHelpers
|| path.EndsWith(".gif") || path.EndsWith(".webp") || path.EndsWith(".avif"); || path.EndsWith(".gif") || path.EndsWith(".webp") || path.EndsWith(".avif");
} }
public static async Task<bool> IsReachableImageAsync(string? url, IHttpClientFactory httpFactory, CancellationToken ct = default)
{
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;
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(3));
var client = httpFactory.CreateClient();
try
{
using var head = new HttpRequestMessage(HttpMethod.Head, uri);
var headResp = await client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead, cts.Token);
if (headResp.IsSuccessStatusCode)
{
var ctHeader = headResp.Content.Headers.ContentType?.MediaType;
if (!string.IsNullOrWhiteSpace(ctHeader) && ctHeader.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
return true;
}
}
catch { /* fallback */ }
try
{
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 (!resp.IsSuccessStatusCode) return false;
var ctHeader = resp.Content.Headers.ContentType?.MediaType;
if (!string.IsNullOrWhiteSpace(ctHeader) && ctHeader.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
return true;
await using var stream = await resp.Content.ReadAsStreamAsync(cts.Token);
Span<byte> buffer = stackalloc byte[12];
var read = await stream.ReadAsync(buffer, cts.Token);
var sig = buffer[..read];
if (IsMagic(sig, "PNG")) return true;
if (IsMagic(sig, new byte[] { 0xFF, 0xD8 })) return true; // JPEG
if (IsMagic(sig, "GIF8")) return true;
if (IsRiffWithTag(sig, "WEBP")) return true;
if (ContainsFtyp(sig, "avif")) return true;
return false;
}
catch
{
return false;
}
}
private static bool IsMagic(ReadOnlySpan<byte> data, string ascii)
{
var bytes = System.Text.Encoding.ASCII.GetBytes(ascii);
return data.StartsWith(bytes);
}
private static bool IsMagic(ReadOnlySpan<byte> data, ReadOnlySpan<byte> magic) => data.StartsWith(magic);
private static bool IsRiffWithTag(ReadOnlySpan<byte> data, string tag)
{
if (data.Length < 12) return false;
var riff = System.Text.Encoding.ASCII.GetBytes("RIFF");
if (!data.StartsWith(riff)) return false;
var tagBytes = System.Text.Encoding.ASCII.GetBytes(tag);
return data[8..].StartsWith(tagBytes);
}
private static bool ContainsFtyp(ReadOnlySpan<byte> data, string brand)
{
if (data.Length < 12) return false;
var ftyp = System.Text.Encoding.ASCII.GetBytes("ftyp");
if (!data[4..].StartsWith(ftyp)) return false;
var brandBytes = System.Text.Encoding.ASCII.GetBytes(brand);
return data[8..].StartsWith(brandBytes);
}
public static async Task<bool> IsAdmin(HttpContext ctx, AppDbContext db, IConfiguration config) public static async Task<bool> IsAdmin(HttpContext ctx, AppDbContext db, IConfiguration config)
{ {
var player = await GetAuthenticatedPlayer(ctx, db); var player = await GetAuthenticatedPlayer(ctx, db);

View File

@@ -40,7 +40,7 @@ public static class SuggestEndpoints
return Results.Ok(ordered); return Results.Ok(ordered);
}); });
app.MapPost("/api/suggestions", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db) => app.MapPost("/api/suggestions", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, IHttpClientFactory http) =>
{ {
var phase = await EndpointHelpers.GetPhase(db); var phase = await EndpointHelpers.GetPhase(db);
if (phase != Phase.Suggest) if (phase != Phase.Suggest)
@@ -55,6 +55,10 @@ public static class SuggestEndpoints
{ {
return Results.BadRequest(new { error = "Screenshot URL must be http(s) and end with an image file extension." }); return Results.BadRequest(new { error = "Screenshot URL must be http(s) and end with an image file extension." });
} }
if (!await EndpointHelpers.IsReachableImageAsync(request.ScreenshotUrl, http))
{
return Results.BadRequest(new { error = "Screenshot URL could not be validated as an image." });
}
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized(); if (player is null) return Results.Unauthorized();
@@ -104,7 +108,7 @@ public static class SuggestEndpoints
return Results.NoContent(); return Results.NoContent();
}); });
app.MapPut("/api/suggestions/{id:int}", async (int id, [FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) => app.MapPut("/api/suggestions/{id:int}", async (int id, [FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, IConfiguration config, IHttpClientFactory http) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db, config); var isAdmin = await EndpointHelpers.IsAdmin(ctx, db, config);
@@ -127,6 +131,10 @@ public static class SuggestEndpoints
{ {
return Results.BadRequest(new { error = "Screenshot URL must be http(s) and end with an image file extension." }); return Results.BadRequest(new { error = "Screenshot URL must be http(s) and end with an image file extension." });
} }
if (!await EndpointHelpers.IsReachableImageAsync(request.ScreenshotUrl, http))
{
return Results.BadRequest(new { error = "Screenshot URL could not be validated as an image." });
}
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id); var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id);
if (suggestion == null) return Results.NotFound(new { error = "Suggestion not found." }); if (suggestion == null) return Results.NotFound(new { error = "Suggestion not found." });

View File

@@ -37,6 +37,8 @@ builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
}); });
builder.Services.AddHttpClient();
var app = builder.Build(); var app = builder.Build();
app.UseForwardedHeaders(new ForwardedHeadersOptions app.UseForwardedHeaders(new ForwardedHeadersOptions