mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 17:15:08 +00:00
c15c9c48f8
Run against the dev stack with real ClamAV and queue workers, and a code review looking for ways around the scanner. Quarantine now stays quarantined until somebody releases the file. A rescan only touches files people can download, and changes nothing when the scanner cannot answer or scanning is off. Before, an old infected file rescanned while clamd restarted went through the "allow" policy and became downloadable. The daily missing-files check leaves quarantined files alone, so a storage outage no longer brings one back as a fresh upload. A file longer than clamd's StreamMaxLength is "too large" again. clamd answers and hangs up; the next write raised a warning that became an exception before the answer was read, so the file was recorded as "scanner down" and retried past the unscannable policy. The production compose example gives clamd the settings it needs. On its own defaults an encrypted zip comes back clean. The Test button now sends a password-protected zip and fails when it is called clean, and says when an address answers but is not ClamAV. Saving the settings restarts the queue workers, which kept the old values in memory. New scan runs --all, as its name says, and is refused while scans are queued. A retry scheduled for later no longer counts as a scan in progress. Also: quarantine respects client scope for listing, release and notifications; a zip built before a file was quarantined is refused; public comments and version links skip unavailable files; a client no longer sees their own quarantined or missing upload; a file whose bytes return is scanned at once; clamd listens on IPv6 too, so its container health check passes.
142 lines
5.1 KiB
PHP
142 lines
5.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Files\Http\Controllers;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\User;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLogger;
|
|
use App\Modules\Files\Access\StaffLibraryScope;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Files\Scanning\FileAvailability;
|
|
use App\Modules\Files\Scanning\NotScannedReason;
|
|
use App\Modules\Files\Scanning\ScanStatus;
|
|
use App\Support\Pagination;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
/**
|
|
* The files the virus scanner refused, and the one decision a person can
|
|
* make about them.
|
|
*
|
|
* Nothing is deleted here automatically and nothing expires out of this
|
|
* list: a quarantined file waits for somebody. Deleting one is the
|
|
* ordinary file deletion, with its ordinary permission — this screen only
|
|
* adds the other answer, which is that the scanner was wrong.
|
|
*
|
|
* Releasing is gated by a permission of its own that only the
|
|
* administrator role holds by default, and by password confirmation on
|
|
* top of it, because it is the one action in the application that
|
|
* deliberately hands out a file something reported as malicious.
|
|
*/
|
|
class QuarantineController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly ActivityLogger $activity,
|
|
private readonly FileAvailability $availability,
|
|
private readonly StaffLibraryScope $scope,
|
|
) {}
|
|
|
|
public function index(Request $request): Response
|
|
{
|
|
$user = $request->user();
|
|
assert($user !== null);
|
|
|
|
$files = $this->quarantined($user)
|
|
->with('uploader')
|
|
->orderByDesc('scanned_at')
|
|
->paginate(25)
|
|
->withQueryString();
|
|
|
|
$files->through(fn (File $file): array => [
|
|
'id' => $file->id,
|
|
'name' => $file->name,
|
|
'original_name' => $file->original_name,
|
|
'size' => $file->size,
|
|
'uploader' => $file->uploader?->name,
|
|
// The threat name, or — for a file nothing could open — what
|
|
// stopped it being read.
|
|
'threat' => $file->scan_status === ScanStatus::UnscannableBlocked
|
|
? __(NotScannedReason::tryFrom((string) $file->scan_note)?->label() ?? 'Could not be scanned')
|
|
: $file->scan_note,
|
|
'status' => $file->scan_status->value,
|
|
'scanned_at' => $file->scanned_at?->toIso8601String(),
|
|
// True only for a file that went out unscanned while the
|
|
// scanner was unreachable and was caught later — which is the
|
|
// one case where somebody may already have a copy.
|
|
'was_available' => $file->scan_was_available,
|
|
'downloads_count' => $file->downloads()->count(),
|
|
]);
|
|
|
|
return Inertia::render('files/quarantine', [
|
|
'files' => $files->items(),
|
|
'pagination' => Pagination::meta($files),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Overrule the scanner for one file.
|
|
*
|
|
* The reason is required and is recorded against the person who gave
|
|
* it. A release is not undone by a later scan: the file stays
|
|
* released until somebody deletes it, which is the point — an
|
|
* administrator who has decided a detection is wrong should not have
|
|
* to decide it again every hour.
|
|
*/
|
|
public function release(Request $request, File $file): RedirectResponse
|
|
{
|
|
$actor = $request->user();
|
|
assert($actor !== null);
|
|
|
|
abort_unless($this->quarantined($actor)->whereKey($file->id)->exists(), 404);
|
|
|
|
$validated = $request->validate([
|
|
'reason' => ['required', 'string', 'max:500'],
|
|
]);
|
|
|
|
$file->forceFill([
|
|
'scan_status' => ScanStatus::Released,
|
|
'released_by' => $actor->id,
|
|
'released_at' => now(),
|
|
])->save();
|
|
|
|
$this->activity->log(Action::FileReleased, subject: $file, context: [
|
|
'reason' => $validated['reason'],
|
|
'threat' => $file->scan_note,
|
|
]);
|
|
|
|
// Everything that was waiting on this file — a share email, a new
|
|
// version notice — goes out now, exactly as it would have if the
|
|
// scan had passed.
|
|
$this->availability->markAvailable($file);
|
|
|
|
return back()->with('success', __('The file has been released.'));
|
|
}
|
|
|
|
/**
|
|
* The quarantined files this person may see and release.
|
|
*
|
|
* A client-scoped staff member gets their own clients' uploads and
|
|
* their own, the same boundary as the rest of the library. The
|
|
* permission alone let one read every quarantined file on the
|
|
* installation, and release a file belonging to a client they could
|
|
* not otherwise open.
|
|
*
|
|
* @return Builder<File>
|
|
*/
|
|
private function quarantined(User $user): Builder
|
|
{
|
|
$query = File::query()
|
|
->whereIn('scan_status', [ScanStatus::Infected->value, ScanStatus::UnscannableBlocked->value]);
|
|
|
|
$uploaders = $this->scope->uploaderIds($user);
|
|
|
|
return $uploaders === null ? $query : $query->whereIn('uploaded_by', $uploaders);
|
|
}
|
|
}
|