22
33namespace phpMyFAQ \Setup ;
44
5+ use Monolog \Logger ;
56use phpMyFAQ \Configuration ;
67use phpMyFAQ \Core \Exception ;
78use phpMyFAQ \Database \Sqlite3 ;
1011use PHPUnit \Framework \Attributes \AllowMockObjectsWithoutExpectations ;
1112use PHPUnit \Framework \MockObject \MockObject ;
1213use PHPUnit \Framework \TestCase ;
14+ use ReflectionClass ;
1315use Symfony \Contracts \HttpClient \HttpClientInterface ;
1416use Symfony \Contracts \HttpClient \ResponseInterface ;
17+ use ZipArchive ;
1518
1619#[AllowMockObjectsWithoutExpectations]
1720class 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