From 7b3164ba84adbd95df223bc7158d83adaa9e755f Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Thu, 20 Aug 2026 10:25:43 +0200 Subject: [PATCH] fix(resize): guard oversized source images from OOMing the resize path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A warehouse thumbnail resize fully rasterizes the SOURCE image (~4 bytes/pixel in GD/ImageMagick, via healCorruptPng() and Spatie Image::load()) before it can downscale. A very large source (e.g. a ~74-megapixel upload) blows the PHP memory_limit during that decode. Memory exhaustion is an UNCATCHABLE fatal, so it bypasses resizedPath()'s try/catch "serve original" fallback and 500s the whole request instead (GlitchTip #5617: "Allowed memory size exhausted", tried to allocate 295995336 bytes). Add a decompression-bomb guard at the top of the try: read the source dimensions cheaply with getimagesize() (header-only, no decode) and, when the pixel count exceeds files.optimization.max_source_megapixels (default 40, 0 disables), log a warning and return the original path — the same graceful fallback the catch performs, but reached BEFORE any allocation. Co-Authored-By: Claude Opus 4.8 (1M context) --- config/files.php | 7 +++ src/Models/File.php | 27 +++++++++++ tests/Unit/ResizeMemoryGuardTest.php | 68 ++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 tests/Unit/ResizeMemoryGuardTest.php diff --git a/config/files.php b/config/files.php index bb848ae..cfad791 100644 --- a/config/files.php +++ b/config/files.php @@ -127,6 +127,13 @@ return [ 'round_to' => 50, 'skip_formats' => ['gif', 'svg', 'svg+xml'], 'preferred_extensions' => ['svg', 'webp', 'png', 'jpg', 'jpeg'], + + // Decompression-bomb guard. Decoding an image allocates ~4 bytes/pixel, + // so a very large source (e.g. a 74-megapixel upload) can blow the PHP + // memory_limit with an UNCATCHABLE fatal that 500s the warehouse request. + // When the source exceeds this pixel budget, resizing is skipped and the + // original is served instead. 0 disables the guard. + 'max_source_megapixels' => 40, ], /* diff --git a/src/Models/File.php b/src/Models/File.php index 87fb122..e429a7d 100644 --- a/src/Models/File.php +++ b/src/Models/File.php @@ -434,6 +434,33 @@ class File extends Model . (isset($pi['extension']) ? '.' . $pi['extension'] : ''); try { + // Decompression-bomb / oversized-source guard. Both healCorruptPng() + // and Spatie's decode below fully rasterize the image (~4 bytes/pixel + // in GD/ImageMagick), so a very large source can exhaust the PHP + // memory_limit — an UNCATCHABLE fatal the catch below cannot recover, + // which 500s the request (GlitchTip "Allowed memory size exhausted"). + // getimagesize() reads only the header, so this is cheap: when the + // source blows the pixel budget, skip resizing and serve the original. + $maxMp = (float) config('files.optimization.max_source_megapixels', 40); + if ($maxMp > 0) { + $dims = @getimagesize($path); + if (is_array($dims) && isset($dims[0], $dims[1]) && $dims[0] > 0 && $dims[1] > 0) { + $megapixels = ($dims[0] * $dims[1]) / 1_000_000; + if ($megapixels > $maxMp) { + if (function_exists('logger')) { + logger()->warning('laravel-files: source image exceeds resize budget; serving original', [ + 'file' => $path, + 'dimensions' => $dims[0] . 'x' . $dims[1], + 'megapixels' => round($megapixels, 1), + 'budget_mp' => $maxMp, + ]); + } + + return $path; + } + } + } + copy($path, $tmpPath); // Some PNGs in storage ship with a miscomputed IDAT CRC, or were diff --git a/tests/Unit/ResizeMemoryGuardTest.php b/tests/Unit/ResizeMemoryGuardTest.php new file mode 100644 index 0000000..c75b8d2 --- /dev/null +++ b/tests/Unit/ResizeMemoryGuardTest.php @@ -0,0 +1,68 @@ +markTestSkipped('GD not available'); + } + + $gd = imagecreatetruecolor($w, $h); + ob_start(); + imagepng($gd); + $png = ob_get_clean(); + imagedestroy($gd); + + $file = File::create(['name' => 'guard-test', 'relativepath' => 'guard/test.png']); + $file->putContents($png); + + return $file; + } + + public function test_oversized_source_serves_original_without_decoding() + { + // A tiny pixel budget makes even a small image "oversized", so the guard + // fires deterministically without allocating a real decompression bomb. + config()->set('files.optimization.max_source_megapixels', 0.0001); // 100 px + + $file = $this->makePngFile(64, 64); // 4096 px = 0.004 MP > budget + + $result = $file->resizedPath(32, 32); + + // Guard fired: the ORIGINAL path is returned, never a /resized/ derivative. + $this->assertSame($file->path, $result); + $this->assertStringNotContainsString('/resized/', $result); + } + + public function test_guard_disabled_when_budget_is_zero() + { + // 0 disables the guard: a within-limits source is not short-circuited by it + // (it proceeds to the normal resize path, which — spatie present or not — + // never returns via the megapixel guard). Asserts the guard itself no-ops. + config()->set('files.optimization.max_source_megapixels', 0); + + $file = $this->makePngFile(64, 64); + + // With the guard disabled the megapixel check must not be what returns the + // path; resizedPath either resizes or falls back on decode error, but the + // call must not throw from the guard branch. + $result = $file->resizedPath(32, 32); + + $this->assertIsString($result); + } +}