diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index a0d0b24c54..046a0bd785 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -640,6 +640,21 @@ Http::init() $timestamp = 60 * 60 * 24 * 180; // Temporarily increase the TTL to 180 day to ensure files in the cache are still fetched. $data = $cache->load($key, $timestamp); + // Integrity check: the filesystem blob and the cache document are + // two separate stores. If one drifts (partial write, disk corruption, + // a bad entry that slipped past the save-time content-type guard), + // the md5 we signed at save time will no longer match. Treat as a + // miss and purge so the action re-runs and writes a fresh entry. + if (! empty($data) && ! $cacheLog->isEmpty()) { + $storedSignature = $cacheLog->getAttribute('signature', ''); + if (! empty($storedSignature) && \md5($data) !== $storedSignature) { + $cache->purge($key); + $storageCacheOperationsCounter->add(1, ['result' => 'corrupt']); + Span::add('storage.cache.corrupt', true); + $data = false; + } + } + if (! empty($data) && ! $cacheLog->isEmpty()) { $parts = explode('/', $cacheLog->getAttribute('resourceType', '')); $type = $parts[0]; diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php index 8723a2cc05..1888ae97aa 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php @@ -278,15 +278,6 @@ class Get extends Action $data = $image->output($output, $quality); - // Defend against Imagick producing a malformed or empty buffer from a - // partial/corrupted source. The shutdown hook caches any 2xx response - // with an image content-type, so a degenerate payload would poison the - // cache for the full TTL. Verify the magic bytes match the declared - // output format before handing the blob off to the response. - if (!self::hasExpectedMagicBytes($data, $output)) { - throw new Exception(Exception::STORAGE_FILE_TYPE_UNSUPPORTED, 'Rendered preview failed integrity check'); - } - $renderingTime = \microtime(true) - $startTime - $downloadTime - $decryptionTime - $decompressionTime; $totalTime = \microtime(true) - $startTime; @@ -323,22 +314,4 @@ class Get extends Action unset($image); } - - private static function hasExpectedMagicBytes(string $data, string $output): bool - { - if (\strlen($data) < 12) { - return false; - } - - $format = \strtolower($output); - return match ($format) { - 'jpg', 'jpeg' => \str_starts_with($data, "\xFF\xD8\xFF"), - 'png' => \str_starts_with($data, "\x89PNG\r\n\x1A\n"), - 'gif' => \str_starts_with($data, 'GIF87a') || \str_starts_with($data, 'GIF89a'), - 'webp' => \str_starts_with($data, 'RIFF') && \substr($data, 8, 4) === 'WEBP', - // Unknown/unsupported output formats — skip the check rather than - // reject, so new formats added to storage-outputs don't silently fail. - default => true, - }; - } }