From 8a2b68530756d01c50441e1039f3f938cd85b419 Mon Sep 17 00:00:00 2001 From: Thomas Hackl <hackl@data-quest.de> Date: Thu, 24 Nov 2022 13:25:18 +0000 Subject: [PATCH] Resolve "Virencheck beim Dateiupload" Closes #1658 Merge request studip/studip!1083 --- .../5.3.10_viruscheck_for_uploads.php | 73 ++++++ lib/classes/Virusscanner.php | 209 ++++++++++++++++++ lib/filesystem/FileManager.php | 70 ++++-- 3 files changed, 331 insertions(+), 21 deletions(-) create mode 100644 db/migrations/5.3.10_viruscheck_for_uploads.php create mode 100644 lib/classes/Virusscanner.php diff --git a/db/migrations/5.3.10_viruscheck_for_uploads.php b/db/migrations/5.3.10_viruscheck_for_uploads.php new file mode 100644 index 00000000000..8abc6bbfc24 --- /dev/null +++ b/db/migrations/5.3.10_viruscheck_for_uploads.php @@ -0,0 +1,73 @@ +<?php + +class ViruscheckForUploads extends Migration +{ + public function description() + { + return 'Provide config options for ClamAV usage on file uploads'; + } + + public function up() + { + $query = "INSERT IGNORE INTO `config` (`field`, `value`, `type`, `range`, `section`, `mkdate`, `chdate`, + `description`) + VALUES (:name, :value, :type, :range, :section, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), :description)"; + + $statement = DBManager::get()->prepare($query); + $statement->execute([ + 'name' => 'VIRUSSCAN_ON_UPLOAD', + 'description' => 'Sollen Dateien beim Upload mit ClamAV auf Viren überprüft werden?', + 'type' => 'boolean', + 'range' => 'global', + 'section' => 'files', + 'value' => '0' + ]); + $statement->execute([ + 'name' => 'VIRUSSCAN_SOCKET', + 'description' => 'Pfad zum Unix Socket (wird statt TCP verwendet, falls etwas eingetragen ist)', + 'type' => 'string', + 'range' => 'global', + 'section' => 'files', + 'value' => '/var/run/clamav/clamd.ctl' + ]); + $statement->execute([ + 'name' => 'VIRUSSCAN_HOST', + 'description' => 'Host des Virenscanners (wird nur verwendet, falls kein Socket eingetragen ist)', + 'type' => 'string', + 'range' => 'global', + 'section' => 'files', + 'value' => '127.0.0.1' + ]); + $statement->execute([ + 'name' => 'VIRUSSCAN_PORT', + 'description' => 'Port des Virenscanners (wird nur verwendet, falls kein Socket eingetragen ist)', + 'type' => 'integer', + 'range' => 'global', + 'section' => 'files', + 'value' => 3310 + ]); + $statement->execute([ + 'name' => 'VIRUSSCAN_MAX_STREAMLENGTH', + 'description' => 'Maximale Streamlänge in Bytes, die beim Virenscanner erlaubt ist', + 'type' => 'integer', + 'range' => 'global', + 'section' => 'files', + 'value' => 26214400 + ]); + } + + public function down() + { + $query = "DELETE `config`, `config_values` + FROM `config` + LEFT JOIN `config_values` USING (`field`) + WHERE `field` IN ( + 'VIRUSSCAN_ON_UPLOAD', + 'VIRUSSCAN_SOCKET', + 'VIRUSSCAN_HOST', + 'VIRUSSCAN_PORT', + 'VIRUSSCAN_MAX_STREAMLENGTH' + )"; + DBManager::get()->exec($query); + } +} diff --git a/lib/classes/Virusscanner.php b/lib/classes/Virusscanner.php new file mode 100644 index 00000000000..91786a64890 --- /dev/null +++ b/lib/classes/Virusscanner.php @@ -0,0 +1,209 @@ +<?php + +/** + * Abstraction for checking files with an external virus scanner. + * Supports connections via TCP or socket and is focused on using ClamAV at the moment. + * Derived from https://github.com/nextcloud/files_antivirus + * + * @author Thomas Hackl <hackl@data-quest.de> + * @author Sebastian Biller <s.biller@tu-braunschweig.de> + * @license GPL 2 or later + * @since 5.3 + */ + +class Virusscanner +{ + // Contains the singleton used. + protected static $instance; + + // Definitions for possible status. + public const SCANRESULT_UNCHECKED = -1; + public const SCANRESULT_CLEAN = 0; + public const SCANRESULT_INFECTED = 1; + + /** + * Scans the given path for viruses. + * + * @param string $path + * @return array Contains the found virus signature, error message or is an empty array on successful scan + */ + public static function scan(string $path) + { + // Get virus scanner singleton. + if (is_null(static::$instance)) { + static::$instance = new static(); + } + $scanner = static::$instance; + + try { + // Connect to scanner. + $handle = $scanner->connect(); + // Read file. + $file = $scanner->readFile($path); + + // ClamAV has a maximum stream length, so we need to track how much data has already been sent. + $bytesWritten = $scanner->sendContent($handle, "nINSTREAM\n"); + + // Send file chunks via socket or TCP. + while ($chunk = @fread($file, 8192)) { + $chunkLength = pack('N', strlen($chunk)); + + // Send next chunk. + if ($bytesWritten + strlen($chunk) <= Config::get()->VIRUSSCAN_MAX_STREAMLENGTH) { + $bytesWritten += $scanner->sendContent($handle, $chunkLength . $chunk); + // Stream limit will be reached: abort. + } else { + return [ + 'error' => _('Die Datei ist zu groß, um vom Virenscanner gelesen zu werden.') + ]; + } + } + + fclose($file); + + // All chunks have been sent - signal stream end and get scanner response. + $result = $scanner->finalize($handle); + + // Nothing found. + if ($result['status'] == static::SCANRESULT_CLEAN) { + return []; + // Virus found or error. + } else if ($result['status'] == static::SCANRESULT_INFECTED) { + return [ + 'found' => $result['details'] + ]; + } else { + return [ + 'error' => $result['details'] + ]; + } + + // There has been an error: send error message back. + } catch (Exception $e) { + return [ + 'error' => $e->getMessage() + ]; + } + + } + + /** + * Establishes a connection to virus scanner via socket or TCP, depending on Stud.IP configuration. + * + * @return resource|null + */ + protected function connect() + { + $handle = false; + + // Use socket connection. + if (Config::get()->VIRUSSCAN_SOCKET) { + $handle = @stream_socket_client('unix://' . Config::get()->VIRUSSCAN_SOCKET, $errno, $errstr, 5); + // use TCP connection. + } else if (Config::get()->VIRUSSCAN_HOST && Config::get()->VIRUSSCAN_PORT) { + $handle = @fsockopen(Config::get()->VIRUSSCAN_HOST, Config::get()->VIRUSSCAN_PORT); + } + + if ($handle === false) { + throw new RuntimeException(_('Der Virenscanner ist nicht verfügbar.')); + } + + return $handle; + } + + /** + * Get contents of the file to scan. + * + * @param string $path + * @return resource + */ + protected function readFile(string $path) + { + $handle = fopen($path, 'r'); + + if ($handle === false) { + throw new RuntimeException(_('Die Datei kann nicht gelesen werden.')); + } + + return $handle; + } + + /** + * Send some content to the virus scanner. + * + * @param resource $handle + * @param string $content + * @return int + */ + protected function sendContent($handle, string $content): int + { + $written = @fwrite($handle, $content); + + // An error has happened -> throw exception. + if ($written === false) { + throw new RuntimeException(_('Fehler bei der Kommunikation mit dem Virenscanner.')); + // Return written byte count. + } else { + return $written; + } + } + + /** + * All file chunks have been sent: we now signal the end of the stream by sending a "0". + * Afterwarda, the response we got from virus scanner is parsed and (in case something was found) + * the name of the virus is returned. + * + * @param resource $handle + * @return array + */ + protected function finalize($handle): array + { + // End stream to socket or TCP endpoint. + $this->sendContent($handle, pack('N', 0)); + + // Fetch virus scanner response. + $response = fgets($handle); + fclose($handle); + + // Parse response. + $matches = []; + + // Possible response types. + $rules = [ + [ + 'match' => '/.*: OK$/', + 'status' => self::SCANRESULT_CLEAN + ], + [ + 'match' => '/.*: (.*) FOUND$/', + 'status' => self::SCANRESULT_INFECTED + ], + [ + 'match' => '/.*: (.*) ERROR$/', + 'status' => self::SCANRESULT_UNCHECKED + ], + ]; + + $status = static::SCANRESULT_UNCHECKED; + $details = _('Die Antwort des Virenscanners wurde nicht erkannt.'); + + foreach ($rules as $rule) { + if (preg_match($rule['match'], $response, $matches)) { + $status = (int) $rule['status']; + + if ((int) $rule['status'] !== static::SCANRESULT_CLEAN) { + $details = $matches[1] ?? _('unbekannt'); + } else { + $details = ''; + } + break; + } + } + + return [ + 'status' => $status, + 'details' => $details + ]; + } + +} diff --git a/lib/filesystem/FileManager.php b/lib/filesystem/FileManager.php index d124384c02f..6d4d95eb21d 100644 --- a/lib/filesystem/FileManager.php +++ b/lib/filesystem/FileManager.php @@ -328,35 +328,63 @@ class FileManager //two-dimensional array. Each index of the first dimension //contains an array attribute for uploaded files, one entry per file. if (is_array($uploaded_files['name'])) { + $error = []; foreach ($uploaded_files['name'] as $key => $filename) { - $uploaded_file = StandardFile::create([ - 'name' => $filename, - 'type' => $uploaded_files['type'][$key] ?: get_mime_type($filename), - 'size' => $uploaded_files['size'][$key], - 'tmp_name' => $uploaded_files['tmp_name'][$key], - 'error' => $uploaded_files['error'][$key] - ]); - - if ($uploaded_file instanceof FileType) { - //validate the upload by looking at the folder where the - //uploaded file shall be stored: - if ($folder_error = $folder->validateUpload($uploaded_file, $user_id)) { - $error[] = $folder_error; - $uploaded_file->delete(); - continue; + $proceed = true; + + if (Config::get()->VIRUSSCAN_ON_UPLOAD) { + $scanned = Virusscanner::scan($uploaded_files['tmp_name'][$key]); + + if (count($scanned) > 0) { + $proceed = false; + + if ($scanned['found']) { + $error[] = sprintf( + _('Die Datei "%1$s" wurde vom Virenscanner überprüft. Dabei wurde das Virus ' . + '"%2$s" gefunden. Die Datei wird nicht hochgeladen.'), + $filename, $scanned['found'] + ); + } else if ($scanned['error']) { + $error[] = sprintf( + _('Die Datei "%1$s" wurde vom Virenscanner überprüft. Dabei ist ein Problem ' . + 'aufgetreten: %2$s'), + $filename, $scanned['error'] + ); + } } + } + + if ($proceed) { + $uploaded_file = StandardFile::create([ + 'name' => $filename, + 'type' => $uploaded_files['type'][$key] ?: get_mime_type($filename), + 'size' => $uploaded_files['size'][$key], + 'tmp_name' => $uploaded_files['tmp_name'][$key], + 'error' => $uploaded_files['error'][$key] + ]); + + if ($uploaded_file instanceof FileType) { + //validate the upload by looking at the folder where the + //uploaded file shall be stored: + if ($folder_error = $folder->validateUpload($uploaded_file, $user_id)) { + $error[] = $folder_error; + $uploaded_file->delete(); + continue; + } - $new_reference = $folder->addFile($uploaded_file, $user_id); - if (!$new_reference){ - $error[] = _('Ein Systemfehler ist beim Upload aufgetreten.'); + $new_reference = $folder->addFile($uploaded_file, $user_id); + if (!$new_reference) { + $error[] = _('Ein Systemfehler ist beim Upload aufgetreten.'); + } else { + $result['files'][] = $new_reference; + } } else { - $result['files'][] = $new_reference; + $error = array_merge($error, $uploaded_file); } - } else { - $error = array_merge($error, $uploaded_file); } } } + return array_merge($result, compact('error')); } -- GitLab