fix(resize): guard oversized source images from OOMing the resize path

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) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-08-20 10:25:43 +02:00
parent 20b1c91e6e
commit 7b3164ba84
3 changed files with 102 additions and 0 deletions

View File

@ -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,
],
/*

View File

@ -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

View File

@ -0,0 +1,68 @@
<?php
namespace Blax\Files\Tests\Unit;
use Blax\Files\Models\File;
use Blax\Files\Tests\TestCase;
/**
* Regression for GlitchTip #5617: an oversized source image fully rasterizes
* (~4 bytes/pixel) during resize and exhausts the PHP memory_limit an
* UNCATCHABLE fatal that 500s the warehouse request instead of hitting the
* method's graceful "serve original" fallback. resizedPath() now reads the
* source dimensions cheaply via getimagesize() and, when they exceed
* files.optimization.max_source_megapixels, returns the original path unchanged
* WITHOUT ever decoding the image.
*/
class ResizeMemoryGuardTest extends TestCase
{
private function makePngFile(int $w, int $h): File
{
if (! function_exists('imagecreatetruecolor')) {
$this->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);
}
}