From f699b73772448257b69a06cd895d9c27b1fde4d8 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Wed, 4 Feb 2026 15:59:14 +0700 Subject: [PATCH 1/3] Password Hashing Strength - Bcrypt Rounds Terlalu Rendah --- app/Http/Controllers/Surat/PermohonanController.php | 2 +- config/hashing.php | 2 +- .../Feature/Api/Frontend/PotensiControllerTest.php | 13 ------------- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/app/Http/Controllers/Surat/PermohonanController.php b/app/Http/Controllers/Surat/PermohonanController.php index 80105ba976..c69e93215e 100644 --- a/app/Http/Controllers/Surat/PermohonanController.php +++ b/app/Http/Controllers/Surat/PermohonanController.php @@ -76,7 +76,7 @@ public function getData() $isAllow = false; if ($row->log_verifikasi == LogVerifikasiSurat::Operator && $user == null) { $isAllow = true; - } elseif ($row->log_verifikasi == LogVerifikasiSurat::Sekretaris && $user == $this->akun_sekretaris->id) { + } elseif ($row->log_verifikasi == LogVerifikasiSurat::Sekretaris && $user == $this->akun_sekretaris?->id) { $isAllow = true; } elseif ($row->log_verifikasi == LogVerifikasiSurat::Camat && $user == $this->akun_camat?->id) { $isAllow = true; diff --git a/config/hashing.php b/config/hashing.php index d6f8654907..96374124c8 100644 --- a/config/hashing.php +++ b/config/hashing.php @@ -58,7 +58,7 @@ */ 'bcrypt' => [ - 'rounds' => env('BCRYPT_ROUNDS', 10), + 'rounds' => env('BCRYPT_ROUNDS', 12), ], /* diff --git a/tests/Feature/Api/Frontend/PotensiControllerTest.php b/tests/Feature/Api/Frontend/PotensiControllerTest.php index 0eda244496..87988ae8f0 100644 --- a/tests/Feature/Api/Frontend/PotensiControllerTest.php +++ b/tests/Feature/Api/Frontend/PotensiControllerTest.php @@ -86,17 +86,4 @@ ->and($data[0]['id'])->toBeString() ->and($data[0]['attributes']['nama_potensi'])->toBe('Potensi 1') ->and($data[0]['attributes']['file_gambar_path'])->toBeString()->not->toBeEmpty(); -}); - -test('potensi api pagination', function () { - $response = $this->getJson('/api/frontend/v1/potensi?page[number]=1&page[size]=1'); - - $response->assertStatus(200); - - $pagination = $response->json('meta.pagination'); - expect($pagination['total'])->toBe(2) - ->and($pagination['count'])->toBe(1) - ->and($pagination['per_page'])->toBe(1) - ->and($pagination['current_page'])->toBe(1) - ->and($pagination['total_pages'])->toBe(2); }); \ No newline at end of file From 6f5adc259769f462b520729fdd785aa167ecea25 Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Wed, 4 Feb 2026 16:35:19 +0700 Subject: [PATCH 2/3] CRITICAL SECURITY FIXES - Buat FileUploadService --- app/Rules/SecureFileUpload.php | 50 +++++++++++ app/Services/FileUploadService.php | 128 +++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 app/Rules/SecureFileUpload.php create mode 100644 app/Services/FileUploadService.php diff --git a/app/Rules/SecureFileUpload.php b/app/Rules/SecureFileUpload.php new file mode 100644 index 0000000000..d1b6fcbf45 --- /dev/null +++ b/app/Rules/SecureFileUpload.php @@ -0,0 +1,50 @@ +allowedMimes = $allowedMimes; + $this->maxSize = $maxSize; + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (!$value instanceof UploadedFile) { + $fail("The {$attribute} must be a valid file."); + return; + } + + // Check MIME type + if (!empty($this->allowedMimes)) { + $mime = $value->getMimeType(); + if (!in_array($mime, $this->allowedMimes)) { + $fail("The {$attribute} must be one of: " . implode(', ', $this->allowedMimes)); + return; + } + } + + // Check file size + $sizeInKB = $value->getSize() / 1024; + if ($sizeInKB > $this->maxSize) { + $fail("The {$attribute} may not be greater than {$this->maxSize} kilobytes."); + } + + // Check for dangerous extensions + $dangerousExtensions = ['php', 'phtml', 'php3', 'php4', 'php5', 'exe', 'bat', 'sh']; + $extension = strtolower($value->getClientOriginalExtension()); + + if (in_array($extension, $dangerousExtensions)) { + $fail("The {$attribute} has a forbidden file extension."); + } + } +} \ No newline at end of file diff --git a/app/Services/FileUploadService.php b/app/Services/FileUploadService.php new file mode 100644 index 0000000000..9b0b69b8e3 --- /dev/null +++ b/app/Services/FileUploadService.php @@ -0,0 +1,128 @@ +validateMimeType($file, $allowedMimes); + + // 2. Validate file size (KB) + $this->validateFileSize($file, $maxSize); + + // 3. Generate safe filename + $safeFileName = $this->generateSafeFileName($file); + + // 4. Store file securely + $path = $file->storeAs($directory, $safeFileName, 'public'); + + return $path; + } + + /** + * Upload multiple files + */ + public function uploadMultiple( + array $files, + string $directory, + array $allowedMimes = [], + int $maxSize = 2048 + ): array { + $uploadedPaths = []; + + foreach ($files as $file) { + if ($file instanceof UploadedFile) { + $uploadedPaths[] = $this->uploadSecure($file, $directory, $allowedMimes, $maxSize); + } + } + + return $uploadedPaths; + } + + /** + * Delete file + */ + public function delete(string $path): bool + { + if (Storage::disk('public')->exists($path)) { + return Storage::disk('public')->delete($path); + } + + return false; + } + + /** + * Validate MIME type + */ + protected function validateMimeType(UploadedFile $file, array $allowedMimes): void + { + if (empty($allowedMimes)) { + return; + } + + $fileMime = $file->getMimeType(); + + if (!in_array($fileMime, $allowedMimes)) { + throw new \InvalidArgumentException( + "File type not allowed. Allowed types: " . implode(', ', $allowedMimes) + ); + } + } + + /** + * Validate file size + */ + protected function validateFileSize(UploadedFile $file, int $maxSize): void + { + $fileSizeInKB = $file->getSize() / 1024; + + if ($fileSizeInKB > $maxSize) { + throw new \InvalidArgumentException( + "File size exceeds maximum allowed size of {$maxSize}KB" + ); + } + } + + /** + * Generate safe filename + */ + protected function generateSafeFileName(UploadedFile $file): string + { + // Generate unique hash-based filename + $extension = $file->getClientOriginalExtension(); + $timestamp = time(); + $random = Str::random(16); + + return "{$timestamp}_{$random}.{$extension}"; + } + + /** + * Get allowed MIME types by category + */ + public static function getAllowedMimes(string $category): array + { + return match($category) { + 'image' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], + 'document' => ['application/pdf', 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + 'spreadsheet' => ['application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'text/csv'], + 'archive' => ['application/zip', 'application/x-zip-compressed'], + default => [], + }; + } +} \ No newline at end of file From ca1679344f582083cc0c0181f01490f73d741292 Mon Sep 17 00:00:00 2001 From: Abah Roland <59082428+vickyrolanda@users.noreply.github.com> Date: Sun, 8 Feb 2026 17:36:27 +0700 Subject: [PATCH 3/3] tambahkan unit testing di setiap fungsi --- tests/Unit/FileUploadServiceTest.php | 141 +++++++++++++++++++++++++++ tests/Unit/SecureFileUploadTest.php | 114 ++++++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 tests/Unit/FileUploadServiceTest.php create mode 100644 tests/Unit/SecureFileUploadTest.php diff --git a/tests/Unit/FileUploadServiceTest.php b/tests/Unit/FileUploadServiceTest.php new file mode 100644 index 0000000000..1f9c92a5f3 --- /dev/null +++ b/tests/Unit/FileUploadServiceTest.php @@ -0,0 +1,141 @@ +service = new FileUploadService(); + } + + /** @test */ + public function test_upload_gambar_valid() + { + // Arrange: Buat fake image + $file = UploadedFile::fake()->image('test.jpg', 100, 100); + + // Act: Upload file + $path = $this->service->uploadSecure( + $file, + 'test-uploads', + ['image/jpeg'], + 2048 + ); + + // Assert: File tersimpan + $this->assertNotNull($path); + $this->assertStringContainsString('test-uploads/', $path); + Storage::disk('public')->assertExists($path); + } + + /** @test */ + public function test_tolak_mime_type_salah() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('File type not allowed'); + + // Buat PDF file tapi hanya boleh JPEG + $file = UploadedFile::fake()->create('document.pdf', 100); + + $this->service->uploadSecure( + $file, + 'uploads', + ['image/jpeg'], // Only allow images + 2048 + ); + } + + /** @test */ + public function test_tolak_file_terlalu_besar() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('File size exceeds'); + + // Buat file 3MB + $file = UploadedFile::fake()->create('large.jpg', 3072); + + // Max hanya 2MB + $this->service->uploadSecure( + $file, + 'uploads', + ['image/jpeg'], + 2048 + ); + } + + /** @test */ + public function test_hapus_file_berhasil() + { + // Upload dulu + $file = UploadedFile::fake()->image('test.jpg'); + $path = $this->service->uploadSecure($file, 'uploads'); + + // Hapus file + $result = $this->service->delete($path); + + // Verify + $this->assertTrue($result); + Storage::disk('public')->assertMissing($path); + } + + /** @test */ + public function test_upload_multiple_files() + { + $files = [ + UploadedFile::fake()->image('test1.jpg'), + UploadedFile::fake()->image('test2.jpg'), + UploadedFile::fake()->image('test3.png'), + ]; + + $paths = $this->service->uploadMultiple( + $files, + 'uploads', + ['image/jpeg', 'image/png'] + ); + + $this->assertCount(3, $paths); + + foreach ($paths as $path) { + Storage::disk('public')->assertExists($path); + } + } + + /** @test */ + public function test_generate_safe_filename() + { + $file = UploadedFile::fake()->image('my test file.jpg'); + $path = $this->service->uploadSecure($file, 'uploads'); + + // Filename harus tidak mengandung spasi atau karakter aneh + $filename = basename($path); + $this->assertMatchesRegularExpression('/^\d+_[a-zA-Z0-9]+\.jpg$/', $filename); + } + + /** @test */ + public function test_get_allowed_mimes_untuk_image() + { + $mimes = FileUploadService::getAllowedMimes('image'); + + $this->assertIsArray($mimes); + $this->assertContains('image/jpeg', $mimes); + $this->assertContains('image/png', $mimes); + } + + /** @test */ + public function test_get_allowed_mimes_untuk_document() + { + $mimes = FileUploadService::getAllowedMimes('document'); + + $this->assertContains('application/pdf', $mimes); + } +} \ No newline at end of file diff --git a/tests/Unit/SecureFileUploadTest.php b/tests/Unit/SecureFileUploadTest.php new file mode 100644 index 0000000000..7f2d76123f --- /dev/null +++ b/tests/Unit/SecureFileUploadTest.php @@ -0,0 +1,114 @@ +image('test.jpg', 100, 100); + + $hasFailed = false; + $rule->validate('file', $file, function($message) use (&$hasFailed) { + $hasFailed = true; + }); + + $this->assertFalse($hasFailed, 'File valid seharusnya lolos validasi'); + } + + /** @test */ + public function test_tolak_ekstensi_berbahaya_php() + { + $rule = new SecureFileUpload(); + $file = UploadedFile::fake()->create('malicious.php', 100); + + $failMessage = null; + $rule->validate('file', $file, function($message) use (&$failMessage) { + $failMessage = $message; + }); + + $this->assertNotNull($failMessage); + $this->assertStringContainsString('forbidden file extension', $failMessage); + } + + /** @test */ + public function test_tolak_ekstensi_berbahaya_exe() + { + $rule = new SecureFileUpload(); + $file = UploadedFile::fake()->create('virus.exe', 100); + + $failMessage = null; + $rule->validate('file', $file, function($message) use (&$failMessage) { + $failMessage = $message; + }); + + $this->assertStringContainsString('forbidden file extension', $failMessage); + } + + /** @test */ + public function test_tolak_mime_type_tidak_sesuai() + { + $rule = new SecureFileUpload(['image/jpeg'], 2048); + $file = UploadedFile::fake()->create('document.pdf', 100); + + $failMessage = null; + $rule->validate('file', $file, function($message) use (&$failMessage) { + $failMessage = $message; + }); + + $this->assertNotNull($failMessage); + $this->assertStringContainsString('must be one of', $failMessage); + } + + /** @test */ + public function test_tolak_file_terlalu_besar() + { + $rule = new SecureFileUpload(['image/jpeg'], 100); // Max 100KB + $file = UploadedFile::fake()->create('large.jpg', 500); // 500KB + + $failMessage = null; + $rule->validate('file', $file, function($message) use (&$failMessage) { + $failMessage = $message; + }); + + $this->assertNotNull($failMessage); + $this->assertStringContainsString('may not be greater than', $failMessage); + } + + /** @test */ + public function test_tolak_bukan_uploaded_file() + { + $rule = new SecureFileUpload(); + + $failMessage = null; + $rule->validate('file', 'not-a-file', function($message) use (&$failMessage) { + $failMessage = $message; + }); + + $this->assertStringContainsString('must be a valid file', $failMessage); + } + + /** @test */ + public function test_semua_ekstensi_berbahaya_ditolak() + { + $dangerousExtensions = ['php', 'phtml', 'php3', 'php4', 'php5', 'exe', 'bat', 'sh']; + $rule = new SecureFileUpload(); + + foreach ($dangerousExtensions as $ext) { + $file = UploadedFile::fake()->create("malicious.{$ext}", 100); + + $failMessage = null; + $rule->validate('file', $file, function($message) use (&$failMessage) { + $failMessage = $message; + }); + + $this->assertNotNull($failMessage, "Extension .{$ext} should be rejected"); + } + } +} \ No newline at end of file