Harden quarantine provisioning; enforce strict permissions and update Ansible and docs

This commit is contained in:
2026-02-12 07:47:48 +01:00
parent 037b176892
commit 1768f61da1
44 changed files with 2587 additions and 698 deletions

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace UploadLogger\Core\Services;
use UploadLogger\Core\Config;
final class QuarantineService
{
private Config $config;
public function __construct(?Config $config = null)
{
$this->config = $config ?? new Config(['modules' => []]);
}
/**
* @param string $tmpPath
* @param string $origName
* @param array<string,string> $hashes
* @return array<string,mixed>
*/
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<string,mixed>
*/
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;
}
}