diff --git a/app/Http/Controllers/BackEnd/ThemesController.php b/app/Http/Controllers/BackEnd/ThemesController.php index e953cd487..785958352 100644 --- a/app/Http/Controllers/BackEnd/ThemesController.php +++ b/app/Http/Controllers/BackEnd/ThemesController.php @@ -4,13 +4,20 @@ use App\Http\Controllers\BackEndController; use App\Models\Themes; -use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\File; +use App\Services\ThemeService; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Artisan; class ThemesController extends BackEndController { + protected ThemeService $themeService; + + public function __construct(ThemeService $themeService) + { + $this->themeService = $themeService; + // In Laravel, if parent has no constructor this is fine, if it does, + // we might need parent::__construct(), but usually it's injected. + } + public function index() { $page_title = 'Tema'; @@ -22,14 +29,17 @@ public function index() public function activate(Themes $themes) { - Themes::where('active', 1)->update(['active' => 0]); - $themes->update(['active' => 1]); - - // Clear all theme API cache - $this->clearThemeCache(); + try { + $this->themeService->activate($themes); + } catch (\RuntimeException $e) { + Log::critical('Theme hooks rejected during activation', [ + 'theme' => $themes->name, + 'reason' => $e->getMessage(), + ]); - // Load theme hooks if exists - $this->loadThemeHooks($themes->name); + return redirect()->route('setting.themes.index') + ->with('error', 'Tema diaktifkan tetapi hooks.php ditolak: ' . $e->getMessage()); + } return redirect()->route('setting.themes.index') ->with('success', 'Tema berhasil diaktifkan. Cache API telah dibersihkan.'); @@ -37,10 +47,7 @@ public function activate(Themes $themes) public function reScan() { - scan_themes(); - - // Clear cache after rescan - $this->clearThemeCache(); + $this->themeService->reScan(); return redirect()->route('setting.themes.index') ->with('success', 'Tema berhasil dipindai ulang'); @@ -50,81 +57,30 @@ public function upload() { try { $file = request()->file('file'); - - // Use FileUploadService for secure file upload - $fileUploadService = new \App\Services\FileUploadService(); - // Define allowed MIME types for zip files - $allowedMimes = \App\Services\FileUploadService::getAllowedMimes('archive'); - - // Validate file type - $fileMimeType = $file->getMimeType(); - if (!in_array($fileMimeType, $allowedMimes)) { + if (!$file) { return response()->json([ 'status' => 'error', - 'message' => 'File harus berformat .zip', + 'message' => 'File tema tidak ditemukan', ]); } - // Use FileUploadService for secure file upload - $fileUploadService = new \App\Services\FileUploadService(); - - // Define allowed MIME types for zip files - $allowedMimes = \App\Services\FileUploadService::getAllowedMimes('archive'); - - // Upload file securely to temp directory - $path = $fileUploadService->uploadSecure($file, 'framework/themes', $allowedMimes, 51200); // 50MB max + $result = $this->themeService->installFromZip($file); - // Extract filename from path - $fileName = basename($path); - - // Get the full file path for further processing - $filePath = storage_path('framework/themes'); - - $zip = new \ZipArchive; - if ($zip->open("$filePath/$fileName") === true) { - $extractedPath = base_path('themes/extracted'); - $zip->extractTo($extractedPath); - $zip->close(); - - $folderTheme = explode('.', $fileName)[0]; - $composerPath = "$extractedPath/$folderTheme/composer.json"; - - if (file_exists($composerPath)) { - $composerData = json_decode(file_get_contents($composerPath), true); - - // Validate theme structure - $this->validateThemeStructure($extractedPath, $folderTheme); - - $newFolder = base_path('themes/' . $composerData['name']); - - if (File::move("$extractedPath/$folderTheme", $newFolder)) { - File::deleteDirectory($extractedPath); - File::deleteDirectory($filePath); + return response()->json($result); - scan_themes(); - - // Load theme hooks - $this->loadThemeHooks($composerData['name']); - - return response()->json([ - 'status' => 'success', - 'message' => 'Tema berhasil diunggah dan siap digunakan', - ]); - } else { - File::deleteDirectory($extractedPath); - File::deleteDirectory($filePath); - } - } - } } catch (\Exception $e) { - Log::error('File upload failed: ' . $e->getMessage()); + Log::error('Theme upload failed: ' . $e->getMessage(), [ + 'action' => 'theme_upload_failed', + 'user_id' => auth()->id(), + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'status' => 'error', + 'message' => 'Tema gagal diunggah. Silakan periksa log untuk detail.', + ]); } - - return response()->json([ - 'status' => 'error', - 'message' => 'Tema gagal diunggah', - ]); } public function destroy(Themes $themes) @@ -138,84 +94,18 @@ public function destroy(Themes $themes) $themes->delete(); // Clear cache - $this->clearThemeCache(); + $this->themeService->clearCache(); return redirect()->route('setting.themes.index') ->with('success', 'Tema berhasil dihapus'); } - /** - * Clear all theme-related cache - */ - private function clearThemeCache() - { - // Clear specific cache keys - Cache::forget('active_theme'); - $store = Cache::getStore(); - if (method_exists($store, 'tags')) { - // Tagged cache supported, clear tag and exit to avoid duplicate calls below - Cache::tags(['theme_api'])->flush(); - Log::info('Theme cache cleared via tags'); - } else { - // Alternative: Clear by prefix - $keys = Cache::get('theme_api:*'); - foreach ($keys ?? [] as $key) { - Cache::forget($key); - } - } - - Log::info('Theme cache cleared'); - } - - /** - * Load theme hooks/filters - */ - private function loadThemeHooks(string $themePath) - { - $hooksFile = base_path("themes/{$themePath}/hooks.php"); - - if (file_exists($hooksFile)) { - try { - include_once $hooksFile; - Log::info("Theme hooks loaded: {$themePath}"); - } catch (\Exception $e) { - Log::error("Failed to load theme hooks: {$e->getMessage()}"); - } - } - } - - /** - * Validate theme structure for API compatibility - */ - private function validateThemeStructure(string $path, string $folder) - { - $requiredFiles = [ - 'composer.json', - 'theme.json', - ]; - - foreach ($requiredFiles as $file) { - if (!file_exists("$path/$folder/$file")) { - throw new \Exception("File {$file} tidak ditemukan di tema"); - } - } - - // Validate theme.json - $themeConfig = json_decode(file_get_contents("$path/$folder/theme.json"), true); - - if (!isset($themeConfig['api_version'])) { - Log::warning("Tema tidak mendefinisikan api_version, gunakan v1 sebagai default"); - } - - return true; - } - /** * Clear API cache manually (untuk admin) */ public function clearCache() { - $this->clearThemeCache(); + $this->themeService->clearCache(); return redirect()->route('setting.themes.index') ->with('success', 'Cache API tema berhasil dibersihkan'); diff --git a/app/Services/ThemeHooksValidator.php b/app/Services/ThemeHooksValidator.php new file mode 100644 index 000000000..c01788542 --- /dev/null +++ b/app/Services/ThemeHooksValidator.php @@ -0,0 +1,253 @@ +numFiles; $i++) { + $filename = $zip->getNameIndex($i); + $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + + if (in_array($ext, self::DANGEROUS_EXTENSIONS, true)) { + $dangerousFiles[] = $filename; + } + } + + if (! empty($dangerousFiles)) { + $list = implode(', ', $dangerousFiles); + Log::critical("Dangerous files detected in theme ZIP: {$list}", [ + 'action' => 'theme_zip_blocked', + 'files' => $dangerousFiles, + 'user_id' => auth()->id(), + ]); + throw new \RuntimeException("Arsip mengandung file PHP berbahaya: {$list}"); + } + } + + /** + * Validate that no ZIP entry contains path traversal sequences. + * Must be called BEFORE extractTo(). + * + * @throws \RuntimeException if any entry contains ".." or starts with "/". + */ + public function validateZipEntryPaths(\ZipArchive $zip): void + { + $traversalEntries = []; + + for ($i = 0; $i < $zip->numFiles; $i++) { + $filename = $zip->getNameIndex($i); + + // Reject entries that start with '/' (absolute path) + // or contain '..' (directory traversal) + if (str_starts_with($filename, '/') || str_contains($filename, '..')) { + $traversalEntries[] = $filename; + } + } + + if (! empty($traversalEntries)) { + $list = implode(', ', $traversalEntries); + Log::critical("Path traversal detected in theme ZIP: {$list}", [ + 'action' => 'theme_zip_traversal_blocked', + 'entries' => $traversalEntries, + 'user_id' => auth()->id(), + ]); + throw new \RuntimeException("Arsip mengandung entri path traversal berbahaya: {$list}"); + } + } + + /** + * Load theme hooks/filters with token-based security validation. + */ + public function loadHooks(string $themePath): void + { + $hooksFile = base_path("themes/{$themePath}/hooks.php"); + + if (! file_exists($hooksFile)) { + return; + } + + // === PHASE 3: Token-based validation === + $source = file_get_contents($hooksFile); + if ($source === false || trim($source) === '') { + Log::warning("Theme hooks file empty or unreadable: {$themePath}"); + return; + } + + $validationResult = $this->validateSource($source, $themePath); + if (! $validationResult['valid']) { + Log::critical("Theme hooks REJECTED: {$themePath} — {$validationResult['reason']}", [ + 'action' => 'theme_hooks_rejected', + 'theme' => $themePath, + 'reason' => $validationResult['reason'], + 'details' => $validationResult['details'] ?? [], + ]); + throw new \RuntimeException("hooks.php mengandung kode berbahaya: {$validationResult['reason']}"); + } + + // Safe to include after token validation + try { + include_once $hooksFile; + Log::info("Theme hooks loaded (validated): {$themePath}", [ + 'action' => 'theme_hooks_loaded', + 'theme' => $themePath, + ]); + } catch (\Exception $e) { + Log::error("Failed to load theme hooks: {$e->getMessage()}", [ + 'action' => 'theme_hooks_error', + 'theme' => $themePath, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Validate hooks.php source code using token_get_all(). + * + * Returns ['valid' => bool, 'reason' => string, 'details' => array] + */ + public function validateSource(string $source, string $themeName): array + { + $tokens = @token_get_all($source); + if (! is_array($tokens)) { + return ['valid' => false, 'reason' => 'hooks.php cannot be parsed', 'details' => []]; + } + + $count = count($tokens); + $dangerousCalls = []; + $hasInlineHtml = false; + $hasBadCharacter = false; + + for ($i = 0; $i < $count; $i++) { + if (! is_array($tokens[$i])) { + continue; + } + + // Detect syntax errors via T_BAD_CHARACTER + if ($tokens[$i][0] === T_BAD_CHARACTER) { + $hasBadCharacter = true; + } + + // Reject inline HTML — hooks.php should be pure PHP + if ($tokens[$i][0] === T_INLINE_HTML) { + $content = trim((string) $tokens[$i][1]); + if ($content !== '') { + $hasInlineHtml = true; + } + } + + // Detect eval() — T_EVAL is a language construct token, not T_STRING + if ($tokens[$i][0] === T_EVAL) { + $dangerousCalls[] = [ + 'function' => 'eval', + 'line' => $tokens[$i][2], + ]; + continue; + } + + // Look for function calls: T_STRING followed by '(' + if ($tokens[$i][0] === T_STRING) { + $funcName = strtolower((string) $tokens[$i][1]); + + // Check if the next non-whitespace token is '(' + $nextIdx = $i + 1; + while ($nextIdx < $count && is_array($tokens[$nextIdx]) && $tokens[$nextIdx][0] === T_WHITESPACE) { + $nextIdx++; + } + + if ($nextIdx < $count && ! is_array($tokens[$nextIdx]) && $tokens[$nextIdx] === '(') { + // Skip if this T_STRING is part of a function/method definition + $prevIdx = $i - 1; + while ($prevIdx >= 0 && is_array($tokens[$prevIdx]) && $tokens[$prevIdx][0] === T_WHITESPACE) { + $prevIdx--; + } + $isDefinition = ($prevIdx >= 0 && is_array($tokens[$prevIdx]) && $tokens[$prevIdx][0] === T_FUNCTION); + + if (! $isDefinition && in_array($funcName, self::DANGEROUS_FUNCTIONS, true)) { + $dangerousCalls[] = [ + 'function' => $funcName, + 'line' => $tokens[$i][2], + ]; + } + } + } + + // BUG FIX (High): Detect variable function calls, e.g. $f('id') or $obj->method(). + if ($tokens[$i][0] === T_VARIABLE) { + $nextIdx = $i + 1; + while ($nextIdx < $count && is_array($tokens[$nextIdx]) && $tokens[$nextIdx][0] === T_WHITESPACE) { + $nextIdx++; + } + + if ($nextIdx < $count && ! is_array($tokens[$nextIdx]) && $tokens[$nextIdx] === '(') { + $dangerousCalls[] = [ + 'function' => 'variable_function_call:' . $tokens[$i][1], + 'line' => $tokens[$i][2], + ]; + } + } + } + + if ($hasBadCharacter) { + return [ + 'valid' => false, + 'reason' => 'hooks.php contains syntax errors', + 'details' => [], + ]; + } + + if ($hasInlineHtml) { + return [ + 'valid' => false, + 'reason' => 'hooks.php must contain only PHP code, no HTML', + 'details' => [], + ]; + } + + if (! empty($dangerousCalls)) { + return [ + 'valid' => false, + 'reason' => 'Dangerous function calls detected: ' . implode(', ', array_column($dangerousCalls, 'function')), + 'details' => $dangerousCalls, + ]; + } + + return ['valid' => true, 'reason' => '', 'details' => []]; + } +} diff --git a/app/Services/ThemeService.php b/app/Services/ThemeService.php new file mode 100644 index 000000000..2ceaa888e --- /dev/null +++ b/app/Services/ThemeService.php @@ -0,0 +1,164 @@ +validator = $validator; + $this->cacheService = $cacheService; + $this->fileUploadService = $fileUploadService; + } + + public function activate(Themes $themes): void + { + Themes::where('active', 1)->update(['active' => 0]); + $themes->update(['active' => 1]); + + $this->clearCache(); + + $this->validator->loadHooks($themes->name); + } + + public function reScan(): void + { + scan_themes(); + $this->clearCache(); + } + + public function clearCache(): void + { + $this->cacheService->removeCacheByKey('active_theme'); + $store = \Illuminate\Support\Facades\Cache::getStore(); + if (method_exists($store, 'tags')) { + \Illuminate\Support\Facades\Cache::tags(['theme_api'])->flush(); + Log::info('Theme cache cleared via tags'); + } else { + $this->cacheService->removeCachePrefix('theme_api'); + Log::info('Theme cache cleared via prefix'); + } + } + + public function installFromZip(UploadedFile $file): array + { + $allowedMimes = FileUploadService::getAllowedMimes('archive'); + + $fileMimeType = $file->getMimeType(); + if (!in_array($fileMimeType, $allowedMimes)) { + return [ + 'status' => 'error', + 'message' => 'File harus berformat .zip', + ]; + } + + $path = $this->fileUploadService->uploadSecure($file, 'framework/themes', $allowedMimes, 51200); + + $fileName = basename($path); + $filePath = storage_path('framework/themes'); + + $zip = new \ZipArchive; + $zipOpened = false; + + try { + if ($zip->open("$filePath/$fileName") === true) { + $zipOpened = true; + + $this->validator->scanZipForPhp($zip); + $this->validator->validateZipEntryPaths($zip); + + $extractedPath = base_path('themes/extracted'); + $zip->extractTo($extractedPath); + $zip->close(); + $zipOpened = false; + + $folderTheme = explode('.', $fileName)[0]; + $composerPath = "$extractedPath/$folderTheme/composer.json"; + + if (file_exists($composerPath)) { + $composerData = json_decode(file_get_contents($composerPath), true); + + $this->validateThemeStructure($extractedPath, $folderTheme); + + $newFolder = base_path('themes/' . $composerData['name']); + + if (File::move("$extractedPath/$folderTheme", $newFolder)) { + File::deleteDirectory($extractedPath); + File::deleteDirectory($filePath); + + scan_themes(); + + $themeName = $composerData['name']; + $userId = auth()->id(); + $userEmail = auth()->user()?->email ?? 'unknown'; + Log::warning("Theme uploaded (hooks NOT auto-loaded): theme={$themeName}, user_id={$userId}, email={$userEmail}", [ + 'action' => 'theme_upload', + 'theme' => $themeName, + 'user_id' => $userId, + ]); + + return [ + 'status' => 'success', + 'message' => 'Tema berhasil diunggah. Review hooks.php melalui menu aktivasi tema sebelum mengaktifkan.', + ]; + } else { + File::deleteDirectory($extractedPath); + File::deleteDirectory($filePath); + } + } + } + } finally { + if ($zipOpened) { + $zip->close(); + } + } + + return [ + 'status' => 'error', + 'message' => 'Tema gagal diunggah (struktur arsip tidak valid atau file hilang)', + ]; + } + + private function validateThemeStructure(string $path, string $folder): bool + { + $requiredFiles = [ + 'composer.json', + 'theme.json', + ]; + + foreach ($requiredFiles as $file) { + if (!file_exists("$path/$folder/$file")) { + throw new \Exception("File {$file} tidak ditemukan di tema"); + } + } + + $themeJsonPath = "$path/$folder/theme.json"; + $themeJsonRaw = file_get_contents($themeJsonPath); + if ($themeJsonRaw === false) { + throw new \Exception("Tidak dapat membaca theme.json dari tema"); + } + + $themeConfig = json_decode($themeJsonRaw, true); + if (! is_array($themeConfig)) { + throw new \Exception("theme.json tidak mengandung JSON yang valid"); + } + + if (!isset($themeConfig['api_version'])) { + Log::warning("Tema tidak mendefinisikan api_version, gunakan v1 sebagai default"); + } + + return true; + } +} diff --git a/routes/web.php b/routes/web.php index 94636e0c7..36a60f133 100644 --- a/routes/web.php +++ b/routes/web.php @@ -966,11 +966,14 @@ Route::get('activate/{themes}', 'activate')->name('setting.themes.activate'); Route::get('rescan', 'rescan')->name('setting.themes.rescan'); Route::post('clear-cache', 'clearCache')->name('setting.themes.clear-cache'); - // post to-upload - Route::post('upload', 'upload')->name('setting.themes.upload'); Route::delete('destroy/{themes}', 'destroy')->name('setting.themes.destroy'); }); + // Theme upload — restricted to super-admin only (CRITICAL security boundary) + Route::group(['prefix' => 'themes', 'controller' => ThemesController::class, 'middleware' => ['role:super-admin']], function () { + Route::post('upload', 'upload')->name('setting.themes.upload'); + }); + Route::group(['prefix' => 'aplikasi', 'controller' => AplikasiController::class, 'middleware' => ['action_permission:access.setting.aplikasi']], function () { Route::get('/', 'index')->name('setting.aplikasi.index'); Route::get('/edit/{aplikasi}', 'edit')->name('setting.aplikasi.edit'); diff --git a/tests/Feature/Security/ThemeUploadSecurityTest.php b/tests/Feature/Security/ThemeUploadSecurityTest.php new file mode 100644 index 000000000..5d3248ad6 --- /dev/null +++ b/tests/Feature/Security/ThemeUploadSecurityTest.php @@ -0,0 +1,253 @@ +seed(RoleSpatieSeeder::class); + + $this->superAdmin = User::factory()->create(); + $this->superAdmin->assignRole('super-admin'); + + $this->adminWebsite = User::factory()->create(); + $this->adminWebsite->assignRole('administrator-website'); + }); + + // ── Phase 4: Route permission ── + + test('super_admin_can_access_upload_page', function () { + $r = $this->actingAs($this->superAdmin)->get(route('setting.themes.index')); + $r->assertStatus(200); + }); + + test('administrator_website_forbidden_from_upload', function () { + $file = UploadedFile::fake()->create('theme.zip', 100, 'application/zip'); + $r = $this->actingAs($this->adminWebsite) + ->post(route('setting.themes.upload'), ['file' => $file]); + $r->assertStatus(403); + }); + + // ── Phase 2: ZIP content scanning ── + + test('upload_with_php_in_zip_is_rejected', function () { + $file = makeZipWithPhp(); + $r = $this->actingAs($this->superAdmin) + ->post(route('setting.themes.upload'), ['file' => $file]); + $r->assertJson(['status' => 'error']); + }); + + // ── BUG 3 FIX: ZIP path traversal ── + + test('upload_with_path_traversal_in_zip_is_rejected', function () { + $file = makeZipWithPathTraversal(); + $r = $this->actingAs($this->superAdmin) + ->post(route('setting.themes.upload'), ['file' => $file]); + $r->assertJson(['status' => 'error']); + }); + + // ── Phase 3: Activate validates hooks ── + + test('activate_with_clean_hooks_succeeds', function () { + // Ensure default theme exists in DB and on disk + $hooksDir = base_path('themes/default'); + if (! is_dir($hooksDir)) { + mkdir($hooksDir, 0755, true); + } + + $hooksFile = $hooksDir . '/hooks.php'; + file_put_contents($hooksFile, ' 'default'], + [ + 'vendor' => 'opendk', + 'version' => '1.0', + 'description' => 'Default', + 'path' => $hooksDir, + 'system' => true, + 'active' => 0, + ] + ); + + $r = $this->actingAs($this->superAdmin) + ->get(route('setting.themes.activate', $theme)); + $r->assertStatus(302); + + @unlink($hooksFile); + }); + + // OBS 1 FIX: activate() sekarang redirect 302 + flash error, bukan HTTP 500 + test('activate_with_malicious_hooks_redirects_with_error', function () { + $hooksDir = base_path('themes/default'); + if (! is_dir($hooksDir)) { + mkdir($hooksDir, 0755, true); + } + + $hooksFile = $hooksDir . '/hooks.php'; + file_put_contents($hooksFile, ' 'default'], + [ + 'vendor' => 'opendk', + 'version' => '1.0', + 'description' => 'Default', + 'path' => $hooksDir, + 'system' => true, + 'active' => 0, + ] + ); + + $r = $this->actingAs($this->superAdmin) + ->get(route('setting.themes.activate', $theme)); + + // OBS 1 FIX: Malicious hooks now produce a friendly 302 redirect with a + // session error flash, not an HTTP 500, improving UX without hiding the failure. + $r->assertStatus(302); + $r->assertSessionHas('error'); + + @unlink($hooksFile); + }); + + // ── BUG 1 FIX: call_user_func / call_user_func_array bypass ── + + test('hooks_with_call_user_func_is_rejected', function () { + $hooksDir = base_path('themes/default'); + if (! is_dir($hooksDir)) { + mkdir($hooksDir, 0755, true); + } + + $hooksFile = $hooksDir . '/hooks.php'; + // Previously this bypassed validation because call_user_func was not in DANGEROUS_FUNCTIONS + file_put_contents($hooksFile, ' 'default'], + [ + 'vendor' => 'opendk', + 'version' => '1.0', + 'description' => 'Default', + 'path' => $hooksDir, + 'system' => true, + 'active' => 0, + ] + ); + + $r = $this->actingAs($this->superAdmin) + ->get(route('setting.themes.activate', $theme)); + + // BUG 1 FIX: call_user_func is now in DANGEROUS_FUNCTIONS → rejected + $r->assertStatus(302); + $r->assertSessionHas('error'); + + @unlink($hooksFile); + }); + + test('hooks_with_call_user_func_array_is_rejected', function () { + $hooksDir = base_path('themes/default'); + if (! is_dir($hooksDir)) { + mkdir($hooksDir, 0755, true); + } + + $hooksFile = $hooksDir . '/hooks.php'; + file_put_contents($hooksFile, ' 'default'], + [ + 'vendor' => 'opendk', + 'version' => '1.0', + 'description' => 'Default', + 'path' => $hooksDir, + 'system' => true, + 'active' => 0, + ] + ); + + $r = $this->actingAs($this->superAdmin) + ->get(route('setting.themes.activate', $theme)); + + // BUG 1 FIX: call_user_func_array is now in DANGEROUS_FUNCTIONS → rejected + $r->assertStatus(302); + $r->assertSessionHas('error'); + + @unlink($hooksFile); + }); + + // ── BUG 2 FIX: Variable function call bypass ── + + test('hooks_with_variable_function_call_is_rejected', function () { + $hooksDir = base_path('themes/default'); + if (! is_dir($hooksDir)) { + mkdir($hooksDir, 0755, true); + } + + $hooksFile = $hooksDir . '/hooks.php'; + // Previously: $f = 'system'; $f('id'); would pass validation (T_VARIABLE + '(' not detected) + file_put_contents($hooksFile, ' 'default'], + [ + 'vendor' => 'opendk', + 'version' => '1.0', + 'description' => 'Default', + 'path' => $hooksDir, + 'system' => true, + 'active' => 0, + ] + ); + + $r = $this->actingAs($this->superAdmin) + ->get(route('setting.themes.activate', $theme)); + + // BUG 2 FIX: T_VARIABLE followed by '(' is now detected → rejected + $r->assertStatus(302); + $r->assertSessionHas('error'); + + @unlink($hooksFile); + }); + + // ── Helpers ── + + function makeZipWithPhp(): UploadedFile + { + $path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid() . '.zip'; + $zip = new ZipArchive(); + $zip->open($path, ZipArchive::CREATE); + $zip->addFromString('evil-theme/composer.json', '{"name":"evil"}'); + $zip->addFromString('evil-theme/theme.json', '{"api_version":"v1"}'); + $zip->addFromString('evil-theme/hooks.php', 'close(); + + return new UploadedFile($path, 'theme.zip', 'application/zip', null, true); + } + + /** + * BUG 3 FIX: Helper — creates a ZIP with a path traversal entry. + */ + function makeZipWithPathTraversal(): UploadedFile + { + $path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid() . '.zip'; + $zip = new ZipArchive(); + $zip->open($path, ZipArchive::CREATE); + $zip->addFromString('evil-theme/composer.json', '{"name":"evil"}'); + $zip->addFromString('evil-theme/theme.json', '{"api_version":"v1"}'); + // Attempt to write outside themes/extracted/ via path traversal + $zip->addFromString('../../../storage/logs/pwned.txt', 'pwned'); + $zip->close(); + + return new UploadedFile($path, 'theme.zip', 'application/zip', null, true); + } + +})->group('security', 'theme-upload'); \ No newline at end of file diff --git a/tests/Unit/ThemesControllerSecurityTest.php b/tests/Unit/ThemesControllerSecurityTest.php new file mode 100644 index 000000000..abde0f5e9 --- /dev/null +++ b/tests/Unit/ThemesControllerSecurityTest.php @@ -0,0 +1,112 @@ +validator = new ThemeHooksValidator(); +}); + +// ── scanZipForPhp tests ── + +test('scanZipForPhp rejects ZIP containing .php file', function () { + $zipPath = sys_get_temp_dir() . '/' . uniqid() . '.zip'; + $z = new ZipArchive(); + $z->open($zipPath, ZipArchive::CREATE); + $z->addFromString('evil.php', 'close(); + + $z = new ZipArchive(); + $z->open($zipPath); + + expect(fn () => $this->validator->scanZipForPhp($z)) + ->toThrow(RuntimeException::class); + + $z->close(); + @unlink($zipPath); +}); + +test('scanZipForPhp allows clean ZIP without PHP files', function () { + $zipPath = sys_get_temp_dir() . '/' . uniqid() . '.zip'; + $z = new ZipArchive(); + $z->open($zipPath, ZipArchive::CREATE); + $z->addFromString('theme.json', '{}'); + $z->addFromString('style.css', 'body{}'); + $z->close(); + + $z = new ZipArchive(); + $z->open($zipPath); + + $this->validator->scanZipForPhp($z); + + expect(true)->toBeTrue(); // no exception = pass + + $z->close(); + @unlink($zipPath); +}); + +test('scanZipForPhp rejects PHP file in subfolder', function () { + $zipPath = sys_get_temp_dir() . '/' . uniqid() . '.zip'; + $z = new ZipArchive(); + $z->open($zipPath, ZipArchive::CREATE); + $z->addFromString('includes/backdoor.php', 'close(); + + $z = new ZipArchive(); + $z->open($zipPath); + + expect(fn () => $this->validator->scanZipForPhp($z)) + ->toThrow(RuntimeException::class); + + $z->close(); + @unlink($zipPath); +}); + +// ── validateHooksSource tests ── + +test('validateHooksSource rejects system() call', function () { + $r = $this->validator->validateSource('toBeFalse(); +}); + +test('validateHooksSource rejects exec() call', function () { + $r = $this->validator->validateSource('toBeFalse(); +}); + +test('validateHooksSource rejects eval() call', function () { + $r = $this->validator->validateSource('toBeFalse(); +}); + +test('validateHooksSource rejects file_put_contents() call', function () { + $r = $this->validator->validateSource('toBeFalse(); +}); + +test('validateHooksSource rejects shell_exec() call', function () { + $r = $this->validator->validateSource('toBeFalse(); +}); + +test('validateHooksSource allows harmless function declaration', function () { + $r = $this->validator->validateSource('toBeTrue(); +}); + +test('validateHooksSource allows array return', function () { + $r = $this->validator->validateSource(' "b"];', 'clean'); + expect($r['valid'])->toBeTrue(); +}); + +test('validateHooksSource rejects inline HTML', function () { + $r = $this->validator->validateSource('

x

toBeFalse(); +}); \ No newline at end of file