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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ Semua perubahan penting pada `opensid/laravel-filemanager` didokumentasikan di b
Format mengikuti [Keep a Changelog](https://keepachangelog.com/id/1.1.0/),
dan proyek ini memakai [Semantic Versioning](https://semver.org/lang/id/).

## [Unreleased]

### Diperbaiki

- `FileContentValidator`: alternatif `<?=` dan `<?` polos pada pemindaian
polyglot hanya 2-3 byte literal, sehingga cocok secara kebetulan pada data
biner terkompresi gambar raster asli (PNG/JPEG/WebP) dan menolak unggahan
gambar yang sah sebagai "wrong extension" — terutama pada gambar
berentropi tinggi seperti foto/tangkapan layar. Kedua alternatif sekarang
mensyaratkan rentetan byte yang tampak seperti kode sungguhan tepat
setelah penanda, yang selalu ada pada payload PHP asli namun nyaris tidak
pernah muncul pada data biner acak (`opensid/premium` #6987).
- Pesan galat `max_size_reached` memakai placeholder gaya `sprintf` (`%d`)
yang tidak pernah disubstitusi oleh `trans()`, sehingga pengguna melihat
teks mentah "...maximale size of %d MB." alih-alih ukuran batas yang
sebenarnya. Sekarang memakai placeholder `:size` Laravel dan controller
menyertakan nilai `max_upload_size` yang terkonfigurasi.

## [1.0.0] - 2026-08-31

Rilis stabil pertama. Penulisan ulang penuh Responsive Filemanager (RFM) v9.x
Expand Down
2 changes: 1 addition & 1 deletion resources/lang/en_EN/filemanager.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@
'no file' => 'No file',
'view type number missing' => 'View type number missing',
'Not enough Memory' => 'Not enough Memory',
'max_size_reached' => 'Your image folder has reach its maximale size of %d MB.',
'max_size_reached' => 'The uploaded file exceeds the maximum allowed size of :size MB.',
'B' => 'B',
'KB' => 'KB',
'MB' => 'MB',
Expand Down
2 changes: 1 addition & 1 deletion resources/lang/id/filemanager.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@
'no file' => 'No file',
'view type number missing' => 'View type number missing',
'Not enough Memory' => 'Not enough Memory',
'max_size_reached' => 'Your image folder has reach its maximale size of %d MB.',
'max_size_reached' => 'Ukuran berkas melebihi batas maksimal :size MB.',
'B' => 'B',
'KB' => 'KB',
'MB' => 'MB',
Expand Down
6 changes: 3 additions & 3 deletions src/Http/Controllers/UploadController.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ protected function handleUpload(Request $request, UploadedFile $file, string $fo

if ($range === null) {
if ($file->getSize() > $this->config->maxUploadSizeBytes()) {
return ['name' => $originalName, 'error' => trans('filemanager::filemanager.max_size_reached')];
return ['name' => $originalName, 'error' => trans('filemanager::filemanager.max_size_reached', ['size' => $this->config->maxUploadSizeMb()])];
}

return $this->finalize($file->getRealPath(), $originalName, $folder);
Expand All @@ -117,7 +117,7 @@ protected function handleUpload(Request $request, UploadedFile $file, string $fo
[$start, $end, $total] = $range;

if ($total > $this->config->maxUploadSizeBytes()) {
return ['name' => $originalName, 'error' => trans('filemanager::filemanager.max_size_reached')];
return ['name' => $originalName, 'error' => trans('filemanager::filemanager.max_size_reached', ['size' => $this->config->maxUploadSizeMb()])];
}

$stagingPath = $this->stagingPath($folder, $originalName);
Expand Down Expand Up @@ -146,7 +146,7 @@ protected function handleUpload(Request $request, UploadedFile $file, string $fo
if (filesize($stagingPath) > min($total, $this->config->maxUploadSizeBytes())) {
@unlink($stagingPath);

return ['name' => $originalName, 'error' => trans('filemanager::filemanager.max_size_reached')];
return ['name' => $originalName, 'error' => trans('filemanager::filemanager.max_size_reached', ['size' => $this->config->maxUploadSizeMb()])];
}

if ($end + 1 < $total) {
Expand Down
17 changes: 16 additions & 1 deletion src/Services/FileContentValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@
*
* The polyglot scan reads the entire file (no 4 MB cap) and catches the
* bare PHP short tag `<?` in addition to `<?php` / `<?=` / `<script`.
*
* The `<?=` and bare `<?` markers are only 2-3 literal bytes, which real
* compressed raster formats (PNG/JPEG/WebP) turn up by pure chance often
* enough to reject legitimate uploads — every ~1 in 30 real PNGs in
* testing. Both alternatives therefore additionally require a run of
* plausible source-code bytes right after the marker (see
* MIN_CODE_RUN_AFTER_SHORT_TAG), which real injected PHP always has and
* random compressed binary essentially never does.
*/
class FileContentValidator
{
Expand Down Expand Up @@ -50,11 +58,18 @@ class FileContentValidator
'mov' => [[[4, 'ftyp']], [[4, 'moov']], [[4, 'free']], [[4, 'mdat']], [[4, 'wide']], [[4, 'skip']]],
];

/**
* Minimum run of printable/text bytes required right after a bare `<?`
* or `<?=` marker for it to count as embedded code rather than a chance
* byte sequence inside compressed binary data.
*/
protected const MIN_CODE_RUN_AFTER_SHORT_TAG = 10;

/**
* Matches an embedded PHP open tag (including the bare short tag) or an
* HTML/script document structure smuggled inside another file type.
*/
protected const POLYGLOT_PATTERN = '/(<\s*script|<\?php|<\?=|<\?[\s\r\n]|<!doctype\s+html|<\s*html\b|<\s*body\b)/i';
protected const POLYGLOT_PATTERN = '/(<\s*script|<\?php|<\?=(?=[\x09\x0A\x0D\x20-\x7E]{'.self::MIN_CODE_RUN_AFTER_SHORT_TAG.',})|<\?[\s\r\n](?=[\x09\x0A\x0D\x20-\x7E]{'.self::MIN_CODE_RUN_AFTER_SHORT_TAG.',})|<!doctype\s+html|<\s*html\b|<\s*body\b)/i';

public function isValidUpload(string $localPath, string $extension): bool
{
Expand Down
7 changes: 6 additions & 1 deletion src/Support/FilemanagerConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,12 @@ public function isHiddenExtension(string $extension): bool

public function maxUploadSizeBytes(): int
{
return (int) config('filemanager.max_upload_size', 8) * 1024 * 1024;
return $this->maxUploadSizeMb() * 1024 * 1024;
}

public function maxUploadSizeMb(): int
{
return (int) config('filemanager.max_upload_size', 8);
}

public function maxTotalSizeBytes(): int|false
Expand Down
26 changes: 26 additions & 0 deletions tests/Feature/UploadRouteTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,32 @@ public function uploads_an_allowed_file_and_returns_its_url(): void
Storage::disk('filemanager')->assertExists('photo.jpg');
}

#[Test]
public function reports_the_actual_size_limit_when_a_file_is_too_large(): void
{
// Regression: max_size_reached used to be an untranslated stock
// string with a raw "%d" that trans() never substitutes, so the
// user saw the literal text "...maximale size of %d MB." instead
// of the configured limit. 'id' is the locale OpenSID host apps
// actually run under (config/app.php: APP_LOCALE default 'id').
app()->setLocale('id');
config(['filemanager.max_upload_size' => 2]);

$file = UploadedFile::fake()->create('big.jpg', 3 * 1024)->size(3 * 1024 * 1024);

$response = $this->post(route('filemanager.upload'), [
'fldr' => '',
'files' => [$file],
]);

$response->assertOk();
$error = $response->json()['files'][0]['error'];

$this->assertStringContainsString('2', $error);
$this->assertStringNotContainsString('%d', $error);
$this->assertStringNotContainsString(':size', $error);
}

#[Test]
public function rejects_a_disallowed_extension_without_storing_anything(): void
{
Expand Down
41 changes: 41 additions & 0 deletions tests/Unit/FileContentValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,27 @@ public function accepts_a_real_jpeg(): void
$this->assertTrue($this->validator->isValidUpload($path, 'jpg'));
}

#[Test]
public function accepts_real_noisy_pngs_without_false_positives(): void
{
// Regression: the compressed IDAT stream of a real, high-entropy PNG
// (a photo-like screenshot, not a flat GD test swatch) contains
// effectively random bytes. The old POLYGLOT_PATTERN's bare `<?`
// and `<?=` alternatives were only 2-3 literal bytes, so they
// matched by pure chance often enough that legitimate PNG uploads
// were rejected as "wrong extension" (reported: PNG uploads failing
// unpredictably). Run several noisy images since any single one is
// probabilistic; none should be rejected after the fix.
for ($i = 0; $i < 20; $i++) {
$path = $this->noisyPng();

$this->assertTrue(
$this->validator->isValidUpload($path, 'png'),
"Iteration {$i}: a clean noisy PNG was rejected (false positive)."
);
}
}

#[Test]
public function rejects_a_php_payload_disguised_with_an_image_extension(): void
{
Expand Down Expand Up @@ -166,6 +187,26 @@ protected function realJpeg(): string
return $this->tempFile($bytes);
}

protected function noisyPng(): string
{
$width = random_int(200, 500);
$height = random_int(200, 500);
$image = imagecreatetruecolor($width, $height);

for ($x = 0; $x < $width; $x += 2) {
for ($y = 0; $y < $height; $y += 2) {
imagesetpixel($image, $x, $y, random_int(0, 0xFFFFFF));
}
}

ob_start();
imagepng($image);
$bytes = ob_get_clean();
imagedestroy($image);

return $this->tempFile($bytes);
}

protected function tempFile(string $contents): string
{
$path = tempnam(sys_get_temp_dir(), 'fcv');
Expand Down
Loading