Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.89% covered (warning)
88.89%
40 / 45
25.00% covered (danger)
25.00%
1 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
ZipSecurityGuard
88.64% covered (warning)
88.64%
39 / 44
25.00% covered (danger)
25.00%
1 / 4
21.65
0.00% covered (danger)
0.00%
0 / 1
 validateArchive
84.21% covered (warning)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
8.25
 assertSafeEntryName
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
7
 assertSafePath
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 verifyChecksum
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Updater\Security;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Updater\Exception\UpdaterException;
12use ZipArchive;
13
14/**
15 * Security guard for verifying ZIP archives and preventing Zip Slip / path traversal attacks.
16 *
17 * @package App\Core\Updater\Security
18 */
19final class ZipSecurityGuard
20{
21    /**
22     * Validates that archive exists, opens cleanly, and contains no directory traversal entries.
23     *
24     * @param string $zipPath Absolute path to the ZIP archive file.
25     * @throws UpdaterException If archive is invalid or contains potentially malicious entries.
26     */
27    public function validateArchive(string $zipPath): void
28    {
29        if (!file_exists($zipPath) || !is_readable($zipPath)) {
30            throw new UpdaterException(sprintf('Update archive not found or unreadable at: %s', $zipPath));
31        }
32
33        $zip = new ZipArchive();
34        $res = $zip->open($zipPath, ZipArchive::RDONLY);
35        if ($res !== true) {
36            throw new UpdaterException(sprintf('Failed to open ZIP archive (code %d): %s', $res, $zipPath));
37        }
38
39        $hasManifest = false;
40        $numFiles = $zip->numFiles;
41
42        for ($i = 0; $i < $numFiles; $i++) {
43            $stat = $zip->statIndex($i);
44            if ($stat === false) {
45                continue;
46            }
47
48            $entryName = $stat['name'];
49            $this->assertSafeEntryName($entryName);
50
51            if ($entryName === 'manifest.json') {
52                $hasManifest = true;
53            }
54        }
55
56        $zip->close();
57
58        if (!$hasManifest) {
59            throw new UpdaterException('Archive validation failed: manifest.json is missing from root.');
60        }
61    }
62
63    /**
64     * Asserts that an archive entry name does not contain path traversal vectors.
65     *
66     * @throws UpdaterException If entry is dangerous or invalid.
67     */
68    public function assertSafeEntryName(string $name): void
69    {
70        if (str_contains($name, "\0")) {
71            throw new UpdaterException(sprintf('Malicious ZIP entry with null-byte detected: %s', $name));
72        }
73
74        $normalized = str_replace('\\', '/', $name);
75
76        if (
77            str_starts_with($normalized, '/')
78            || preg_match('/^[A-Za-z]:\//', $normalized) === 1
79            || str_contains($normalized, '../')
80            || str_contains($normalized, '/..')
81            || $normalized === '..'
82        ) {
83            throw new UpdaterException(sprintf('Malicious Zip Slip entry detected: %s', $name));
84        }
85    }
86
87    /**
88     * Computes safe target path and ensures it remains inside the target directory.
89     *
90     * @param string $targetBaseDir Base directory where files will be unpacked.
91     * @param string $relativePath Relative path from the manifest/archive.
92     * @return string Canonical safe absolute path.
93     * @throws UpdaterException If resolved path escapes target directory.
94     */
95    public function assertSafePath(string $targetBaseDir, string $relativePath): string
96    {
97        $this->assertSafeEntryName($relativePath);
98
99        $baseReal = realpath($targetBaseDir);
100        $base = $baseReal !== false ? $baseReal : rtrim(str_replace('\\', '/', $targetBaseDir), '/');
101
102        $cleanRel = ltrim(str_replace('\\', '/', $relativePath), '/');
103        $resolved = $base . '/' . $cleanRel;
104
105        $normalizedBase = str_replace('\\', '/', $base);
106        $normalizedResolved = str_replace('\\', '/', $resolved);
107
108        if (!str_starts_with($normalizedResolved, $normalizedBase . '/')) {
109            throw new UpdaterException(sprintf('Path traversal detected: %s escapes %s', $relativePath, $base));
110        }
111
112        return $resolved;
113    }
114
115    /**
116     * Verifies SHA-256 checksum of a physical file against expected hash.
117     */
118    public function verifyChecksum(string $filePath, string $expectedSha256): bool
119    {
120        if (!file_exists($filePath)) {
121            return false;
122        }
123
124        $calculated = hash_file('sha256', $filePath);
125        if ($calculated === false) {
126            return false;
127        }
128
129        return hash_equals(strtolower($expectedSha256), strtolower($calculated));
130    }
131}