JezK
Edit File: files-restore.php
<?php class wp_file_manager_files_restore { /** * Security hardening (CVE-2026-19708) - ZIP Slip protection. * * Previously every non-"wp-file-manager" entry name in the archive was * handed straight to ZipArchive::extractTo($destination, $allfiles). * While extractTo() does some internal normalisation, it is not a * substitute for validating entry names ourselves: a crafted archive * whose entries contain absolute paths, `..` traversal segments, or * backslash-based traversal (`..\..\wp-config.php`) can, depending on * PHP/libzip version, still cause files to be written outside the * intended restore directory (e.g. overwriting wp-config.php or placing * a PHP file inside a publicly-served directory). * * This version validates every entry name before extraction, rejects * the whole archive (no partial extraction) if any entry is unsafe, and * only then extracts. The resolved destination path for every entry is * verified to remain inside $destination, and a final on-disk pass * confirms nothing extracted outside the destination tree. */ public function extract($source, $destination) { if (extension_loaded('zip') !== true) { return false; } if (file_exists($source) !== true) { return false; } $zip = new ZipArchive(); $res = $zip->open($source); if ($res !== TRUE) { return false; } // Resolve/create the destination directory up front so we have a // canonical real path to contain every extracted entry within. if (!is_dir($destination)) { if (!wp_mkdir_p($destination)) { $zip->close(); return false; } } $real_destination = realpath($destination); if ($real_destination === false) { $zip->close(); return false; } $real_destination = rtrim(str_replace('\\', '/', $real_destination), '/') . '/'; $safe_entries = array(); for ($i = 0; $i < $zip->numFiles; $i++) { $filename = $zip->getNameIndex($i); if ($filename === false) { continue; } if (strpos($filename, 'wp-file-manager') !== false) { continue; } if (!$this->is_safe_zip_entry_name($filename)) { // Fail closed: abort the entire restore rather than // extracting a partial/possibly-malicious archive. $zip->close(); return false; } $target = $real_destination . ltrim(str_replace('\\', '/', $filename), '/'); // Belt-and-braces: re-verify the resolved target -- built purely // from string concatenation, without touching the filesystem -- // still resolves (lexically) inside the destination directory. if (!$this->path_is_contained($target, $real_destination)) { $zip->close(); return false; } $safe_entries[] = $filename; } // Extract only the pre-validated entry list. Every entry has already // been proven free of traversal/absolute-path segments above, and // extractTo() further normalises within libzip itself, so both // layers must agree before anything is written to disk. $extracted = $zip->extractTo($destination, $safe_entries); $zip->close(); if (!$extracted) { return false; } // Final on-disk containment check: every file that ended up under // $destination must still resolve inside it (defends against any // symlink-based trickery introduced by the extraction itself). if (!$this->verify_extracted_contents_contained($destination, $real_destination)) { return false; } $isLocal = explode(':\\', $destination); $path = count($isLocal) > 1 ? str_replace(DIRECTORY_SEPARATOR, '/', $isLocal[1]) : str_replace(DIRECTORY_SEPARATOR, '/', $isLocal[0]); if (is_dir($destination . '/' . $path)) { $is_copied = copy_dir($destination . '/' . $path, $destination); if ($is_copied) { $folderarr = explode('/', $path); if (is_dir($destination . '/' . $folderarr[0])) { $this->fm_rmdir($destination . '/' . $folderarr[0]); } return true; } } return true; } /** * Validates a single ZIP entry name before it is ever used to build a * filesystem path. Rejects anything that isn't a plain relative path. */ private function is_safe_zip_entry_name($filename) { if ($filename === '' || $filename === null) { return false; } // Reject NUL bytes and other control characters outright. if (preg_match('/[\x00-\x1F]/', $filename)) { return false; } // Normalize backslashes to forward slashes for a single set of // checks (crafted Windows-style traversal, e.g. "..\..\foo"). $normalized = str_replace('\\', '/', $filename); // Reject absolute paths (Unix "/etc/passwd", Windows "C:\...", // UNC "\\server\share"). if (isset($normalized[0]) && $normalized[0] === '/') { return false; } if (preg_match('#^[A-Za-z]:#', $normalized)) { return false; } // Reject any use of PHP stream wrappers embedded in the entry name. if (preg_match('#^[a-zA-Z][a-zA-Z0-9+.\-]*://#', $normalized)) { return false; } // Reject any ".." path segment, wherever it appears. $segments = explode('/', $normalized); foreach ($segments as $segment) { if ($segment === '..') { return false; } } return true; } /** * Purely lexical containment check (no filesystem access needed/wanted * here since the target does not exist yet): resolves "." and ".." * segments against the known-safe $base and confirms the result still * starts with $base. */ private function path_is_contained($target, $base) { $base = rtrim(str_replace('\\', '/', $base), '/') . '/'; $target = str_replace('\\', '/', $target); $parts = explode('/', $target); $resolved = array(); foreach ($parts as $part) { if ($part === '' || $part === '.') { continue; } if ($part === '..') { array_pop($resolved); continue; } $resolved[] = $part; } $resolved_path = '/' . implode('/', $resolved); return strpos($resolved_path . '/', $base) === 0 || strpos($resolved_path, $base) === 0; } /** * After extraction, walks the destination tree and confirms every * regular file/directory realpath()s to somewhere inside the * destination directory. Catches any symlink an archive entry might * have caused to be created pointing outside the tree. */ private function verify_extracted_contents_contained($destination, $real_destination) { if (!is_dir($destination)) { return true; } $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($destination, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST ); foreach ($iterator as $item) { $real_item = realpath($item->getPathname()); if ($real_item === false) { continue; } $real_item = str_replace('\\', '/', $real_item); if (strpos($real_item . '/', $real_destination) !== 0 && $real_item . '/' !== $real_destination) { return false; } } return true; } public function fm_rmdir($src) { $dir = opendir($src); while(false !== ( $file = readdir($dir)) ) { if (( $file != '.' ) && ( $file != '..' )) { $full = $src . '/' . $file; if ( is_dir($full) ) { $this->fm_rmdir($full); } else { unlink($full); } } } closedir($dir); rmdir($src); } }