Skip to content

Commit e187d97

Browse files
committed
Image for analysis now validates analysis ID and ensures the image is a real image
1 parent fa1d520 commit e187d97

2 files changed

Lines changed: 77 additions & 10 deletions

File tree

apps/pwabuilder/Controllers/ImagesController.cs

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
using System.Drawing;
2-
using System.Text.Json;
31
using Microsoft.AspNetCore.Mvc;
42
using PuppeteerSharp;
53
using PWABuilder.Common;
64
using PWABuilder.Models;
75
using PWABuilder.Services;
6+
using PWABuilder.Validations.Services;
87

98
namespace PWABuilder.Controllers;
109

@@ -27,34 +26,41 @@ public ImagesController(IHttpClientFactory httpClientFactory, ILogger<ImagesCont
2726
/// </summary>
2827
/// <param name="analysisId">The ID of the analysis whose manifest contains the image.</param>
2928
/// <param name="puppeteer">The Puppeteer service.</param>
29+
/// <param name="redis">The Redis cache.</param>
30+
/// <param name="imageValidation">The image validation service.</param>
3031
/// <param name="cancelToken">The cancellation token.</param>
3132
/// <param name="imageUrl">The URL of the image to proxy.</param>
3233
/// <returns></returns>
3334
[HttpGet("getSafeImageForAnalysis")]
34-
public async Task<IActionResult> GetSafeImageForAnalysis([FromQuery] string analysisId, [FromQuery] Uri imageUrl, [FromServices] IPuppeteerService puppeteer, [FromServices] IRedisCache db, CancellationToken cancelToken)
35+
public async Task<IActionResult> GetSafeImageForAnalysis([FromQuery] string analysisId, [FromQuery] Uri imageUrl, [FromServices] IPuppeteerService puppeteer, [FromServices] IRedisCache redis, [FromServices] IImageValidationService imageValidation, CancellationToken cancelToken)
3536
{
3637
if (!imageUrl.IsAbsoluteInternetHttps())
3738
{
3839
return BadRequest("Image URL must be an absolute HTTPS URI pointing to a non-local address.");
3940
}
4041

41-
HttpClientExtensions.LimitedReadStreamWithMediaType imageStream;
42+
// Ensure the analysis exists.
43+
var analysis = await redis.GetByIdAsync<Analysis>(analysisId);
44+
if (analysis == null)
45+
{
46+
logger.LogWarning("Image proxy endpoint failed to find analysis {analysisId} for image URL {imageUrl}", analysisId, imageUrl);
47+
return NotFound($"Analysis with ID {analysisId} not found.");
48+
}
49+
4250
try
4351
{
44-
imageStream = await http.GetImageAsync(imageUrl, maxSize, cancelToken);
45-
Response.RegisterForDispose(imageStream);
46-
return File(imageStream, string.IsNullOrWhiteSpace(imageStream.MediaType) ? "image/png" : imageStream.MediaType);
52+
using var imageStream = await http.GetImageAsync(imageUrl, maxSize, cancelToken);
53+
return await ValidateImageAsync(imageStream, imageStream.MediaType ?? "image/png", imageValidation, cancelToken);
4754
}
4855
catch (Exception error)
4956
{
5057
// Download via C# HttpClient failed. Try to load the image via Puppeteer.
5158
if (!string.IsNullOrEmpty(analysisId))
5259
{
53-
var puppeteerImageStream = await TryDownloadImageViaPuppeteer(analysisId, imageUrl, puppeteer, db);
60+
using var puppeteerImageStream = await TryDownloadImageViaPuppeteer(analysisId, imageUrl, puppeteer, redis);
5461
if (puppeteerImageStream != null)
5562
{
56-
Response.RegisterForDispose(puppeteerImageStream);
57-
return File(puppeteerImageStream, puppeteerImageStream.MediaType ?? "image/png");
63+
return await ValidateImageAsync(puppeteerImageStream, puppeteerImageStream.MediaType ?? "image/png", imageValidation, cancelToken);
5864
}
5965
}
6066

@@ -211,4 +217,31 @@ public async Task<IActionResult> GenerateStoreImages([FromForm] StoreImageCreati
211217
await resultHandle.DisposeAsync();
212218
}
213219
}
220+
221+
private async Task<IActionResult> ValidateImageAsync(Stream imageStream, string contentType, IImageValidationService imageValidation, CancellationToken cancellationToken)
222+
{
223+
// First, we need to copy the stream because the image stream may not be seekable.
224+
var memStream = new MemoryStream();
225+
await imageStream.CopyToAsync(memStream, cancellationToken);
226+
memStream.Position = 0;
227+
228+
// Validate that it's a real image. This is needed to prevent crafted attacks where the server response is not an image.
229+
var imageStatus = await imageValidation.IsValidImageAsync(memStream, cancellationToken);
230+
if (imageStatus.Error != null)
231+
{
232+
logger.LogWarning("Image validation failed for proxied image with content type {contentType}. Error: {error}", contentType, imageStatus.Error);
233+
memStream.Dispose();
234+
return BadRequest($"You didn't pass a valid image endpoint.");
235+
}
236+
if (imageStatus.Value == false)
237+
{
238+
logger.LogWarning("Image validation failed for proxied image with content type {contentType}. The stream is not a valid image.", contentType);
239+
memStream.Dispose();
240+
return BadRequest($"You didn't pass a valid image endpoint.");
241+
}
242+
243+
memStream.Position = 0;
244+
Response.RegisterForDispose(memStream);
245+
return File(memStream, contentType);
246+
}
214247
}

apps/pwabuilder/Services/ImageValidationService.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ public interface IImageValidationService
3535
/// <param name="cancelToken">The cancellation token.</param>
3636
/// <returns>True if the actual image dimensions match any of the declared sizes, otherwise the result will contain an exception.</returns>
3737
Task<Result<bool>> TryValidateImageSizeAsync(Uri imageUri, string? declaredSizes, CancellationToken cancelToken);
38+
39+
/// <summary>
40+
/// Attempts to load the image to see if it's a valid image.
41+
/// </summary>
42+
/// <param name="imageStream">The stream to validate as an image.</param>
43+
/// <param name="cancellationToken">The cancellation token.</param>
44+
/// <returns>True if the image is valid, otherwise false with any error details.</returns>
45+
Task<Result<bool>> IsValidImageAsync(Stream imageStream, CancellationToken cancellationToken);
3846
}
3947

