1- using System . Drawing ;
2- using System . Text . Json ;
31using Microsoft . AspNetCore . Mvc ;
42using PuppeteerSharp ;
53using PWABuilder . Common ;
64using PWABuilder . Models ;
75using PWABuilder . Services ;
6+ using PWABuilder . Validations . Services ;
87
98namespace 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}
0 commit comments