Harden quarantine provisioning; enforce strict permissions and update Ansible and docs
This commit is contained in:
136
core/Services/QuarantineService.php
Normal file
136
core/Services/QuarantineService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user