fix(cache): verify md5 signature on load, drop magic-byte check

Replace the output-side magic-byte check with a read-time integrity
verification in the shared cache middleware.

The cache document already stores `signature = md5(payload)` at save
time, but the load path never verified it. If the filesystem blob and
the cache document drift — a partial write, disk corruption, or a bad
entry that predates the content-type save guard — the mismatched md5
surfaces here and we treat the hit as a miss, purge, and let the action
regenerate the response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
loks0n
2026-04-23 11:38:51 +01:00
co-authored by Claude Opus 4.7
parent 37d6d33814
commit 957a588786
2 changed files with 15 additions and 27 deletions
+15
View File
@@ -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];
@@ -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,
};
}
}