diff --git a/modules/update/unzip.php b/modules/update/unzip.php index 0d95a496..67e438af 100644 --- a/modules/update/unzip.php +++ b/modules/update/unzip.php @@ -27,100 +27,139 @@ function extractZip( $zipFile, $extract_path, $remove_path = '', $blacklist = '' $temp_path = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; $base_path = rtrim(getcwd(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; - if(!file_exists($extract_path)) + if (!file_exists($extract_path)) { - return "Destination path (${extract_path}) does not exists.\n"; + return "Destination path ({$extract_path}) does not exist.\n"; } - if(!is_writable($extract_path)) + if (!is_writable($extract_path)) { - return "Can't extract to ${extract_path}, not writable.\n"; + return "Can't extract to {$extract_path}, not writable.\n"; } - if($zipFile == '' or $extract_path == '') + if ($zipFile === '' || $extract_path === '') return "Invalid arguments.\n"; - if( ! file_exists( $zipFile ) ) - return "Unable to read ${zipFile}.\n"; - $zip = zip_open($zipFile); - $remove_path = addcslashes($remove_path,"/"); + if (!file_exists($zipFile)) + return "Unable to read {$zipFile}.\n"; - if (is_resource($zip)) + $zip = new ZipArchive(); + $res = $zip->open($zipFile); + if ($res !== true) { - $i=0; - $i2=0; - $extracted_files = array(); - $ignored_files = array(); - while ($zip_entry = zip_read($zip)) + return "{$zipFile} is corrupt or cannot be opened (error code: {$res}).\n"; + } + + $remove_path_escaped = addcslashes($remove_path, "/"); + + $i = 0; + $i2 = 0; + $extracted_files = array(); + $ignored_files = array(); + + // Resolve the real extraction root once for Zip Slip checks. + $real_extract_root = realpath($extract_path); + if ($real_extract_root === false) + { + $zip->close(); + return "Cannot resolve extraction path {$extract_path}.\n"; + } + $norm_root = str_replace('\\', '/', $real_extract_root); + + for ($idx = 0; $idx < $zip->numFiles; $idx++) + { + $filename = $zip->getNameIndex($idx); + $file_path = preg_replace("/{$remove_path_escaped}/", '', $filename); + $dir_path = preg_replace("/{$remove_path_escaped}/", '', dirname($filename)); + + // Zip Slip protection: reject entries with path traversal or absolute paths. + $norm_filename = str_replace('\\', '/', $filename); + if (strpos($norm_filename, '../') !== false || substr($norm_filename, 0, 1) === '/') { - $filename = zip_entry_name( $zip_entry ); - $file_path = preg_replace( "/$remove_path/", "", $filename ); - $dir_path = preg_replace( "/$remove_path/", "", dirname( $filename ) ); + continue; + } - if( isset( $blacklist ) and is_array( $blacklist ) and in_array( $file_path , $blacklist ) ) + if (isset($blacklist) && is_array($blacklist) && in_array($file_path, $blacklist)) + { + if (isset($whitelist) && is_array($whitelist) && in_array($filename, $whitelist)) { - if( isset( $whitelist ) and is_array( $whitelist ) and in_array( $filename , $whitelist ) ) - { - $ignored_files[$i2] = $file_path; - $i2++; - } - continue; + $ignored_files[$i2] = $file_path; + $i2++; } - if( isset( $whitelist ) and is_array( $whitelist ) and !in_array( $filename , $whitelist ) ) - continue; + continue; + } + if (isset($whitelist) && is_array($whitelist) && !in_array($filename, $whitelist)) + continue; - $completePath = $extract_path . $dir_path; - $completeName = $extract_path . $file_path; - $escaped_temp_path = str_replace('\\', '\\\\', $temp_path);// For Windows paths backslashes - $root = preg_match("#^$escaped_temp_path#", $completePath)?$temp_path:$base_path; - $escaped_root = str_replace('\\', '\\\\', $root); - $relative_path = preg_replace("#^$escaped_root(.*)$#","$1",$completePath); + $completePath = $extract_path . $dir_path; + $completeName = $extract_path . $file_path; + $escaped_temp_path = str_replace('\\', '\\\\', $temp_path); // For Windows paths backslashes + $root = preg_match("#^{$escaped_temp_path}#", $completePath) ? $temp_path : $base_path; + $escaped_root = str_replace('\\', '\\\\', $root); + $relative_path = preg_replace("#^{$escaped_root}(.*)$#", '$1', $completePath); - // Walk through path to create non existing directories - // This won't apply to empty directories ! They are created further below - if(!file_exists($completePath) && preg_match( '/^' . $remove_path .'/', dirname(zip_entry_name($zip_entry)) ) ) + // Cache this repeated check to avoid duplication. + $dirname_in_path = preg_match('/^' . $remove_path_escaped . '/', dirname($filename)); + + // Walk through path to create non-existing directories. + // This won't apply to empty directories – they are created further below. + if (!file_exists($completePath) && $dirname_in_path) + { + $tmp = $root; + foreach (preg_split('/(\/|\\\\)/', $relative_path) as $k) { - $tmp = $root; - foreach(preg_split('/(\/|\\\\)/',$relative_path) AS $k) + if ($k !== '') { - if( $k != "" ) + $tmp .= $k . DIRECTORY_SEPARATOR; + if (!file_exists($tmp)) { - $tmp .= $k.DIRECTORY_SEPARATOR; - if( !file_exists($tmp) ) + if (!mkdir($tmp, 0777)) { - if(!mkdir($tmp, 0777)) - { - return "Unable to write folder ${tmp}.\n"; - } + $zip->close(); + return "Unable to write folder {$tmp}.\n"; } } } } - - if (zip_entry_open($zip, $zip_entry, "r")) - { - if( preg_match( '/^' . $remove_path .'/', dirname(zip_entry_name($zip_entry)) ) ) - { - if ( ! preg_match( "/\/$/", $completeName) ) - { - if ( $fd = fopen($completeName, 'w+')) - { - fwrite($fd, zip_entry_read($zip_entry, zip_entry_filesize($zip_entry))); - fclose($fd); - $extracted_files[$i]['filename'] = zip_entry_name($zip_entry); - $i++; - } - else - { - return "Unable to write file ${completeName}.\n"; - } - } - } - zip_entry_close($zip_entry); - } } - zip_close($zip); - return array('ignored_files' => $ignored_files, 'extracted_files' => $extracted_files); + + if ($dirname_in_path) + { + if (!preg_match('/\/$/', $completeName)) + { + // Secondary Zip Slip check: verify the destination path (after normalizing + // the string, since the file may not exist yet) stays within the extraction + // root. Because we already blocked '../' in the entry name above, this is + // belt-and-suspenders for path-manipulation edge-cases. + $norm_complete = str_replace('\\', '/', $completeName); + if (strpos($norm_complete . '/', $norm_root . '/') !== 0) + { + continue; // Path escapes the extraction root – skip. + } + + // Stream the entry to disk without loading it entirely into memory. + $stream = $zip->getStream($filename); + if ($stream === false) + { + $zip->close(); + return "Unable to read entry {$filename} from ZIP.\n"; + } + $fd = fopen($completeName, 'w+'); + if ($fd === false) + { + fclose($stream); + $zip->close(); + return "Unable to write file {$completeName}.\n"; + } + stream_copy_to_stream($stream, $fd); + fclose($stream); + fclose($fd); + $extracted_files[$i]['filename'] = $filename; + $i++; + } + } } - return "${zipFile} is corrupt.\n"; + + $zip->close(); + return array('ignored_files' => $ignored_files, 'extracted_files' => $extracted_files); } ?> \ No newline at end of file