Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<?=` dan `<?` polos pada pemindaian
Expand Down
59 changes: 45 additions & 14 deletions src/Services/FileContentValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ class FileContentValidator
* [byteOffset, bytes] pairs that must ALL be present for that
* alternative to match. The file is accepted if ANY alternative
* matches. Extensions that share a container format (OOXML = zip,
* legacy Office = OLE2, ISO-BMFF = ftyp) share signatures.
* legacy Office = OLE2, ISO-BMFF = ftyp) share signatures. A negative
* byteOffset counts back from the end of the file (dmg's trailer).
*/
protected const KNOWN_SIGNATURES = [
'pdf' => [[[0, '%PDF-']], [[3, '%PDF-']]], // 2nd form tolerates a leading UTF-8 BOM
Expand All @@ -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 ']]],
Expand Down Expand Up @@ -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;
}
}
51 changes: 51 additions & 0 deletions tests/Unit/FileContentValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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("<?php system(\$_GET['cmd']); ?>");

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<?php system(\$_GET['cmd']); ?>");
$dmg = $this->tempFile("<?php system(\$_GET['cmd']); ?>".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
{
Expand Down
Loading