config = $config ?? new Config(['modules' => []]); } /** * @param string $tmpPath * @param string $origName * @param array $hashes * @return array */ public function quarantineFile(string $tmpPath, string $origName, array $hashes): array { $enabled = $this->config->isModuleEnabled('quarantine') && (bool)$this->config->get('ops.quarantine_enabled', true); $quarantineDir = (string)$this->config->get('paths.quarantine_dir', __DIR__ . '/../../quarantine'); if (!$enabled) return ['ok' => false, 'path' => '']; if (!is_uploaded_file($tmpPath)) return ['ok' => false, 'path' => '']; if (!is_dir($quarantineDir)) return ['ok' => false, 'path' => '']; $ext = strtolower((string)pathinfo($origName, PATHINFO_EXTENSION)); if (!preg_match('/^[a-z0-9]{1,10}$/', $ext)) { $ext = ''; } $base = $hashes['sha1'] ?? ''; if ($base === '') { try { $base = bin2hex(random_bytes(16)); } catch (\Throwable $e) { $base = uniqid('q', true); } } $dest = rtrim($quarantineDir, '/\\') . '/' . $base . ($ext ? '.' . $ext : ''); $ok = @move_uploaded_file($tmpPath, $dest); if ($ok) { @chmod($dest, 0600); return ['ok' => true, 'path' => $dest]; } return ['ok' => false, 'path' => $dest]; } /** * @param string $path * @return array */ public function inspectArchiveQuarantine(string $path): array { $maxEntries = (int)$this->config->get('limits.archive_max_entries', 200); $maxInspectSize = (int)$this->config->get('limits.archive_max_inspect_size', 50 * 1024 * 1024); $fsz = @filesize($path); if ($fsz !== false && $fsz > $maxInspectSize) { return ['entries' => 0, 'suspicious_entries' => [], 'unsupported' => false, 'too_large' => true]; } $out = ['entries' => 0, 'suspicious_entries' => [], 'unsupported' => false]; if (!is_file($path)) { $out['unsupported'] = true; return $out; } $lower = strtolower($path); if (class_exists('ZipArchive') && preg_match('/\.zip$/i', $lower)) { $za = new \ZipArchive(); if ($za->open($path) === true) { $cnt = $za->numFiles; $out['entries'] = min($cnt, $maxEntries); $limit = $out['entries']; for ($i = 0; $i < $limit; $i++) { $stat = $za->statIndex($i); if (is_array($stat)) { $name = $stat['name']; $entry = ['name' => $name, 'suspicious' => false, 'reason' => null]; if (strpos($name, '../') !== false || strpos($name, '..\\') !== false || strpos($name, '/') === 0 || strpos($name, '\\') === 0) { $entry['suspicious'] = true; $entry['reason'] = 'path_traversal'; } if (preg_match('/\.(php|phtml|phar|php\d|pl|cgi|sh|asp|aspx|jsp)$/i', $name)) { $entry['suspicious'] = true; $entry['reason'] = ($entry['reason'] ? $entry['reason'] . ',ext' : 'ext'); } if ($entry['suspicious']) $out['suspicious_entries'][] = $entry; } } $za->close(); } else { $out['unsupported'] = true; } return $out; } if (class_exists('PharData') && preg_match('/\.(tar|tar\.gz|tgz|tar\.bz2)$/i', $lower)) { try { $ph = new \PharData($path); $it = new \RecursiveIteratorIterator($ph); $count = 0; foreach ($it as $file) { if ($count++ >= $maxEntries) break; $name = (string)$file; $entry = ['name' => $name, 'suspicious' => false, 'reason' => null]; if (strpos($name, '../') !== false || strpos($name, '..\\') !== false || strpos($name, '/') === 0 || strpos($name, '\\') === 0) { $entry['suspicious'] = true; $entry['reason'] = 'path_traversal'; } if (preg_match('/\.(php|phtml|phar|php\d|pl|cgi|sh|asp|aspx|jsp)$/i', $name)) { $entry['suspicious'] = true; $entry['reason'] = ($entry['reason'] ? $entry['reason'] . ',ext' : 'ext'); } if ($entry['suspicious']) $out['suspicious_entries'][] = $entry; } $out['entries'] = $count; } catch (\Exception $e) { $out['unsupported'] = true; } return $out; } $out['unsupported'] = true; return $out; } }