diff --git a/CHANGELOG.md b/CHANGELOG.md index 3301641..674370b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ dan proyek ini memakai [Semantic Versioning](https://semver.org/lang/id/). ## [Unreleased] +### Ditambahkan + +- `FileContentValidator`: tanda tangan *magic-byte* untuk ekstensi yang dulu + diterima RFM tetapi belum bisa diverifikasi: mpeg, mpg, wma, flv, mpga, m4a, + ac3, aiff, mid, iso, dmg. Pencocokan kini mendukung offset jauh (iso di + 32769) dan offset negatif dari akhir berkas (trailer `koly` milik dmg) + (`OpenSID/OpenSID` #12025). + ### Diperbaiki - `FileContentValidator`: alternatif ` [[[0, '%PDF-']], [[3, '%PDF-']]], // 2nd form tolerates a leading UTF-8 BOM @@ -48,7 +49,18 @@ class FileContentValidator 'rar' => [[[0, "Rar!\x1A\x07\x00"]], [[0, "Rar!\x1A\x07\x01\x00"]]], 'gz' => [[[0, "\x1F\x8B"]]], 'tar' => [[[257, 'ustar']]], + 'iso' => [[[32769, 'CD001']], [[32769, 'BEA01']]], // ISO 9660 / UDF volume descriptor + 'dmg' => [[[-512, 'koly']]], // UDIF trailer 'mp3' => [[[0, 'ID3']], [[0, "\xFF\xFB"]], [[0, "\xFF\xF3"]], [[0, "\xFF\xF2"]], [[0, "\xFF\xFA"]], [[0, "\xFF\xE3"]]], + 'mpga' => [[[0, 'ID3']], [[0, "\xFF\xFB"]], [[0, "\xFF\xF3"]], [[0, "\xFF\xF2"]], [[0, "\xFF\xFA"]], [[0, "\xFF\xE3"]]], + 'm4a' => [[[4, 'ftyp']]], + 'ac3' => [[[0, "\x0B\x77"]]], + 'aiff' => [[[0, 'FORM'], [8, 'AIFF']], [[0, 'FORM'], [8, 'AIFC']]], + 'mid' => [[[0, 'MThd']]], + 'wma' => [[[0, "\x30\x26\xB2\x75\x8E\x66\xCF\x11"]]], // ASF header GUID + 'flv' => [[[0, 'FLV']]], + 'mpeg' => [[[0, "\x00\x00\x01\xBA"]], [[0, "\x00\x00\x01\xB3"]]], // MPEG-PS pack / sequence header + 'mpg' => [[[0, "\x00\x00\x01\xBA"]], [[0, "\x00\x00\x01\xB3"]]], 'ogg' => [[[0, 'OggS']]], 'wav' => [[[0, 'RIFF'], [8, 'WAVE']]], 'avi' => [[[0, 'RIFF'], [8, 'AVI ']]], @@ -154,25 +166,44 @@ protected function matchesKnownSignature(string $localPath, string $extension): return false; } - // Enough for the deepest offset we check (tar's "ustar" at 257). - $header = (string) fread($handle, 512); - fclose($handle); + try { + foreach ($signatures as $alternative) { + $allPresent = true; - foreach ($signatures as $alternative) { - $allPresent = true; + foreach ($alternative as [$offset, $needle]) { + if ($this->readAt($handle, $offset, strlen($needle)) !== $needle) { + $allPresent = false; + break; + } + } - foreach ($alternative as [$offset, $needle]) { - if (substr($header, $offset, strlen($needle)) !== $needle) { - $allPresent = false; - break; + if ($allPresent) { + return true; } } - if ($allPresent) { - return true; - } + return false; + } finally { + fclose($handle); } + } + + /** + * Reads $length bytes at $offset (negative = from the end of the file), + * or null when the file is too short to hold them. + * + * @param resource $handle + */ + protected function readAt($handle, int $offset, int $length): ?string + { + $seeked = $offset < 0 ? fseek($handle, $offset, SEEK_END) : fseek($handle, $offset); + + if ($seeked !== 0) { + return null; + } + + $bytes = fread($handle, $length); - return false; + return $bytes === false ? null : $bytes; } } diff --git a/tests/Unit/FileContentValidatorTest.php b/tests/Unit/FileContentValidatorTest.php index e607adc..e6d3757 100644 --- a/tests/Unit/FileContentValidatorTest.php +++ b/tests/Unit/FileContentValidatorTest.php @@ -140,6 +140,57 @@ public function accepts_a_non_image_with_a_valid_signature(): void $this->assertTrue($this->validator->isValidUpload($zip, 'zip')); } + #[Test] + public function accepts_the_legacy_rfm_media_and_archive_formats_with_a_valid_signature(): void + { + $samples = [ + 'mpeg' => "\x00\x00\x01\xBA".str_repeat("\x00", 60), + 'mpg' => "\x00\x00\x01\xB3".str_repeat("\x00", 60), + 'wma' => "\x30\x26\xB2\x75\x8E\x66\xCF\x11\xA6\xD9\x00\xAA\x00\x62\xCE\x6C".str_repeat("\x00", 48), + 'flv' => "FLV\x01\x05\x00\x00\x00\x09".str_repeat("\x00", 55), + 'mpga' => 'ID3'.str_repeat("\x00", 61), + 'm4a' => "\x00\x00\x00\x20ftypM4A ".str_repeat("\x00", 52), + 'ac3' => "\x0B\x77".str_repeat("\x00", 62), + 'aiff' => "FORM\x00\x00\x00\x40AIFF".str_repeat("\x00", 52), + 'mid' => "MThd\x00\x00\x00\x06".str_repeat("\x00", 56), + 'iso' => str_repeat("\x00", 32769).'CD001'.str_repeat("\x00", 64), + 'dmg' => str_repeat("\x00", 1024).'koly'.str_repeat("\x00", 508), + ]; + + foreach ($samples as $extension => $bytes) { + $this->assertTrue($this->validator->isValidUpload($this->tempFile($bytes), $extension), "{$extension} asli harus diterima"); + } + } + + #[Test] + public function rejects_a_php_payload_disguised_as_a_legacy_rfm_format(): void + { + $path = $this->tempFile(""); + + foreach (['mpeg', 'mpg', 'wma', 'flv', 'mpga', 'm4a', 'ac3', 'aiff', 'mid', 'iso', 'dmg'] as $extension) { + $this->assertFalse($this->validator->isValidUpload($path, $extension), "{$extension} palsu harus ditolak"); + } + } + + #[Test] + public function rejects_a_valid_signature_carrying_an_embedded_php_payload(): void + { + $flv = $this->tempFile("FLV\x01\x05\x00\x00\x00\x09"); + $dmg = $this->tempFile("".str_repeat("\x00", 1024).'koly'.str_repeat("\x00", 508)); + + $this->assertFalse($this->validator->isValidUpload($flv, 'flv')); + $this->assertFalse($this->validator->isValidUpload($dmg, 'dmg')); + } + + #[Test] + public function rejects_a_file_too_short_to_hold_a_deep_or_trailing_signature(): void + { + $path = $this->tempFile('koly'); + + $this->assertFalse($this->validator->isValidUpload($path, 'dmg')); + $this->assertFalse($this->validator->isValidUpload($path, 'iso')); + } + #[Test] public function rejects_a_non_image_extension_with_no_verifiable_signature(): void {