diff --git a/changelog/unreleased/41833 b/changelog/unreleased/41833
new file mode 100644
index 00000000000..336caae79ab
--- /dev/null
+++ b/changelog/unreleased/41833
@@ -0,0 +1,10 @@
+Security: Replace bitmap preview Imagick coder pinning with a magic-byte check
+
+The previous hardening for bitmap previews pinned the ImageMagick coder for
+each provider by writing the file's content to a temporary file before
+decoding, which added a disk write to every preview generation. Each
+provider now instead verifies its own expected file signature against the
+raw content in memory before ImageMagick is invoked at all, closing the
+same class of cross-provider decoding confusion without the extra I/O.
+
+https://github.com/owncloud/core/pull/41833
diff --git a/lib/private/Preview/Bitmap.php b/lib/private/Preview/Bitmap.php
index ad6f11f97d5..3379b2cf476 100644
--- a/lib/private/Preview/Bitmap.php
+++ b/lib/private/Preview/Bitmap.php
@@ -49,7 +49,7 @@ public function getThumbnail(File $file, $maxX, $maxY, $scalingUp) {
// Creates \Imagick object from bitmap or vector file
try {
- $bp = $this->getResizedPreview($stream, $maxX, $maxY, $file->getMimeType());
+ $bp = $this->getResizedPreview($stream, $maxX, $maxY);
} catch (\Exception $e) {
Util::writeLog('core', 'ImageMagick says: ' . $e->getmessage(), Util::ERROR);
return false;
@@ -82,40 +82,34 @@ public function isAvailable(FileInfo $file) {
* @param resource $stream the handle of the file to convert
* @param int $maxX
* @param int $maxY
- * @param string $mimeType the file's own detected mime type, used to pin the
- * Imagick coder so it can't be redirected by the file's actual content
*
* @return Imagick
*/
- private function getResizedPreview($stream, int $maxX, int $maxY, string $mimeType): Imagick {
+ private function getResizedPreview($stream, int $maxX, int $maxY): Imagick {
$content = \stream_get_contents($stream);
if ($this->isDangerousToDecode($content)) {
throw new \RuntimeException('Refusing to decode text-based content for a bitmap preview');
}
- $bp = ImagickFactory::create();
-
- # Pin the coder instead of letting Imagick's own content-sniffing pick one:
- # reading with no format set re-derives the format from a ~130-entry magic
- # table independently of isDangerousToDecode()'s check above, so content that
- # looks like PostScript/PDF (which that check must allow through for the
- # Postscript/PDF providers) would otherwise reach the Ghostscript delegate via
- # any Bitmap provider, not just those two.
- #
- # The pin has to be a "FORMAT:path" read, not setFormat()+readImageBlob(): the
- # latter does reliably reject a mismatched format, but for several coders here
- # (PDF, EPS, AI, PSD, SGI, TIFF, HEIC, HEIF - i.e. all of them) it also silently
- # skips the actual rasterization step, so setImageFormat('png') below ends up
- # with no effect and getImageBlob() returns the original, undecoded bytes.
- $tmpPath = \OC::$server->getTempManager()->getTemporaryFile();
- \file_put_contents($tmpPath, $content);
- try {
- $bp->readImage($this->getImagickFormat($mimeType) . ':' . $tmpPath);
- } finally {
- \unlink($tmpPath);
+ # Reject content whose own leading bytes don't match this provider's expected
+ # format before Imagick ever sees it, instead of pinning the coder Imagick
+ # itself uses to decode: readImageBlob() with no format set picks its coder
+ # from the same leading bytes via its own ~130-entry magic table, independently
+ # of isDangerousToDecode()'s check above - so content that looks like
+ # PostScript/PDF (which that check must allow through for the Postscript/PDF
+ # providers) would otherwise reach the Ghostscript delegate via any Bitmap
+ # provider, not just those two. Since this check and Imagick's own sniffing key
+ # off the same bytes, passing it guarantees Imagick will independently reach the
+ # same, safe conclusion - there's no way to satisfy this check with bytes that
+ # then decode via a different coder.
+ if (!$this->hasExpectedMagicBytes($content)) {
+ throw new \RuntimeException("Refusing to decode content whose signature does not match this provider's expected format");
}
+ $bp = ImagickFactory::create();
+ $bp->readImageBlob($content);
+
# setIteratorIndex(0) will make previews to be generated from the first page
$bp->setIteratorIndex(0);
@@ -144,10 +138,34 @@ private function isDangerousToDecode(string $content): bool {
}
/**
- * Maps this provider's own detected mime type(s) to the Imagick coder name that
- * must decode them - the format pinned in getResizedPreview() above.
+ * True if $content's own leading bytes plausibly belong to a format this
+ * provider is registered to decode - checked before Imagick ever sees the
+ * content, per getResizedPreview() above.
+ */
+ abstract protected function hasExpectedMagicBytes(string $content): bool;
+
+ /**
+ * True if $content has $signature's exact bytes starting at $offset.
+ */
+ protected function hasSignatureAt(string $content, string $signature, int $offset = 0): bool {
+ return \substr($content, $offset, \strlen($signature)) === $signature;
+ }
+
+ /**
+ * True if $content is one of the three PostScript variants ImageMagick's own
+ * magic table (magick/magic.c, MagicMap[], coder name "PS") recognizes: plain
+ * ASCII PostScript, DOS EPS ASCII (a leading Ctrl-D byte), or DOS EPS binary
+ * (a 4-byte binary preamble). Shared by Postscript and Illustrator, since
+ * Illustrator's pre-9, non-PDF-based files use a plain PostScript header too.
*/
- abstract protected function getImagickFormat(string $mimeType): string;
+ protected function hasPostScriptSignature(string $content): bool {
+ foreach (['%!', "\x04%!", "\xC5\xD0\xD3\xC6"] as $signature) {
+ if ($this->hasSignatureAt($content, $signature)) {
+ return true;
+ }
+ }
+ return false;
+ }
/**
* Returns a resized \Imagick object
diff --git a/lib/private/Preview/Font.php b/lib/private/Preview/Font.php
index 3cd330aa216..a20a926cdc9 100644
--- a/lib/private/Preview/Font.php
+++ b/lib/private/Preview/Font.php
@@ -30,12 +30,22 @@ public function getMimeType() {
return '/application\/(?:font-sfnt|x-font$)/';
}
- protected function getImagickFormat(string $mimeType): string {
- if ($mimeType === 'application/x-font') {
- return 'PFB';
+ protected function hasExpectedMagicBytes(string $content): bool {
+ # Verified against ImageMagick's own compiled-in magic table
+ # (magick/magic.c, MagicMap[]): the sfnt version-1.0 tag is the ONLY font
+ # signature it recognizes ("TTF", 5 bytes including the high byte of
+ # numTables, which is 0 for any font with fewer than 256 tables - true in
+ # practice for every real font). It has no entry at all for "OTTO"/"true"/
+ # "ttcf" - confirmed empirically too: this environment's Imagick has no
+ # decode delegate for genuine OTF ('OTTO'-tagged) content regardless of
+ # how it's read, pinned or not.
+ if ($this->hasSignatureAt($content, "\x00\x01\x00\x00\x00")) {
+ return true;
}
- # .otf and .ttf are indistinguishable by mime type alone (both application/font-sfnt);
- # TTF is what actually decodes real font files here, both tagged variants included.
- return 'TTF';
+ # PFB ("Printer Font Binary"): the entry is "PFB", offset 6,
+ # "%!PS-AdobeFont-1.0" - the first 6 bytes are the PFB binary segment
+ # header (0x80, segment type, 4-byte little-endian length), followed by
+ # the standard Adobe Type 1 font program identification string.
+ return $this->hasSignatureAt($content, '%!PS-AdobeFont-1.0', 6);
}
}
diff --git a/lib/private/Preview/Heic.php b/lib/private/Preview/Heic.php
index e06cd65acc1..48b72014415 100644
--- a/lib/private/Preview/Heic.php
+++ b/lib/private/Preview/Heic.php
@@ -30,10 +30,16 @@ public function getMimeType() {
return '/image\/hei(f|c)/';
}
- protected function getImagickFormat(string $mimeType): string {
- if ($mimeType === 'image/heif') {
- return 'HEIF';
+ protected function hasExpectedMagicBytes(string $content): bool {
+ // ISO-BMFF: bytes[0:4] are a variable box size (not checked - any value is
+ // structurally valid), bytes[4:8] must be "ftyp", bytes[8:12] are the brand.
+ // Brand list verified against ImageMagick's own compiled-in magic table
+ // (magick/magic.c, MagicMap[]): "avif"/"heic"/"heix"/"mif1" are registered
+ // there under the "HEIC" coder name - no others are.
+ if (!$this->hasSignatureAt($content, 'ftyp', 4)) {
+ return false;
}
- return 'HEIC';
+ $brand = \substr($content, 8, 4);
+ return \in_array($brand, ['avif', 'heic', 'heix', 'mif1'], true);
}
}
diff --git a/lib/private/Preview/Illustrator.php b/lib/private/Preview/Illustrator.php
index 0267b4712d1..72f1b0f79c8 100644
--- a/lib/private/Preview/Illustrator.php
+++ b/lib/private/Preview/Illustrator.php
@@ -31,7 +31,10 @@ public function getMimeType() {
return '/application\/illustrator/';
}
- protected function getImagickFormat(string $mimeType): string {
- return 'AI';
+ protected function hasExpectedMagicBytes(string $content): bool {
+ // Modern (9+) Illustrator files are PDF-compatible ("%PDF-"); legacy pre-9
+ // files use a plain PostScript header. Both already reach the same
+ // Ghostscript/PDF delegate as the Postscript and PDF providers.
+ return $this->hasSignatureAt($content, '%PDF-') || $this->hasPostScriptSignature($content);
}
}
diff --git a/lib/private/Preview/PDF.php b/lib/private/Preview/PDF.php
index 40ae6397091..c532a9d6deb 100644
--- a/lib/private/Preview/PDF.php
+++ b/lib/private/Preview/PDF.php
@@ -31,7 +31,7 @@ public function getMimeType() {
return '/application\/pdf/';
}
- protected function getImagickFormat(string $mimeType): string {
- return 'PDF';
+ protected function hasExpectedMagicBytes(string $content): bool {
+ return $this->hasSignatureAt($content, '%PDF-');
}
}
diff --git a/lib/private/Preview/Photoshop.php b/lib/private/Preview/Photoshop.php
index 7f7add29fa9..25e2c426464 100644
--- a/lib/private/Preview/Photoshop.php
+++ b/lib/private/Preview/Photoshop.php
@@ -31,7 +31,7 @@ public function getMimeType() {
return '/application\/x-photoshop/';
}
- protected function getImagickFormat(string $mimeType): string {
- return 'PSD';
+ protected function hasExpectedMagicBytes(string $content): bool {
+ return $this->hasSignatureAt($content, '8BPS');
}
}
diff --git a/lib/private/Preview/Postscript.php b/lib/private/Preview/Postscript.php
index e96c2539afa..d112c2f41f7 100644
--- a/lib/private/Preview/Postscript.php
+++ b/lib/private/Preview/Postscript.php
@@ -31,7 +31,7 @@ public function getMimeType() {
return '/application\/postscript/';
}
- protected function getImagickFormat(string $mimeType): string {
- return 'EPS';
+ protected function hasExpectedMagicBytes(string $content): bool {
+ return $this->hasPostScriptSignature($content);
}
}
diff --git a/lib/private/Preview/SGI.php b/lib/private/Preview/SGI.php
index c9c24d4ab52..32c0cad585e 100644
--- a/lib/private/Preview/SGI.php
+++ b/lib/private/Preview/SGI.php
@@ -28,7 +28,7 @@ public function getMimeType() {
return '/image\/sgi/';
}
- protected function getImagickFormat(string $mimeType): string {
- return 'SGI';
+ protected function hasExpectedMagicBytes(string $content): bool {
+ return $this->hasSignatureAt($content, "\x01\xDA");
}
}
diff --git a/lib/private/Preview/SVG.php b/lib/private/Preview/SVG.php
index f750de84e34..972c463c100 100644
--- a/lib/private/Preview/SVG.php
+++ b/lib/private/Preview/SVG.php
@@ -58,18 +58,13 @@ public function getThumbnail(File $file, $maxX, $maxY, $scalingUp) {
return false;
}
- # Pin the coder: reading with no format set would let Imagick's own content-
- # sniffing pick the coder independently of the svg:sanitize/embed/decode
- # options above and of the isDangerousToDecode()-style reasoning in Bitmap.php.
- # This has to be a "SVG:path" read, not setFormat()+readImageBlob(): the latter
- # silently skips the actual rasterization step, same as in Bitmap.php.
- $tmpPath = \OC::$server->getTempManager()->getTemporaryFile();
- \file_put_contents($tmpPath, $output);
- try {
- $imagick->readImage('SVG:' . $tmpPath);
- } finally {
- \unlink($tmpPath);
- }
+ # $output is DOMSanitizer's serialized DOM output, not raw uploaded bytes -
+ # sanitizeSVGContent() already returned null (handled above) for anything
+ # that didn't parse as well-formed XML/SVG. A DOM serializer cannot emit
+ # PostScript/PDF/binary bytes as document-leading output, so there's no
+ # cross-coder-confusion risk here the way there is for raw file content in
+ # Bitmap.php - no need to pin the coder Imagick decodes this with.
+ $imagick->readImageBlob($output);
$imagick->setImageFormat('png32');
} catch (\Exception $e) {
\OCP\Util::writeLog('core', $e->getmessage(), \OCP\Util::ERROR);
diff --git a/lib/private/Preview/TIFF.php b/lib/private/Preview/TIFF.php
index 9f7ff4bfedc..20e5b839ad7 100644
--- a/lib/private/Preview/TIFF.php
+++ b/lib/private/Preview/TIFF.php
@@ -31,7 +31,17 @@ public function getMimeType() {
return '/image\/tiff/';
}
- protected function getImagickFormat(string $mimeType): string {
- return 'TIFF';
+ protected function hasExpectedMagicBytes(string $content): bool {
+ // Classic TIFF, both byte orders, plus BigTIFF ("TIFF64" in ImageMagick's own
+ // magic table) - verified against magick/magic.c's MagicMap[]. BigTIFF was
+ // reachable via the original, unpinned readImageBlob() call this replaces, so
+ // leaving it out here would be a real functional regression, not just an
+ // unverified edge case.
+ foreach (["II*\0", "MM\0*", "II+\0\x08\x00\x00\x00", "MM\0+\x00\x08\x00\x00"] as $signature) {
+ if ($this->hasSignatureAt($content, $signature)) {
+ return true;
+ }
+ }
+ return false;
}
}
diff --git a/tests/lib/Preview/CoderPinningTest.php b/tests/lib/Preview/CoderPinningTest.php
deleted file mode 100644
index 2bf0dadf12c..00000000000
--- a/tests/lib/Preview/CoderPinningTest.php
+++ /dev/null
@@ -1,133 +0,0 @@
-
- *
- * @copyright Copyright (c) 2026, ownCloud GmbH
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see
- *
- */
-
-namespace Test\Preview;
-
-use Generator;
-use OC\Preview\Bitmap;
-use OC\Preview\Font;
-use OC\Preview\Heic;
-use OC\Preview\Illustrator;
-use OC\Preview\PDF;
-use OC\Preview\Photoshop;
-use OC\Preview\Postscript;
-use OC\Preview\SGI;
-use OC\Preview\TIFF;
-use OCP\Files\File;
-use Test\TestCase;
-
-class CoderPinningTest extends TestCase {
- private function makeFile(string $content, string $mimeType): File {
- $stream = \fopen('php://memory', 'rb+');
- \fwrite($stream, $content);
- \rewind($stream);
- $file = $this->createMock(File::class);
- $file->method('fopen')->willReturn($stream);
- $file->method('getMimeType')->willReturn($mimeType);
- return $file;
- }
-
- /**
- * @dataProvider providesLegitimateContent
- */
- public function testDecodesItsOwnFormat(string $fixture, string $mimeType, Bitmap $provider): void {
- if (\count(\Imagick::queryFormats('SVG')) === 0) {
- $this->markTestSkipped('No SVG/extra ImageMagick coders present');
- }
- # HEIF is registered as a coder distinct from HEIC on a real libheif1 install (verified
- # against the actual owncloud/server images), but not in every CI ImageMagick build.
- if ($mimeType === 'image/heif' && \count(\Imagick::queryFormats('HEIF')) === 0) {
- $this->markTestSkipped('No distinct HEIF coder present in this environment');
- }
- $content = \file_get_contents(__DIR__ . '/../../data/' . $fixture);
- $file = $this->makeFile($content, $mimeType);
-
- $result = $provider->getThumbnail($file, 32, 32, false);
-
- $this->assertNotFalse($result, "$fixture via " . \get_class($provider) . ' should have decoded');
- }
-
- public function providesLegitimateContent(): Generator {
- yield 'PDF' => ['testimage.pdf', 'application/pdf', new PDF()];
- yield 'Postscript (EPS)' => ['testimage.eps', 'application/postscript', new Postscript()];
- yield 'Illustrator (AI)' => ['testimage.ai', 'application/illustrator', new Illustrator()];
- yield 'Photoshop (PSD)' => ['testimage.psd', 'application/x-photoshop', new Photoshop()];
- yield 'SGI' => ['testimage.sgi', 'image/sgi', new SGI()];
- yield 'TIFF' => ['testimage.tiff', 'image/tiff', new TIFF()];
- # no genuine OTF ('OTTO'-tagged) fixture here: this environment's ImageMagick/FreeType
- # delegate cannot decode CFF-outline OpenType fonts at all, pinned or not - confirmed
- # against four real system .otf files. TTF-tagged content, which the TTF coder decodes
- # fine, is what's actually exercised in practice for the font-sfnt mime type.
- yield 'Font (font-sfnt, ttf bytes)' => ['testimage.ttf', 'application/font-sfnt', new Font()];
- yield 'Heic (image/heic)' => ['testimage.heic', 'image/heic', new Heic()];
- yield 'Heic (image/heif)' => ['testimage.heic', 'image/heif', new Heic()];
- }
-
- /**
- * PostScript content is sniffed by libmagic as application/postscript, which
- * isDangerousToDecode() must not reject since Postscript/PDF legitimately decode
- * it - so the mime-type gate alone lets it through here too. Pinning the expected
- * coder is what stops ImageMagick's own content-sniffing from handing it to the
- * Ghostscript delegate through a provider that has nothing to do with PostScript.
- *
- * PDF/Postscript/Illustrator are deliberately not in this set: they are the
- * Ghostscript-backed providers PostScript-ish content is NOT foreign to, so
- * feeding it to them tests Ghostscript's own leniency, not cross-coder confusion.
- * Font is also excluded: see testFontNeverInvokesADangerousCoderForForeignContent().
- *
- * @dataProvider providesForeignProviders
- */
- public function testRejectsPostScriptContentFromAForeignProvider(Bitmap $provider, string $mimeType): void {
- if (\count(\Imagick::queryFormats('SVG')) === 0) {
- $this->markTestSkipped('No SVG/extra ImageMagick coders present');
- }
- $postscript = "%!PS-Adobe-3.0\n%%BoundingBox: 0 0 10 10\nshowpage\n";
- $file = $this->makeFile($postscript, $mimeType);
-
- $result = $provider->getThumbnail($file, 32, 32, false);
-
- $this->assertFalse($result);
- }
-
- public function providesForeignProviders(): Generator {
- yield 'SGI' => [new SGI(), 'image/sgi'];
- yield 'Photoshop' => [new Photoshop(), 'application/x-photoshop'];
- yield 'TIFF' => [new TIFF(), 'image/tiff'];
- yield 'Heic' => [new Heic(), 'image/heic'];
- }
-
- public function testFontNeverInvokesADangerousCoderForForeignContent(): void {
- if (\count(\Imagick::queryFormats('SVG')) === 0) {
- $this->markTestSkipped('No SVG/extra ImageMagick coders present');
- }
- $postscript = "%!PS-Adobe-3.0\n%%BoundingBox: 0 0 10 10\nshowpage\n";
- $file = $this->makeFile($postscript, 'application/font-sfnt');
-
- $result = (new Font())->getThumbnail($file, 32, 32, false);
-
- # FreeType fails on non-font bytes by producing a blank placeholder, not by
- # invoking Ghostscript or a script coder - so this may be a small valid image
- # rather than false, but it must never carry rendered PostScript content.
- if ($result !== false) {
- $this->assertLessThan(2048, \strlen($result->data()));
- }
- }
-}
diff --git a/tests/lib/Preview/MagicByteGateTest.php b/tests/lib/Preview/MagicByteGateTest.php
new file mode 100644
index 00000000000..a142fa7756a
--- /dev/null
+++ b/tests/lib/Preview/MagicByteGateTest.php
@@ -0,0 +1,198 @@
+
+ *
+ * @copyright Copyright (c) 2026, ownCloud GmbH
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see
+ *
+ */
+
+namespace Test\Preview;
+
+use Generator;
+use OC\Preview\Bitmap;
+use OC\Preview\Font;
+use OC\Preview\Heic;
+use OC\Preview\Illustrator;
+use OC\Preview\PDF;
+use OC\Preview\Photoshop;
+use OC\Preview\Postscript;
+use OC\Preview\SGI;
+use OC\Preview\TIFF;
+use OCP\Files\File;
+use ReflectionMethod;
+use Test\TestCase;
+
+class MagicByteGateTest extends TestCase {
+ private function makeFile(string $content, string $mimeType): File {
+ $stream = \fopen('php://memory', 'rb+');
+ \fwrite($stream, $content);
+ \rewind($stream);
+ $file = $this->createMock(File::class);
+ $file->method('fopen')->willReturn($stream);
+ $file->method('getMimeType')->willReturn($mimeType);
+ return $file;
+ }
+
+ /**
+ * @dataProvider providesLegitimateContent
+ */
+ public function testDecodesItsOwnFormat(string $fixture, string $mimeType, Bitmap $provider): void {
+ if (\count(\Imagick::queryFormats('SVG')) === 0) {
+ $this->markTestSkipped('No SVG/extra ImageMagick coders present');
+ }
+ # HEIF is registered as a coder distinct from HEIC on a real libheif1 install (verified
+ # against the actual owncloud/server images), but not in every CI ImageMagick build.
+ if ($mimeType === 'image/heif' && \count(\Imagick::queryFormats('HEIF')) === 0) {
+ $this->markTestSkipped('No distinct HEIF coder present in this environment');
+ }
+ $content = \file_get_contents(__DIR__ . '/../../data/' . $fixture);
+ $file = $this->makeFile($content, $mimeType);
+
+ $result = $provider->getThumbnail($file, 32, 32, false);
+
+ $this->assertNotFalse($result, "$fixture via " . \get_class($provider) . ' should have decoded');
+ }
+
+ public function providesLegitimateContent(): Generator {
+ yield 'PDF' => ['testimage.pdf', 'application/pdf', new PDF()];
+ yield 'Postscript (EPS)' => ['testimage.eps', 'application/postscript', new Postscript()];
+ yield 'Illustrator (AI)' => ['testimage.ai', 'application/illustrator', new Illustrator()];
+ yield 'Photoshop (PSD)' => ['testimage.psd', 'application/x-photoshop', new Photoshop()];
+ yield 'SGI' => ['testimage.sgi', 'image/sgi', new SGI()];
+ yield 'TIFF' => ['testimage.tiff', 'image/tiff', new TIFF()];
+ # no genuine OTF ('OTTO'-tagged) fixture here: this environment's ImageMagick/FreeType
+ # delegate cannot decode CFF-outline OpenType fonts at all - confirmed against four
+ # real system .otf files. TTF-tagged content is what's actually exercised in practice
+ # for the font-sfnt mime type it shares with OTF.
+ yield 'Font (font-sfnt, ttf bytes)' => ['testimage.ttf', 'application/font-sfnt', new Font()];
+ yield 'Heic (image/heic)' => ['testimage.heic', 'image/heic', new Heic()];
+ yield 'Heic (image/heif)' => ['testimage.heic', 'image/heif', new Heic()];
+ }
+
+ /**
+ * PostScript content is sniffed by libmagic as application/postscript, which
+ * isDangerousToDecode() must not reject since Postscript/PDF legitimately decode
+ * it - so the mime-type gate alone lets it through here too. hasExpectedMagicBytes()
+ * is what rejects it before Imagick is ever invoked, for a provider that has
+ * nothing to do with PostScript.
+ *
+ * PDF/Postscript/Illustrator are deliberately not in this set: they are the
+ * Ghostscript-backed providers PostScript-ish content is NOT foreign to.
+ *
+ * @dataProvider providesForeignProviders
+ */
+ public function testRejectsPostScriptContentFromAForeignProvider(Bitmap $provider, string $mimeType): void {
+ $postscript = "%!PS-Adobe-3.0\n%%BoundingBox: 0 0 10 10\nshowpage\n";
+ $file = $this->makeFile($postscript, $mimeType);
+
+ $result = $provider->getThumbnail($file, 32, 32, false);
+
+ $this->assertFalse($result);
+ }
+
+ public function providesForeignProviders(): Generator {
+ yield 'SGI' => [new SGI(), 'image/sgi'];
+ yield 'Photoshop' => [new Photoshop(), 'application/x-photoshop'];
+ yield 'TIFF' => [new TIFF(), 'image/tiff'];
+ yield 'Heic' => [new Heic(), 'image/heic'];
+ # Font's gate rejects this deterministically too: "%!" matches none of its
+ # accepted sfnt tags or the PFB marker, so it never reaches FreeType at all.
+ yield 'Font' => [new Font(), 'application/font-sfnt'];
+ }
+
+ /**
+ * Stronger proof than garbage bytes: a GENUINE fixture of one format, fed to a
+ * provider for a different format. If hasExpectedMagicBytes() were accidentally
+ * missing or a no-op, several of these would very plausibly still decode -
+ * Imagick would happily auto-sniff a real PSD/TIFF/SGI/HEIC file regardless of
+ * which PHP class read it.
+ *
+ * @dataProvider providesForeignGenuineContent
+ */
+ public function testRejectsGenuineContentOfAnotherFormat(string $fixture, Bitmap $provider, string $mimeType): void {
+ $content = \file_get_contents(__DIR__ . '/../../data/' . $fixture);
+ $file = $this->makeFile($content, $mimeType);
+
+ $result = $provider->getThumbnail($file, 32, 32, false);
+
+ $this->assertFalse($result);
+ }
+
+ public function providesForeignGenuineContent(): Generator {
+ yield 'PSD fixture via SGI provider' => ['testimage.psd', new SGI(), 'image/sgi'];
+ yield 'TIFF fixture via Photoshop provider' => ['testimage.tiff', new Photoshop(), 'application/x-photoshop'];
+ yield 'SGI fixture via TIFF provider' => ['testimage.sgi', new TIFF(), 'image/tiff'];
+ yield 'HEIC fixture via Photoshop provider' => ['testimage.heic', new Photoshop(), 'application/x-photoshop'];
+ }
+
+ /**
+ * Direct boundary tests for hasExpectedMagicBytes() itself - the cheapest, most
+ * direct regression guard for this change, independent of any Imagick delegate
+ * being installed (rejection happens before Imagick is ever touched).
+ *
+ * @dataProvider providesMagicByteBoundaries
+ */
+ public function testHasExpectedMagicBytesBoundary(Bitmap $provider, string $content, bool $expected): void {
+ $method = new ReflectionMethod($provider, 'hasExpectedMagicBytes');
+ $method->setAccessible(true);
+
+ $this->assertSame($expected, $method->invoke($provider, $content));
+ }
+
+ public function providesMagicByteBoundaries(): Generator {
+ yield 'empty string rejected everywhere (PDF)' => [new PDF(), '', false];
+ yield 'single byte rejected (SGI)' => [new SGI(), "\x01", false];
+
+ yield 'PDF accepts %PDF-' => [new PDF(), "%PDF-1.4\n", true];
+ yield 'PDF rejects %!' => [new PDF(), "%!PS-Adobe-3.0\n", false];
+
+ yield 'Postscript accepts plain %!' => [new Postscript(), "%!PS-Adobe-3.0\n", true];
+ yield 'Postscript accepts DOS EPS ASCII (0x04 %!)' => [new Postscript(), "\x04%!PS-Adobe-3.0\n", true];
+ yield 'Postscript accepts DOS EPS binary preamble' => [new Postscript(), "\xC5\xD0\xD3\xC6rest", true];
+ yield 'Postscript rejects %PDF-' => [new Postscript(), "%PDF-1.4\n", false];
+
+ yield 'Illustrator accepts %PDF-' => [new Illustrator(), "%PDF-1.4\n", true];
+ yield 'Illustrator accepts plain %!' => [new Illustrator(), "%!PS-Adobe-3.0\n", true];
+ yield 'Illustrator accepts DOS EPS binary preamble' => [new Illustrator(), "\xC5\xD0\xD3\xC6rest", true];
+ yield 'Illustrator rejects 8BPS' => [new Illustrator(), "8BPS\0\0", false];
+
+ yield 'Photoshop accepts 8BPS' => [new Photoshop(), "8BPS\x00\x01", true];
+ yield 'Photoshop rejects %PDF-' => [new Photoshop(), "%PDF-1.4\n", false];
+
+ yield 'SGI accepts 0x01 0xDA' => [new SGI(), "\x01\xDA\x01\x01", true];
+ yield 'SGI rejects 8BPS' => [new SGI(), "8BPS\x00\x01", false];
+
+ yield 'TIFF accepts little-endian II*\0' => [new TIFF(), "II*\0\x08\x00\x00\x00", true];
+ yield 'TIFF accepts big-endian MM\0*' => [new TIFF(), "MM\0*\x00\x00\x00\x08", true];
+ yield 'TIFF accepts little-endian BigTIFF (II+\0)' => [new TIFF(), "II+\0\x08\x00\x00\x00", true];
+ yield 'TIFF accepts big-endian BigTIFF (MM\0+)' => [new TIFF(), "MM\0+\x00\x08\x00\x00", true];
+ yield 'TIFF rejects 8BPS' => [new TIFF(), "8BPS\x00\x01", false];
+
+ yield 'Font accepts sfnt 1.0 tag' => [new Font(), "\x00\x01\x00\x00\x00rest", true];
+ yield 'Font rejects sfnt tag with a non-zero 5th byte' => [new Font(), "\x00\x01\x00\x00\x01rest", false];
+ yield 'Font rejects OTTO (no ImageMagick magic entry for it)' => [new Font(), 'OTTOrest', false];
+ yield 'Font accepts a real PFB header' => [new Font(), "\x80\x01\x00\x00\x00\x00%!PS-AdobeFont-1.0", true];
+ yield 'Font rejects PFB-looking bytes without the Adobe font string' => [new Font(), "\x80\x01\x00\x00\x00\x00not a font", false];
+ yield 'Font rejects %!' => [new Font(), "%!PS-Adobe-3.0\n", false];
+
+ yield 'Heic accepts a heic brand' => [new Heic(), "\x00\x00\x00\x18ftypheic", true];
+ yield 'Heic accepts a heix brand' => [new Heic(), "\x00\x00\x00\x18ftypheix", true];
+ yield 'Heic accepts a mif1 (generic HEIF) brand' => [new Heic(), "\x00\x00\x00\x18ftypmif1", true];
+ yield 'Heic accepts an avif brand' => [new Heic(), "\x00\x00\x00\x18ftypavif", true];
+ yield 'Heic rejects an unregistered brand' => [new Heic(), "\x00\x00\x00\x18ftyphevc", false];
+ yield 'Heic rejects content missing the ftyp box' => [new Heic(), "\x00\x00\x00\x18wxyzheic", false];
+ }
+}