4048
/// <summary>
@@ -257,6 +265,32 @@ public async Task<Result<bool>> TryValidateImageSizeAsync(Uri imageUri, string?
257265
}
258266
}
259267

268+
/// <inheritdoc/>
269+
public async Task<Result<bool>> IsValidImageAsync(Stream imageStream, CancellationToken cancellationToken)
270+
{
271+
try
272+
{
273+
var imageInfo = await Image.IdentifyAsync(imageStream, cancellationToken);
274+
if (imageInfo == null)
275+
{
276+
logger.LogInformation("Attempted to identify image from stream, but it couldn't be identified.");
277+
return new ArgumentException("Image couldn't be identified.", nameof(imageStream));
278+
}
279+
280+
return true;
281+
}
282+
catch (UnknownImageFormatException unknownFormatError)
283+
{
284+
logger.LogInformation(unknownFormatError, "Attempted to identify image from stream, but it was in an unknown format.");
285+
return unknownFormatError;
286+
}
287+
catch (Exception error)
288+
{
289+
logger.LogInformation(error, "Attempted to identify image from stream, but an error occurred.");
290+
return error;
291+
}
292+
}
293+
260294
private Result<bool> EnsureImageContentType(Uri imageUrl, string? contentType)
261295
{
262296
// If we have no content type, we can't validate it. Assume it's valid and hope for the best.

0 commit comments

Comments
 (0)