├── tests └── VIPSoft │ └── Unzip │ └── Tests │ ├── fixtures │ ├── zaabs.zip │ ├── zaatt.zip │ └── relative.zip │ └── UnzipTest.php ├── bootstrap.php ├── composer.json ├── README.md ├── phpunit.xml.dist ├── LICENSE └── src └── VIPSoft └── Unzip └── Unzip.php /tests/VIPSoft/Unzip/Tests/fixtures/zaabs.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vipsoft/Unzip/HEAD/tests/VIPSoft/Unzip/Tests/fixtures/zaabs.zip -------------------------------------------------------------------------------- /tests/VIPSoft/Unzip/Tests/fixtures/zaatt.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vipsoft/Unzip/HEAD/tests/VIPSoft/Unzip/Tests/fixtures/zaatt.zip -------------------------------------------------------------------------------- /tests/VIPSoft/Unzip/Tests/fixtures/relative.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vipsoft/Unzip/HEAD/tests/VIPSoft/Unzip/Tests/fixtures/relative.zip -------------------------------------------------------------------------------- /bootstrap.php: -------------------------------------------------------------------------------- 1 | =5.3.6", 17 | "ext-zip": "*" 18 | }, 19 | "autoload": { 20 | "psr-0": { "VIPSoft\\Unzip": "src/" } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VIPSoft\Unzip 2 | 3 | A wrapper around ZipArchive to extract .zip files. 4 | 5 | This is a refactoring of my Piwik_Unzip_ZipArchive class. It now requires 6 | php 5.3.6+, uses namespaces, adheres to object calisthenics, and is re-licensed 7 | as MIT. 8 | 9 | ## Features 10 | 11 | * Simple to use! 12 | 13 | ```php 14 | use VIPSoft\Unzip\Unzip; 15 | 16 | $unzipper = new Unzip(); 17 | $filenames = $unzipper->extract($zipFilePath, $extractToThisDir); 18 | ``` 19 | 20 | * Guards against malicious filenames in the archive 21 | 22 | ## Copyright 23 | 24 | Copyright (c) 2010-2012 Anthon Pang. See LICENSE for details. 25 | 26 | ## Contributors 27 | 28 | * Anthon Pang [robocoder](http://github.com/robocoder) 29 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 16 | 17 | tests 18 | 19 | 20 | 21 | 22 | 23 | src 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Anthon Pang 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /tests/VIPSoft/Unzip/Tests/UnzipTest.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class UnzipTest extends \PHPUnit_Framework_TestCase 17 | { 18 | /** 19 | * {@inheritdoc} 20 | */ 21 | protected function setUp() 22 | { 23 | if (!class_exists('ZipArchive', false)) { 24 | $this->markTestSkipped('ZipArchive class does not exist'); 25 | } 26 | 27 | clearstatcache(); 28 | } 29 | 30 | /** 31 | * Normal case where file is relative to current directory 32 | */ 33 | public function test_relativePath() 34 | { 35 | $extractDir = __DIR__ . '/fixtures/tmp/'; 36 | $test = 'relative'; 37 | $filename = __DIR__ . '/fixtures/'.$test.'.zip'; 38 | 39 | $unzip = new Unzip(); 40 | 41 | $res = $unzip->extract($filename, $extractDir); 42 | 43 | $this->assertEquals(1, count($res)); 44 | $this->assertFileExists($extractDir . $test . '.txt'); 45 | $this->assertFileNotExists(dirname(__FILE__) . '/' . $test . '.txt'); 46 | $this->assertFileNotExists(dirname(__FILE__) . '/../../tests/' . $test . '.txt'); 47 | 48 | unlink($extractDir . $test . '.txt'); 49 | } 50 | 51 | /** 52 | * .zip file contains a file which attempts to navigate to a parent directory (i.e., '..') 53 | */ 54 | public function test_relativePathAttack() 55 | { 56 | $extractDir = __DIR__ . '/fixtures/tmp/'; 57 | $test = 'zaatt'; 58 | $filename = __DIR__ . '/fixtures/'.$test.'.zip'; 59 | 60 | $unzip = new Unzip(); 61 | 62 | try { 63 | $res = $unzip->extract($filename, $extractDir); 64 | $this->fail(); 65 | } catch (\Exception $e) { 66 | $this->assertEquals('Invalid filename path in zip archive', $e->getMessage()); 67 | } 68 | 69 | $this->assertFalse(isset($res)); 70 | $this->assertFileNotExists($extractDir . $test . '.txt'); 71 | $this->assertFileNotExists($extractDir . '../' . $test . '.txt'); 72 | $this->assertFileNotExists(dirname(__FILE__) . '/' . $test . '.txt'); 73 | $this->assertFileNotExists(dirname(__FILE__) . '/../' . $test . '.txt'); 74 | $this->assertFileNotExists(dirname(__FILE__) . '/../../' . $test . '.txt'); 75 | } 76 | 77 | /** 78 | * .zip file contains a file with an absolute path 79 | */ 80 | public function test_absolutePathAttack() 81 | { 82 | $extractDir = __DIR__ . '/fixtures/tmp/'; 83 | $test = 'zaabs'; 84 | $filename = __DIR__ . '/fixtures/'.$test.'.zip'; 85 | 86 | $unzip = new Unzip(); 87 | 88 | try { 89 | $res = $unzip->extract($filename, $extractDir); 90 | $this->fail(); 91 | } catch (\Exception $e) { 92 | $this->assertEquals('Invalid filename path in zip archive', $e->getMessage()); 93 | } 94 | 95 | $this->assertFalse(isset($res)); 96 | $this->assertFileNotExists($extractDir . $test . '.txt'); 97 | $this->assertFileNotExists(dirname(__FILE__) . '/' . $test . '.txt'); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/VIPSoft/Unzip/Unzip.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class Unzip 15 | { 16 | /** 17 | * @var array 18 | */ 19 | private static $statusStrings = array( 20 | \ZipArchive::ER_OK => 'No error', 21 | \ZipArchive::ER_MULTIDISK => 'Multi-disk zip archives not supported', 22 | \ZipArchive::ER_RENAME => 'Renaming temporary file failed', 23 | \ZipArchive::ER_CLOSE => 'Closing zip archive failed', 24 | \ZipArchive::ER_SEEK => 'Seek error', 25 | \ZipArchive::ER_READ => 'Read error', 26 | \ZipArchive::ER_WRITE => 'Write error', 27 | \ZipArchive::ER_CRC => 'CRC error', 28 | \ZipArchive::ER_ZIPCLOSED => 'Containing zip archive was closed', 29 | \ZipArchive::ER_NOENT => 'No such file', 30 | \ZipArchive::ER_EXISTS => 'File already exists', 31 | \ZipArchive::ER_OPEN => 'Can\'t open file', 32 | \ZipArchive::ER_TMPOPEN => 'Failure to create temporary file', 33 | \ZipArchive::ER_ZLIB => 'Zlib error', 34 | \ZipArchive::ER_MEMORY => 'Malloc failure', 35 | \ZipArchive::ER_CHANGED => 'Entry has been changed', 36 | \ZipArchive::ER_COMPNOTSUPP => 'Compression method not supported', 37 | \ZipArchive::ER_EOF => 'Premature EOF', 38 | \ZipArchive::ER_INVAL => 'Invalid argument', 39 | \ZipArchive::ER_NOZIP => 'Not a zip archive', 40 | \ZipArchive::ER_INTERNAL => 'Internal error', 41 | \ZipArchive::ER_INCONS => 'Zip archive inconsistent', 42 | \ZipArchive::ER_REMOVE => 'Can\'t remove file', 43 | \ZipArchive::ER_DELETED => 'Entry has been deleted', 44 | ); 45 | 46 | /** 47 | * @var boolean 48 | */ 49 | private $continueOnError; 50 | 51 | /** 52 | * Extract zip file to target path 53 | * 54 | * @param string $zipFile Path of .zip file 55 | * @param string $targetPath Extract to this target (destination) path 56 | * @param boolean $continueOnError Continue extracting files on error 57 | * 58 | * @return mixed Array of filenames corresponding to the extracted files 59 | * 60 | * @throw \Exception 61 | */ 62 | public function extract($zipFile, $targetPath, $continueOnError = false) 63 | { 64 | $this->continueOnError = $continueOnError; 65 | 66 | $zipArchive = $this->openZipFile($zipFile); 67 | $targetPath = $this->fixPath($targetPath); 68 | $filenames = $this->extractFilenames($zipArchive); 69 | 70 | if ($zipArchive->extractTo($targetPath, $filenames) === false) { 71 | throw new \Exception($this->getStatusAsText($zipArchive->status)); 72 | } 73 | 74 | $zipArchive->close(); 75 | 76 | return $filenames; 77 | } 78 | 79 | /** 80 | * Make sure target path ends in '/' 81 | * 82 | * @param string $path 83 | * 84 | * @return string 85 | */ 86 | private function fixPath($path) 87 | { 88 | if (substr($path, -1) === '/') { 89 | $path .= '/'; 90 | } 91 | 92 | return $path; 93 | } 94 | 95 | /** 96 | * Open .zip archive 97 | * 98 | * @param string $zipFile 99 | * 100 | * @return \ZipArchive 101 | * 102 | * @throw \Exception 103 | */ 104 | private function openZipFile($zipFile) 105 | { 106 | $zipArchive = new \ZipArchive; 107 | $status = $zipArchive->open($zipFile); 108 | 109 | if ($status !== true) { 110 | throw new \Exception($this->getStatusAsText($status) . ": $zipFile"); 111 | } 112 | 113 | return $zipArchive; 114 | } 115 | 116 | /** 117 | * Extract list of filenames from .zip 118 | * 119 | * @param \ZipArchive $zipArchive 120 | * 121 | * @return array 122 | */ 123 | private function extractFilenames(\ZipArchive $zipArchive) 124 | { 125 | $filenames = array(); 126 | $fileCount = $zipArchive->numFiles; 127 | 128 | for ($i = 0; $i < $fileCount; $i++) { 129 | $filename = $this->extractFilename($zipArchive, $i); 130 | 131 | if ($filename !== false) { 132 | $filenames[] = $filename; 133 | } 134 | } 135 | 136 | return $filenames; 137 | } 138 | 139 | /** 140 | * Test for valid filename path 141 | * 142 | * The .zip file is untrusted input. We check for absolute path (i.e., leading slash), 143 | * possible directory traversal attack (i.e., '..'), and use of PHP wrappers (i.e., ':'). 144 | * Subclass and override this method at your own risk! 145 | * 146 | * @param string $path 147 | * 148 | * @return boolean 149 | */ 150 | protected function isValidPath($path) 151 | { 152 | $pathParts = explode('/', $path); 153 | 154 | if (strncmp($path, '/', 1) === 0 || 155 | array_search('..', $pathParts) !== false || 156 | strpos($path, ':') !== false 157 | ) { 158 | return false; 159 | } 160 | 161 | return true; 162 | } 163 | 164 | /** 165 | * Extract filename from .zip 166 | * 167 | * @param \ZipArchive $zipArchive Zip file 168 | * @param integer $fileIndex File index 169 | * 170 | * @return string 171 | * 172 | * @throw \Exception 173 | */ 174 | private function extractFilename(\ZipArchive $zipArchive, $fileIndex) 175 | { 176 | $entry = $zipArchive->statIndex($fileIndex); 177 | 178 | // convert Windows directory separator to Unix style 179 | $filename = str_replace('\\', '/', $entry['name']); 180 | 181 | if ($this->isValidPath($filename)) { 182 | return $filename; 183 | } 184 | 185 | $statusText = "Invalid filename path in zip archive: $filename"; 186 | 187 | if ($this->continueOnError) { 188 | trigger_error($statusText); 189 | 190 | return false; 191 | } 192 | 193 | throw new \Exception($statusText); 194 | } 195 | 196 | /** 197 | * Get status as text string 198 | * 199 | * @param integer $status ZipArchive status 200 | * 201 | * @return string 202 | */ 203 | private function getStatusAsText($status) 204 | { 205 | $statusString = isset($this->statusStrings[$status]) 206 | ? $this->statusStrings[$status] 207 | : 'Unknown status'; 208 | 209 | return $statusString . '(' . $status . ')'; 210 | } 211 | } 212 | --------------------------------------------------------------------------------