max_uncompressed_size = $max_uncompressed_size; $this->max_entry_count = $max_entry_count; $this->max_compression_ratio = $max_compression_ratio; } /** * Best-effort listing of entry names without extracting or validating * anything — used to explain *why* a rejected archive was rejected * (e.g. it's a mobile app export, not oversized-by-accident) when * extract() itself refused to touch it. Reading just names via * statIndex() is cheap and safe regardless of archive size, since * nothing is written to disk. * * @param string $zip_path Path to the ZIP file. * @param int $limit Maximum number of entry names to return. * * @return string[] Entry names, or an empty array if the archive * couldn't even be opened. */ public function peek_entry_names( $zip_path, $limit = 2000 ) { $zip = new \ZipArchive(); if ( true !== $zip->open( $zip_path ) ) { return array(); } $names = array(); $count = min( $zip->numFiles, $limit ); for ( $i = 0; $i < $count; $i++ ) { $stat = $zip->statIndex( $i ); if ( false !== $stat ) { $names[] = $stat['name']; } } $zip->close(); return $names; } /** * Extract a ZIP archive into $dest_dir. * * @param string $zip_path Path to the ZIP file. * @param string $dest_dir Destination directory (created if missing). * * @return string[] Relative paths (within $dest_dir) of extracted files. * * @throws Zip_Extraction_Exception On any validation failure. $dest_dir * is cleaned up before the exception * propagates. */ public function extract( $zip_path, $dest_dir ) { if ( ! is_readable( $zip_path ) ) { throw new Zip_Extraction_Exception( esc_html( sprintf( 'ZIP file is not readable: %s', $zip_path ) ) ); } $zip = new \ZipArchive(); $res = $zip->open( $zip_path ); if ( true !== $res ) { throw new Zip_Extraction_Exception( esc_html( sprintf( 'Unable to open ZIP file (libzip error code %s).', $res ) ) ); } $dest_dir = rtrim( str_replace( '\\', '/', $dest_dir ), '/' ); if ( ! is_dir( $dest_dir ) && ! wp_mkdir_p( $dest_dir ) ) { $zip->close(); throw new Zip_Extraction_Exception( esc_html( sprintf( 'Unable to create destination directory: %s', $dest_dir ) ) ); } try { return $this->extract_entries( $zip, $dest_dir ); } catch ( Zip_Extraction_Exception $e ) { $this->remove_directory( $dest_dir ); throw $e; } finally { $zip->close(); } } /** * Validate and write every entry in the archive. * * @param \ZipArchive $zip Open archive. * @param string $dest_dir Destination directory, no trailing slash. * * @return string[] Relative paths of extracted files. * * @throws Zip_Extraction_Exception On any validation failure. */ private function extract_entries( \ZipArchive $zip, $dest_dir ) { $entry_count = $zip->numFiles; if ( $entry_count > $this->max_entry_count ) { throw new Zip_Extraction_Exception( esc_html( sprintf( 'Archive contains %1$d entries, exceeding the limit of %2$d.', $entry_count, $this->max_entry_count ) ) ); } $total_uncompressed = 0; $extracted = array(); for ( $i = 0; $i < $entry_count; $i++ ) { $stat = $zip->statIndex( $i ); if ( false === $stat ) { throw new Zip_Extraction_Exception( esc_html( sprintf( 'Unable to read archive entry at index %d.', $i ) ) ); } $name = str_replace( '\\', '/', $stat['name'] ); if ( ! $this->is_safe_entry_name( $name ) ) { throw new Zip_Extraction_Exception( esc_html( sprintf( 'Unsafe entry path in archive: %s', $name ) ) ); } if ( $this->is_symlink_entry( $zip, $i ) ) { throw new Zip_Extraction_Exception( esc_html( sprintf( 'Symlink entries are not permitted: %s', $name ) ) ); } // Directory entry: nothing to write, just ensure it exists. if ( '/' === substr( $name, -1 ) ) { $dir = $dest_dir . '/' . rtrim( $name, '/' ); if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { throw new Zip_Extraction_Exception( esc_html( sprintf( 'Unable to create directory: %s', $name ) ) ); } continue; } $size = isset( $stat['size'] ) ? (int) $stat['size'] : 0; $comp_size = isset( $stat['comp_size'] ) ? (int) $stat['comp_size'] : 0; $total_uncompressed += $size; if ( $total_uncompressed > $this->max_uncompressed_size ) { throw new Zip_Extraction_Exception( esc_html( sprintf( 'Archive exceeds the maximum total uncompressed size of %d bytes.', $this->max_uncompressed_size ) ) ); } if ( $this->exceeds_compression_ratio( $size, $comp_size ) ) { throw new Zip_Extraction_Exception( esc_html( sprintf( "Entry '%s' has a suspicious compression ratio and was rejected as a possible zip bomb.", $name ) ) ); } $dest_path = $dest_dir . '/' . $name; $parent = dirname( $dest_path ); if ( ! is_dir( $parent ) && ! wp_mkdir_p( $parent ) ) { throw new Zip_Extraction_Exception( esc_html( sprintf( 'Unable to create directory for entry: %s', $name ) ) ); } $this->write_entry( $zip, $name, $dest_path ); $extracted[] = $name; } return $extracted; } /** * Write a single entry's contents to disk via WP_Filesystem. Never * uses extractTo(), so the archive can never cause a symlink (or * anything other than a plain regular file) to be created. The entry * has already passed the per-entry size and compression-ratio gates * in extract_entries() by the time this runs, so reading it in full * here can't be used as a zip-bomb vector. * * @param \ZipArchive $zip Open archive. * @param string $name Entry name (already validated safe). * @param string $dest_path Absolute destination file path. * * @throws Zip_Extraction_Exception If the entry cannot be read or written. */ private function write_entry( \ZipArchive $zip, $name, $dest_path ) { $contents = $zip->getFromName( $name ); if ( false === $contents ) { throw new Zip_Extraction_Exception( esc_html( sprintf( 'Unable to read archive entry: %s', $name ) ) ); } if ( ! $this->get_filesystem()->put_contents( $dest_path, $contents, FS_CHMOD_FILE ) ) { throw new Zip_Extraction_Exception( esc_html( sprintf( 'Unable to write extracted file: %s', $name ) ) ); } } /** * Initialize WP_Filesystem for direct, credential-less access. * * @return \WP_Filesystem_Base * * @throws Zip_Extraction_Exception If the filesystem can't be initialized. */ private function get_filesystem() { global $wp_filesystem; if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } if ( ! WP_Filesystem() || ! $wp_filesystem ) { throw new Zip_Extraction_Exception( esc_html__( 'Unable to initialize the WordPress filesystem.', 'esipher' ) ); } return $wp_filesystem; } /** * Reject absolute paths, Windows drive-letter paths, and any path * traversal ('..') segment. * * @param string $name Archive entry name. * * @return bool True if the name is safe to extract under the dest dir. */ private function is_safe_entry_name( $name ) { if ( '' === $name ) { return false; } if ( '/' === $name[0] ) { return false; } if ( preg_match( '#^[A-Za-z]:#', $name ) ) { return false; } foreach ( explode( '/', $name ) as $segment ) { if ( '..' === $segment ) { return false; } } return true; } /** * Detect a Unix symlink entry via ZipArchive's external file * attributes. Non-Unix-authored archives have no such bit to check, * but since entries are always written via write_entry() (never * extractTo()), an undetected symlink entry can still only ever * produce a plain regular file containing the link-target text. * * @param \ZipArchive $zip Open archive. * @param int $index Entry index. * * @return bool True if the entry is flagged as a Unix symlink. */ private function is_symlink_entry( \ZipArchive $zip, $index ) { $opsys = 0; $attr = 0; if ( ! $zip->getExternalAttributesIndex( $index, $opsys, $attr ) ) { return false; } if ( \ZipArchive::OPSYS_UNIX !== $opsys ) { return false; } $unix_mode = ( $attr >> 16 ) & 0xFFFF; return 0xA000 === ( $unix_mode & 0xF000 ); } /** * Zip-bomb heuristic: an entry whose uncompressed size is * disproportionately larger than its compressed size. * * @param int $size Uncompressed size in bytes. * @param int $comp_size Compressed size in bytes. * * @return bool True if the ratio exceeds the configured maximum. */ private function exceeds_compression_ratio( $size, $comp_size ) { if ( 0 === $size ) { return false; } if ( 0 === $comp_size ) { // Non-zero uncompressed size claimed from zero compressed bytes. return true; } return ( $size / $comp_size ) > $this->max_compression_ratio; } /** * Remove a directory tree, used to clean up on failure. * * @param string $dir Directory to remove. */ private function remove_directory( $dir ) { if ( ! is_dir( $dir ) ) { return; } try { $this->get_filesystem()->delete( $dir, true, 'd' ); } catch ( Zip_Extraction_Exception $e ) { // Best-effort cleanup — a leftover temp directory under the // system temp dir isn't worth surfacing over the real error // that triggered this cleanup. return; } } }