diff --git a/Endpoints/EndpointHelpers.cs b/Endpoints/EndpointHelpers.cs index b19581e..9f74f02 100644 --- a/Endpoints/EndpointHelpers.cs +++ b/Endpoints/EndpointHelpers.cs @@ -44,6 +44,86 @@ internal static class EndpointHelpers || path.EndsWith(".gif") || path.EndsWith(".webp") || path.EndsWith(".avif"); } + public static async Task 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 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 data, string ascii) + { + var bytes = System.Text.Encoding.ASCII.GetBytes(ascii); + return data.StartsWith(bytes); + } + + private static bool IsMagic(ReadOnlySpan data, ReadOnlySpan magic) => data.StartsWith(magic); + + private static bool IsRiffWithTag(ReadOnlySpan 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 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 IsAdmin(HttpContext ctx, AppDbContext db, IConfiguration config) { var player = await GetAuthenticatedPlayer(ctx, db); diff --git a/Endpoints/SuggestEndpoints.cs b/Endpoints/SuggestEndpoints.cs index 24c0334..36b9791 100644 --- a/Endpoints/SuggestEndpoints.cs +++ b/Endpoints/SuggestEndpoints.cs @@ -40,7 +40,7 @@ public static class SuggestEndpoints 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); 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." }); } + 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); if (player is null) return Results.Unauthorized(); @@ -104,7 +108,7 @@ public static class SuggestEndpoints 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 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." }); } + 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); if (suggestion == null) return Results.NotFound(new { error = "Suggestion not found." }); diff --git a/Program.cs b/Program.cs index 8381517..9aa12ac 100644 --- a/Program.cs +++ b/Program.cs @@ -37,6 +37,8 @@ builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); +builder.Services.AddHttpClient(); + var app = builder.Build(); app.UseForwardedHeaders(new ForwardedHeadersOptions