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]; + } +}