VYPR
High severityGHSA Advisory· Published May 7, 2026· Updated May 7, 2026

CVE-2026-41587

CVE-2026-41587

Description

CI4MS is a CodeIgniter 4-based CMS skeleton that delivers a production-ready, modular architecture with RBAC authorization and theme support. From version 0.26.0.0 to before version 0.31.7.0, a theme upload feature allows any authenticated backend user with theme-upload permission to achieve remote code execution (RCE) by uploading a crafted ZIP file. PHP files inside the ZIP are installed into the web-accessible public/ directory with no extension or content filtering, making them directly executable via HTTP. This issue has been patched in version 0.31.7.0.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
ci4-cms-erp/ci4msPackagist
>= 0.26.0.0, < 0.31.7.00.31.7.0

Affected products

1

Patches

1
b969465e71ea

Merge commit from fork

https://github.com/ci4-cms-erp/ci4msBertuğ Fahri ÖZERApr 17, 2026via ghsa
5 files changed · +124 10
  • modules/Theme/Controllers/Theme.php+42 2 modified
    @@ -20,13 +20,53 @@ public function upload()
             $tempPath = WRITEPATH . 'tmp/' . str_replace('_theme.zip', '', $file->getName()) . '/';
             $zip = new \ZipArchive();
             if ($zip->open($file->getTempName()) === true) {
    +            // ── Security: Pre-extraction validation ──────────────────────
    +            // Allowed static file extensions for the public/ directory.
    +            // PHP files MUST NOT be written under public/ (RCE prevention).
    +            $allowedPublicExtensions = [
    +                'css', 'js', 'map',
    +                'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif',
    +                'woff', 'woff2', 'ttf', 'eot', 'otf',
    +                'xml', 'json', 'txt', 'md',
    +                'mp4', 'webm', 'ogg', 'mp3', 'wav',
    +                'pdf',
    +            ];
    +
    +            $forbiddenEntries = [];
    +
                 for ($i = 0; $i < $zip->numFiles; $i++) {
                     $entryName = $zip->getNameIndex($i);
    -                if (preg_match('/\.\./', $entryName)) {
    +
    +                // 1. Path traversal check (strengthened)
    +                if (preg_match('/\.\.[\\/\\\\]/', $entryName) || preg_match('/^[\\/\\\\]/', $entryName)) {
                         $zip->close();
    -                    return redirect()->route('backendThemes')->withInput()->with('errors', [lang('Theme.zipOpenFailed')]);
    +                    return redirect()->route('backendThemes')->withInput()
    +                        ->with('errors', [lang('Theme.pathTraversalDetected', [$entryName])]);
    +                }
    +
    +                // 2. Forbidden file extension check for public/ directory
    +                //    Skip directory entries (they end with /)
    +                if (substr($entryName, -1) === '/') {
    +                    continue;
    +                }
    +
    +                // Normalize: check files destined for public/
    +                $normalizedEntry = strtolower($entryName);
    +                if (strpos($normalizedEntry, 'public/') === 0) {
    +                    $ext = strtolower(pathinfo($entryName, PATHINFO_EXTENSION));
    +                    if (!empty($ext) && !in_array($ext, $allowedPublicExtensions, true)) {
    +                        $forbiddenEntries[] = $entryName;
    +                    }
                     }
                 }
    +
    +            if (!empty($forbiddenEntries)) {
    +                $zip->close();
    +                return redirect()->route('backendThemes')->withInput()
    +                    ->with('errors', [lang('Theme.forbiddenFileInZip', [implode(', ', $forbiddenEntries)])]);
    +            }
    +            // ── End Security ─────────────────────────────────────────────
    +
                 $zip->extractTo($tempPath);
                 $zip->close();
             } else {
    
  • modules/Theme/Helpers/themes_helper.php+45 7 modified
    @@ -28,6 +28,17 @@ function install_theme_from_tmp(string $themeName = 'ci4ms'): array
                 $baseApp    = rtrim(APPPATH, '/');
                 $basePublic = rtrim(ROOTPATH . 'public', '/');
     
    +            // ── Security: Allowed extensions for public/ directory ──
    +            $allowedPublicExtensions = [
    +                'css', 'js', 'map',
    +                'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif',
    +                'woff', 'woff2', 'ttf', 'eot', 'otf',
    +                'xml', 'json', 'txt', 'md',
    +                'mp4', 'webm', 'ogg', 'mp3', 'wav',
    +                'pdf',
    +            ];
    +            // ── End Security ────────────────────────────────────────
    +
                 $appFolders = ['Config', 'Controllers', 'Helpers', 'Libraries', 'Views', 'Database/Migrations'];
     
                 // folders under app
    @@ -42,14 +53,14 @@ function install_theme_from_tmp(string $themeName = 'ci4ms'): array
                     $log = array_merge($log, smart_move($src, $dst));
                 }
     
    -            // Move public/assets
    +            // Move public/assets (with extension filter)
                 $srcAssets = "$tmpPath/public/assets";
                 if (is_dir("$tmpPath/public/templates/$themeName/assets")) {
                     $srcAssets = "$tmpPath/public/templates/$themeName/assets";
                 }
     
                 $dstAssets = "$basePublic/templates/$themeName/assets";
    -            $log = array_merge($log, smart_move($srcAssets, $dstAssets));
    +            $log = array_merge($log, smart_move($srcAssets, $dstAssets, $allowedPublicExtensions));
     
                 // public root files (info.xml, screenshot.png etc.)
                 $publicSearchDir = "$tmpPath/public";
    @@ -60,6 +71,14 @@ function install_theme_from_tmp(string $themeName = 'ci4ms'): array
                 foreach (glob("$publicSearchDir/*.*") as $file) {
                     if (is_dir($file) || basename($file) === 'assets') continue;
     
    +                // ── Security: Filter public root files by extension ──
    +                $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
    +                if (!empty($ext) && !in_array($ext, $allowedPublicExtensions, true)) {
    +                    $log[] = "⛔ Blocked (forbidden extension): " . basename($file);
    +                    continue;
    +                }
    +                // ── End Security ─────────────────────────────────────
    +
                     $targetDir = "$basePublic/templates/$themeName";
                     if (!is_dir($targetDir)) mkdir($targetDir, 0777, true);
     
    @@ -73,7 +92,16 @@ function install_theme_from_tmp(string $themeName = 'ci4ms'): array
         }
     
         if (!function_exists('smart_move')) {
    -        function smart_move(string $source, string $target): array
    +        /**
    +         * Recursively move files from source to target directory.
    +         *
    +         * @param string     $source              Source directory path
    +         * @param string     $target              Target directory path
    +         * @param array|null $allowedExtensions    If provided, only files with these extensions will be moved.
    +         *                                        This is used as a defense-in-depth measure for public/ directory writes.
    +         * @return array     Log messages
    +         */
    +        function smart_move(string $source, string $target, ?array $allowedExtensions = null): array
             {
                 $log = [];
     
    @@ -93,16 +121,26 @@ function smart_move(string $source, string $target): array
                     if ($fileInfo->isDir()) {
                         if (!is_dir($toPath)) {
                             mkdir($toPath, 0777, true);
    -                        $log[] = "📁 Folder created: $toPath"; // Optional log
    +                        $log[] = "📁 Folder created: $toPath";
                         }
                     } else {
    +                    // ── Security: Extension filter (defense-in-depth) ──
    +                    if ($allowedExtensions !== null) {
    +                        $ext = strtolower(pathinfo($fileInfo->getFilename(), PATHINFO_EXTENSION));
    +                        if (!empty($ext) && !in_array($ext, $allowedExtensions, true)) {
    +                            $log[] = "⛔ Blocked (forbidden extension): " . $fileInfo->getFilename();
    +                            continue;
    +                        }
    +                    }
    +                    // ── End Security ───────────────────────────────────
    +
                         $toDir = dirname($toPath);
                         if (!is_dir($toDir)) {
                             mkdir($toDir, 0777, true);
                         }
     
    -                    rename($fileInfo->getPathname(), $toPath); // veya copy()
    -                    $log[] = "📄 Moved: " . $fileInfo->getFilename(); // Optional log
    +                    rename($fileInfo->getPathname(), $toPath);
    +                    $log[] = "📄 Moved: " . $fileInfo->getFilename();
                     }
                 }
     
    @@ -156,7 +194,7 @@ function remove_theme_files(string $themeName): array
                     $target = "$baseApp/$folder/templates/$themeName";
                     if (is_dir($target)) {
                         deleteFldr($target);
    -                    $log[] = lang('Theme.deleted', "app/$folder/templates/$themeName");
    +                    $log[] = lang('Theme.deleted', ["app/$folder/templates/$themeName"]);
                     }
                 }
     
    
  • modules/Theme/Language/en/Theme.php+2 0 modified
    @@ -7,4 +7,6 @@
         'foldersWithSameNameHeader' => '<h3>Folders with the same name:</h3>',
         'foldersWithSameNameMessage' => '<strong>{0}</strong> exists in the following folders:<ul>',
         'foldersWithSameNameListItem' => '<li>{0}</li>',
    +    'forbiddenFileInZip'          => 'Forbidden file type detected in the ZIP archive: {0}. Only static asset files (css, js, images, fonts, xml, json) are allowed under the public/ directory. PHP files are not permitted.',
    +    'pathTraversalDetected'       => 'Potential path traversal attack detected in the ZIP archive entry: {0}',
     ];
    
  • modules/Theme/Language/tr/Theme.php+3 1 modified
    @@ -15,5 +15,7 @@
         'themeDeletedSuccessfully' => 'Tema başarıyla kaldırıldı.',
         'themeActiveCannotDelete' => 'DİKKAT: Bu tema an itibariyle aktif! Lütfen önce başka bir temaya geçiş yapın.',
         'noTablesFound' => 'Bu temaya ait hiçbir migration/veritabanı tablosu tespit edilemedi. Sadece tema dosyaları kaldırılacaktır.',
    -    'deleted'=>'Silindi : {0}'
    +    'deleted'=>'Silindi : {0}',
    +    'forbiddenFileInZip'          => 'ZIP arşivinde yasaklı dosya türü tespit edildi: {0}. public/ dizini altında yalnızca statik dosyalara (css, js, resimler, fontlar, xml, json) izin verilir. PHP dosyalarına izin verilmez.',
    +    'pathTraversalDetected'       => 'ZIP arşiv girişinde olası dizin geçişi (path traversal) saldırısı tespit edildi: {0}',
     ];
    
  • public/templates/.htaccess+32 0 added
    @@ -0,0 +1,32 @@
    +# ──────────────────────────────────────────────────────────────
    +# Security: Prevent PHP execution in theme templates directory
    +# ──────────────────────────────────────────────────────────────
    +# This is a defense-in-depth measure to prevent Remote Code
    +# Execution (RCE) via malicious PHP files uploaded through
    +# theme ZIP archives. Even if a PHP file somehow bypasses
    +# the application-level validation, the web server will
    +# deny direct access to it.
    +# ──────────────────────────────────────────────────────────────
    +
    +<FilesMatch "\.(php|phtml|php3|php4|php5|php7|php8|phps|pht|phar|shtml|cgi)$">
    +    <IfModule mod_authz_core.c>
    +        # Apache 2.4+
    +        Require all denied
    +    </IfModule>
    +    <IfModule !mod_authz_core.c>
    +        # Apache 2.2
    +        Order Allow,Deny
    +        Deny from all
    +    </IfModule>
    +</FilesMatch>
    +
    +# Also prevent execution via handler override
    +<IfModule mod_php.c>
    +    php_flag engine off
    +</IfModule>
    +<IfModule mod_php7.c>
    +    php_flag engine off
    +</IfModule>
    +<IfModule mod_php8.c>
    +    php_flag engine off
    +</IfModule>
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.