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:
parent
20b1c91e6e
commit
7b3164ba84
|
|
@ -127,6 +127,13 @@ return [
|
||||||
'round_to' => 50,
|
'round_to' => 50,
|
||||||
'skip_formats' => ['gif', 'svg', 'svg+xml'],
|
'skip_formats' => ['gif', 'svg', 'svg+xml'],
|
||||||
'preferred_extensions' => ['svg', 'webp', 'png', 'jpg', 'jpeg'],
|
'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,
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -434,6 +434,33 @@ class File extends Model
|
||||||
. (isset($pi['extension']) ? '.' . $pi['extension'] : '');
|
. (isset($pi['extension']) ? '.' . $pi['extension'] : '');
|
||||||
|
|
||||||
try {
|
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);
|
copy($path, $tmpPath);
|
||||||
|
|
||||||
// Some PNGs in storage ship with a miscomputed IDAT CRC, or were
|
// Some PNGs in storage ship with a miscomputed IDAT CRC, or were
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue