Skip to content

Commit d3d0fd8

Browse files
committed
feat: added check to prevent Zip Slip attacks
1 parent 926bff3 commit d3d0fd8

File tree

2 files changed

+274
-1
lines changed

2 files changed

+274
-1
lines changed

phpmyfaq/src/phpMyFAQ/Setup/Upgrade.php

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,13 +223,96 @@ public function extractPackage(string $path, callable $progressCallback): bool
223223
});
224224

225225
if ($zipFile) {
226-
$zipArchive->extractTo($this->upgradeDirectory . '/new/');
226+
// Secure extraction to prevent Zip Slip vulnerability
227+
$extractPath = $this->upgradeDirectory . '/new/';
228+
$this->secureExtractZip($zipArchive, $extractPath);
227229
return $zipArchive->close();
228230
}
229231

230232
throw new Exception(message: 'Cannot open zipped download package.');
231233
}
232234

235+
/**
236+
* Securely extracts a ZIP archive, preventing Zip Slip attacks
237+
*
238+
* @param ZipArchive $zipArchive The ZIP archive to extract
239+
* @param string $destination The destination directory
240+
* @throws Exception If a malicious path is detected
241+
*/
242+
private function secureExtractZip(ZipArchive $zipArchive, string $destination): void
243+
{
244+
// Normalize destination path
245+
$destination = rtrim(realpath($destination) ?: $destination, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
246+
247+
// Create destination directory if it doesn't exist
248+
if (!is_dir($destination)) {
249+
mkdir($destination, 0755, true);
250+
}
251+
252+
// Iterate through all entries in the archive
253+
for ($i = 0; $i < $zipArchive->numFiles; $i++) {
254+
$entry = $zipArchive->getNameIndex($i);
255+
if ($entry === false) {
256+
continue;
257+
}
258+
259+
// Validate the entry path to prevent directory traversal
260+
if (!$this->isPathSafe($entry, $destination)) {
261+
$this->configuration->getLogger()->error('Zip Slip attack detected in package', [
262+
'malicious_entry' => $entry,
263+
]);
264+
throw new Exception(message: sprintf('Malicious path detected in archive: %s', $entry));
265+
}
266+
267+
// Extract individual file
268+
$zipArchive->extractTo($destination, $entry);
269+
}
270+
}
271+
272+
/**
273+
* Validates that a ZIP entry path is safe and doesn't escape the destination directory
274+
*
275+
* @param string $entryPath The path from the ZIP entry
276+
* @param string $destination The destination directory
277+
* @return bool True if path is safe, false otherwise
278+
*/
279+
private function isPathSafe(string $entryPath, string $destination): bool
280+
{
281+
// Remove any null bytes
282+
$entryPath = str_replace("\0", '', $entryPath);
283+
284+
// Build the full destination path
285+
$fullPath = $destination . $entryPath;
286+
287+
// Resolve the real path (this resolves .. and . sequences)
288+
$realPath = realpath(dirname($fullPath));
289+
if ($realPath === false) {
290+
// Path doesn't exist yet, construct it manually
291+
$realPath = realpath($destination) . DIRECTORY_SEPARATOR . dirname($entryPath);
292+
}
293+
294+
// Normalize both paths for comparison
295+
$normalizedDestination = rtrim(realpath($destination) ?: $destination, DIRECTORY_SEPARATOR);
296+
$normalizedPath = rtrim($realPath, DIRECTORY_SEPARATOR);
297+
298+
// Check if the resolved path is within the destination directory
299+
if (!str_starts_with($normalizedPath, $normalizedDestination)) {
300+
return false;
301+
}
302+
303+
// Additional check: reject paths with directory traversal sequences
304+
if (preg_match('#(\.\./)|(\.\.)|(\./)|(^/)#', $entryPath)) {
305+
return false;
306+
}
307+
308+
// Check for absolute paths (Unix and Windows)
309+
if (str_starts_with($entryPath, '/') || preg_match('#^[a-zA-Z]:#', $entryPath)) {
310+
return false;
311+
}
312+
313+
return true;
314+
}
315+
233316
/**
234317
* Method to create a temporary backup of the current files
235318
*

tests/phpMyFAQ/Setup/UpgradeTest.php

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace phpMyFAQ\Setup;
44

5+
use Monolog\Logger;
56
use phpMyFAQ\Configuration;
67
use phpMyFAQ\Core\Exception;
78
use phpMyFAQ\Database\Sqlite3;
@@ -10,14 +11,17 @@
1011
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
1112
use PHPUnit\Framework\MockObject\MockObject;
1213
use PHPUnit\Framework\TestCase;
14+
use ReflectionClass;
1315
use Symfony\Contracts\HttpClient\HttpClientInterface;
1416
use Symfony\Contracts\HttpClient\ResponseInterface;
17+
use ZipArchive;
1518

1619
#[AllowMockObjectsWithoutExpectations]
1720
class UpgradeTest extends TestCase
1821
{
1922
private Upgrade $upgrade;
2023
private HttpClientInterface $httpClientMock;
24+
private string $testDir;
2125

2226
protected function setUp(): void
2327
{
@@ -30,6 +34,43 @@ protected function setUp(): void
3034
$this->httpClientMock = $this->createMock(HttpClientInterface::class);
3135
$this->upgrade = new Upgrade(new System(), $configuration, $this->httpClientMock);
3236
$this->upgrade->setUpgradeDirectory(PMF_CONTENT_DIR . '/upgrades');
37+
38+
// Setup test directory for Zip Slip tests
39+
$this->testDir = sys_get_temp_dir() . '/zip_slip_test_' . uniqid();
40+
mkdir($this->testDir);
41+
mkdir($this->testDir . '/extract');
42+
}
43+
44+
protected function tearDown(): void
45+
{
46+
parent::tearDown();
47+
48+
// Clean up test files
49+
if (is_dir($this->testDir)) {
50+
$this->recursiveDelete($this->testDir);
51+
}
52+
}
53+
54+
private function recursiveDelete(string $dir): void
55+
{
56+
if (!is_dir($dir)) {
57+
return;
58+
}
59+
60+
$items = scandir($dir);
61+
foreach ($items as $item) {
62+
if ($item === '.' || $item === '..') {
63+
continue;
64+
}
65+
66+
$path = $dir . '/' . $item;
67+
if (is_dir($path)) {
68+
$this->recursiveDelete($path);
69+
} else {
70+
unlink($path);
71+
}
72+
}
73+
rmdir($dir);
3374
}
3475

3576
/**
@@ -125,4 +166,153 @@ public function testGetPathForNonNightly(): void
125166

126167
$this->assertEquals('', $this->upgrade->getPath());
127168
}
169+
170+
// Zip Slip vulnerability tests
171+
172+
public function testIsPathSafeRejectsDotDotSlash(): void
173+
{
174+
$reflection = new ReflectionClass($this->upgrade);
175+
$method = $reflection->getMethod('isPathSafe');
176+
177+
$result = $method->invoke($this->upgrade, '../../../etc/passwd', $this->testDir . '/extract/');
178+
$this->assertFalse($result);
179+
}
180+
181+
public function testIsPathSafeRejectsDotDot(): void
182+
{
183+
$reflection = new ReflectionClass($this->upgrade);
184+
$method = $reflection->getMethod('isPathSafe');
185+
186+
$result = $method->invoke($this->upgrade, 'subdir/../../etc/passwd', $this->testDir . '/extract/');
187+
$this->assertFalse($result);
188+
}
189+
190+
public function testIsPathSafeRejectsAbsolutePath(): void
191+
{
192+
$reflection = new ReflectionClass($this->upgrade);
193+
$method = $reflection->getMethod('isPathSafe');
194+
195+
$result = $method->invoke($this->upgrade, '/etc/passwd', $this->testDir . '/extract/');
196+
$this->assertFalse($result);
197+
}
198+
199+
public function testIsPathSafeRejectsWindowsAbsolutePath(): void
200+
{
201+
$reflection = new ReflectionClass($this->upgrade);
202+
$method = $reflection->getMethod('isPathSafe');
203+
204+
$result = $method->invoke($this->upgrade, 'C:\\Windows\\System32\\evil.exe', $this->testDir . '/extract/');
205+
$this->assertFalse($result);
206+
}
207+
208+
public function testIsPathSafeAcceptsSafePath(): void
209+
{
210+
$reflection = new ReflectionClass($this->upgrade);
211+
$method = $reflection->getMethod('isPathSafe');
212+
213+
$result = $method->invoke($this->upgrade, 'subdir/file.txt', $this->testDir . '/extract/');
214+
$this->assertTrue($result);
215+
}
216+
217+
public function testIsPathSafeAcceptsSafeNestedPath(): void
218+
{
219+
$reflection = new ReflectionClass($this->upgrade);
220+
$method = $reflection->getMethod('isPathSafe');
221+
222+
$result = $method->invoke($this->upgrade, 'a/b/c/d/file.txt', $this->testDir . '/extract/');
223+
$this->assertTrue($result);
224+
}
225+
226+
public function testIsPathSafeRemovesNullBytes(): void
227+
{
228+
$reflection = new ReflectionClass($this->upgrade);
229+
$method = $reflection->getMethod('isPathSafe');
230+
231+
$result = $method->invoke($this->upgrade, "file.txt\0.php", $this->testDir . '/extract/');
232+
// Should still be safe after null byte removal
233+
$this->assertTrue($result);
234+
}
235+
236+
public function testSecureExtractZipRejectsMaliciousArchive(): void
237+
{
238+
// Create a malicious ZIP archive
239+
$zipPath = $this->testDir . '/malicious.zip';
240+
$zip = new ZipArchive();
241+
$zip->open($zipPath, ZipArchive::CREATE);
242+
243+
// Add a file with directory traversal
244+
$zip->addFromString('../../../evil.txt', 'malicious content');
245+
$zip->close();
246+
247+
// Try to extract
248+
$zip->open($zipPath);
249+
250+
$reflection = new ReflectionClass($this->upgrade);
251+
$method = $reflection->getMethod('secureExtractZip');
252+
253+
$this->expectException(Exception::class);
254+
$this->expectExceptionMessage('Malicious path detected in archive');
255+
256+
$method->invoke($this->upgrade, $zip, $this->testDir . '/extract/');
257+
}
258+
259+
public function testSecureExtractZipAllowsSafeArchive(): void
260+
{
261+
// Create a safe ZIP archive
262+
$zipPath = $this->testDir . '/safe.zip';
263+
$zip = new ZipArchive();
264+
$zip->open($zipPath, ZipArchive::CREATE);
265+
266+
// Add safe files
267+
$zip->addFromString('file1.txt', 'content 1');
268+
$zip->addFromString('subdir/file2.txt', 'content 2');
269+
$zip->close();
270+
271+
// Extract
272+
$zip->open($zipPath);
273+
274+
$reflection = new ReflectionClass($this->upgrade);
275+
$method = $reflection->getMethod('secureExtractZip');
276+
277+
$method->invoke($this->upgrade, $zip, $this->testDir . '/extract/');
278+
$zip->close();
279+
280+
// Verify files were extracted
281+
$this->assertFileExists($this->testDir . '/extract/file1.txt');
282+
$this->assertFileExists($this->testDir . '/extract/subdir/file2.txt');
283+
$this->assertEquals('content 1', file_get_contents($this->testDir . '/extract/file1.txt'));
284+
$this->assertEquals('content 2', file_get_contents($this->testDir . '/extract/subdir/file2.txt'));
285+
}
286+
287+
public function testSecureExtractZipDoesNotEscapeDirectory(): void
288+
{
289+
// Create a malicious ZIP that tries to escape
290+
$zipPath = $this->testDir . '/escape.zip';
291+
$zip = new ZipArchive();
292+
$zip->open($zipPath, ZipArchive::CREATE);
293+
294+
// Try various escape techniques
295+
$zip->addFromString('../../outside.txt', 'should not be here');
296+
$zip->addFromString('a/../../../outside2.txt', 'should not be here');
297+
$zip->close();
298+
299+
// Try to extract
300+
$zip->open($zipPath);
301+
302+
$reflection = new ReflectionClass($this->upgrade);
303+
$method = $reflection->getMethod('secureExtractZip');
304+
305+
try {
306+
$method->invoke($this->upgrade, $zip, $this->testDir . '/extract/');
307+
$this->fail('Should have thrown an exception');
308+
} catch (Exception $e) {
309+
$this->assertStringContainsString('Malicious path detected', $e->getMessage());
310+
}
311+
312+
$zip->close();
313+
314+
// Verify files were NOT created outside the extract directory
315+
$this->assertFileDoesNotExist($this->testDir . '/outside.txt');
316+
$this->assertFileDoesNotExist($this->testDir . '/outside2.txt');
317+
}
128318
}

0 commit comments

Comments
 (0)