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