From 3f859809a059fbdbddfa32b0a345a9f81d9fbe26 Mon Sep 17 00:00:00 2001 From: Guus der Kinderen Date: Sat, 9 Sep 2023 18:01:15 +0200 Subject: [PATCH] fix #41: Provide ClamAV integration Adding a new feature that allows the application to be configured to send all uploaded content to an external process, a ClamAV daemon, that scans it for malware. --- README.md | 25 ++++- pom.xml | 8 +- .../nl/goodbytes/xmpp/xep0363/Launcher.java | 39 +++++++- .../xep0363/MalwareDetectedException.java | 20 ++++ .../xmpp/xep0363/MalwareScanner.java | 27 ++++++ .../xmpp/xep0363/MalwareScannerManager.java | 60 ++++++++++++ .../nl/goodbytes/xmpp/xep0363/Repository.java | 4 +- .../nl/goodbytes/xmpp/xep0363/Servlet.java | 23 ++++- .../xep0363/clamav/ClamavMalwareScanner.java | 96 +++++++++++++++++++ .../AbstractFileSystemRepository.java | 9 +- 10 files changed, 303 insertions(+), 8 deletions(-) create mode 100644 src/main/java/nl/goodbytes/xmpp/xep0363/MalwareDetectedException.java create mode 100644 src/main/java/nl/goodbytes/xmpp/xep0363/MalwareScanner.java create mode 100644 src/main/java/nl/goodbytes/xmpp/xep0363/MalwareScannerManager.java create mode 100644 src/main/java/nl/goodbytes/xmpp/xep0363/clamav/ClamavMalwareScanner.java diff --git a/README.md b/README.md index ad6a6d9..061f19f 100644 --- a/README.md +++ b/README.md @@ -67,13 +67,18 @@ A full set of usage instructions are provided by adding the ``--help`` argument: --announcedWebProtocol The Protocol that is to be used by the end users. Defaults to the webProtocol value + --clamavHost The FQDN or IP address of the host + running the optional ClamAV malware + scanner, if any. + --clamavPort The TCP port number for the optional + ClamAV malware scanner, if any. --domain The domain that will be used for the component with the XMPP domain. --fileRepo Store files in a directory provided by the file system. Provide the desired path as a value. Path must exist. - -h,--help Displays this help text. + -h,--help Displays this help text. --maxFileSize The maximum allowed size per file, in bytes. Use -1 to disable file size limit. Defaults to 5242880 @@ -108,3 +113,21 @@ A full set of usage instructions are provided by adding the ``--help`` argument: --xmppPort The TCP port number on the xmppHost, to which a connection will be made. Defaults to 5275. + +Scanning for Malware +-------------------- +To facilitate virus scanning, you can configure the application to use ClamAV. ClamAV is a third-party, open source +(GPLv2) anti-virus toolkit, available at https://www.clamav.net/ + +To configure this application to use ClamAV, install, configure and run clamav-daemon, the scanner daemon of ClamAV. +Configure the daemon in such a way that Openfire can access it via TCP. + +Note: ClamAV is configured with a maximum file size. Ensure that this is at least as big as the `maxFileSize` that is +provided as an argument to the HTTP File Upload Component. + +Then, start the HTTP File Upload Component application with the `clamavHost` and `clamavPort` arguments. When these are +provided, the application will supply each file that is being uploaded to the ClamAV daemon for scanning. A file upload +will fail when the ClamAV daemon could not be reached, or, obviously, when it detects malware. + +While malware scanning can offer some protection against distributing unwanted content, it has limitations. Particularly +when the uploaded data is encrypted, the scanner is unlikely able to detect any malware in it. diff --git a/pom.xml b/pom.xml index 28ceae0..2b825ae 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ nl.goodbytes.xmpp.xep httpfileuploadcomponent - 1.5.1-SNAPSHOT + 1.6.0-SNAPSHOT HTTP File Upload Component Implementation of an XMPP External Component that implements XEP-0363 'HTTP File Upload'. @@ -166,6 +166,12 @@ 3.8.5 + + xyz.capybara + clamav-client + 2.1.2 + + junit junit diff --git a/src/main/java/nl/goodbytes/xmpp/xep0363/Launcher.java b/src/main/java/nl/goodbytes/xmpp/xep0363/Launcher.java index 2329b3c..5a3097f 100644 --- a/src/main/java/nl/goodbytes/xmpp/xep0363/Launcher.java +++ b/src/main/java/nl/goodbytes/xmpp/xep0363/Launcher.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017-2022 Guus der Kinderen. All rights reserved. + * Copyright (c) 2017-2023 Guus der Kinderen. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ package nl.goodbytes.xmpp.xep0363; +import nl.goodbytes.xmpp.xep0363.clamav.ClamavMalwareScanner; import nl.goodbytes.xmpp.xep0363.repository.DirectoryRepository; import nl.goodbytes.xmpp.xep0363.repository.TempDirectoryRepository; import nl.goodbytes.xmpp.xep0363.slot.DefaultSlotProvider; @@ -32,6 +33,7 @@ import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Duration; import java.util.*; /** @@ -58,8 +60,9 @@ public class Launcher private final SlotProvider slotProvider; private final Long maxFileSize; private final boolean wildcardCORS; + private final MalwareScanner malwareScanner; - public Launcher( String xmppHost, Integer xmppPort, String domain, String sharedSecret, String webProtocol, String webHost, Integer webPort, String webContextRoot, String announcedWebProtocol, String announcedWebHost, Integer announcedWebPort, String announcedWebContextRoot, Repository repository, Long maxFileSize, boolean wildcardCORS) + public Launcher( String xmppHost, Integer xmppPort, String domain, String sharedSecret, String webProtocol, String webHost, Integer webPort, String webContextRoot, String announcedWebProtocol, String announcedWebHost, Integer announcedWebPort, String announcedWebContextRoot, Repository repository, Long maxFileSize, boolean wildcardCORS, MalwareScanner malwareScanner) { this.xmppHost = xmppHost != null ? xmppHost : "localhost"; this.xmppPort = xmppPort != null ? xmppPort : 5275; @@ -77,6 +80,7 @@ public Launcher( String xmppHost, Integer xmppPort, String domain, String shared this.slotProvider = new DefaultSlotProvider(); this.maxFileSize = maxFileSize != null ? maxFileSize : SlotManager.DEFAULT_MAX_FILE_SIZE; this.wildcardCORS = wildcardCORS; + this.malwareScanner = malwareScanner; } public static void main( String[] args ) @@ -224,6 +228,23 @@ public static void main( String[] args ) .build() ); + options.addOption( + Option.builder() + .longOpt( "clamavHost" ) + .hasArg() + .desc( "The FQDN or IP address of the host running the optional ClamAV malware scanner, if any." ) + .build() + ); + + options.addOption( + Option.builder() + .longOpt( "clamavPort" ) + .hasArg() + .desc( "The TCP port number for the optional ClamAV malware scanner, if any." ) + .type( Integer.class ) + .build() + ); + try { final CommandLineParser parser = new DefaultParser(); @@ -250,6 +271,8 @@ public static void main( String[] args ) final String sharedSecret = line.getOptionValue( "sharedSecret" ); final Long maxFileSize = line.hasOption( "maxFileSize" ) ? Long.parseLong(line.getOptionValue( "maxFileSize" )) : null; final boolean wildcardCORS = line.hasOption("wildcardCORS"); + final String clamavHost = line.getOptionValue("clamavHost", null); + final Integer clamavPort = line.hasOption( "clamavPort" ) ? Integer.parseInt(line.getOptionValue( "clamavPort" )) : null; final Repository repository; if ( line.hasOption( "tempFileRepo" ) ) @@ -272,7 +295,14 @@ else if (line.hasOption( "fileRepo")) repository = null; } - final Launcher launcher = new Launcher( xmppHost, xmppPort, domain, sharedSecret, webProtocol, webHost, webPort, webContextRoot, announcedWebProtocol, announcedWebHost, announcedWebPort, announcedWebContextRoot, repository, maxFileSize, wildcardCORS ); + final MalwareScanner clamav; + if ( clamavHost != null ) { + clamav = new ClamavMalwareScanner(clamavHost, clamavPort == null ? 3310 : clamavPort, Duration.ofSeconds(2)); + } else { + clamav = null; + } + + final Launcher launcher = new Launcher( xmppHost, xmppPort, domain, sharedSecret, webProtocol, webHost, webPort, webContextRoot, announcedWebProtocol, announcedWebHost, announcedWebPort, announcedWebContextRoot, repository, maxFileSize, wildcardCORS, clamav ); launcher.start(); } } @@ -372,6 +402,9 @@ public void start() Log.info( "Starting repository..." ); RepositoryManager.getInstance().initialize( repository ); + Log.info( "Starting malware scanner..."); + MalwareScannerManager.getInstance().initialize( malwareScanner ); + Log.info( "Starting webserver..." ); jetty = new Server(); diff --git a/src/main/java/nl/goodbytes/xmpp/xep0363/MalwareDetectedException.java b/src/main/java/nl/goodbytes/xmpp/xep0363/MalwareDetectedException.java new file mode 100644 index 0000000..2e4b931 --- /dev/null +++ b/src/main/java/nl/goodbytes/xmpp/xep0363/MalwareDetectedException.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2023 Guus der Kinderen. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nl.goodbytes.xmpp.xep0363; + +public class MalwareDetectedException extends Exception +{ +} diff --git a/src/main/java/nl/goodbytes/xmpp/xep0363/MalwareScanner.java b/src/main/java/nl/goodbytes/xmpp/xep0363/MalwareScanner.java new file mode 100644 index 0000000..0c0ebb6 --- /dev/null +++ b/src/main/java/nl/goodbytes/xmpp/xep0363/MalwareScanner.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 Guus der Kinderen. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nl.goodbytes.xmpp.xep0363; + +import java.io.IOException; + +public interface MalwareScanner +{ + void initialize() throws IOException; + + void destroy(); + + void scan(final SecureUniqueId uuid) throws MalwareDetectedException, IOException; +} diff --git a/src/main/java/nl/goodbytes/xmpp/xep0363/MalwareScannerManager.java b/src/main/java/nl/goodbytes/xmpp/xep0363/MalwareScannerManager.java new file mode 100644 index 0000000..cee33cd --- /dev/null +++ b/src/main/java/nl/goodbytes/xmpp/xep0363/MalwareScannerManager.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023 Guus der Kinderen. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nl.goodbytes.xmpp.xep0363; + +import java.io.IOException; + +public class MalwareScannerManager +{ + private static MalwareScannerManager INSTANCE; + + public synchronized static MalwareScannerManager getInstance() + { + if (INSTANCE == null) { + INSTANCE = new MalwareScannerManager(); + } + + return INSTANCE; + } + + private MalwareScanner malwareScanner; + + public boolean isEnabled() { + return this.malwareScanner != null; + } + + public void initialize(final MalwareScanner malwareScanner) throws IOException + { + if (this.malwareScanner != null) { + throw new IllegalArgumentException("Already initialized."); + } + this.malwareScanner = malwareScanner; + this.malwareScanner.initialize(); + } + + public MalwareScanner getMalwareScanner() + { + return this.malwareScanner; + } + + public void destroy() + { + if (this.malwareScanner != null) { + this.malwareScanner.destroy(); + this.malwareScanner = null; + } + } +} diff --git a/src/main/java/nl/goodbytes/xmpp/xep0363/Repository.java b/src/main/java/nl/goodbytes/xmpp/xep0363/Repository.java index 7d3ab75..fd6fee6 100644 --- a/src/main/java/nl/goodbytes/xmpp/xep0363/Repository.java +++ b/src/main/java/nl/goodbytes/xmpp/xep0363/Repository.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017 Guus der Kinderen. All rights reserved. + * Copyright (c) 2017-2023 Guus der Kinderen. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -45,4 +45,6 @@ public interface Repository // For writing data. OutputStream getOutputStream( SecureUniqueId uuid ) throws IOException; + + boolean delete(SecureUniqueId uuid) throws IOException; } diff --git a/src/main/java/nl/goodbytes/xmpp/xep0363/Servlet.java b/src/main/java/nl/goodbytes/xmpp/xep0363/Servlet.java index 30f7bfa..98bae6e 100644 --- a/src/main/java/nl/goodbytes/xmpp/xep0363/Servlet.java +++ b/src/main/java/nl/goodbytes/xmpp/xep0363/Servlet.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017-2022 Guus der Kinderen. All rights reserved. + * Copyright (c) 2017-2023 Guus der Kinderen. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -190,6 +190,7 @@ protected void doPut( HttpServletRequest req, HttpServletResponse resp ) throws try ( final InputStream in = req.getInputStream(); final OutputStream out = new BufferedOutputStream( repository.getOutputStream( slot.getUuid() ) ) ) { + Log.debug("... receiving content ..."); final byte[] buffer = new byte[ 1024 * 4 ]; int bytesRead; while ( ( bytesRead = in.read( buffer ) ) != -1 ) @@ -198,6 +199,26 @@ protected void doPut( HttpServletRequest req, HttpServletResponse resp ) throws } } + final MalwareScannerManager malwareScannerManager = MalwareScannerManager.getInstance(); + if (malwareScannerManager.isEnabled()) { + try { + Log.debug("... scanning uploaded content for malware ..."); + final MalwareScanner malwareScanner = malwareScannerManager.getMalwareScanner(); + malwareScanner.scan(slot.getUuid()); + Log.info("... malware scanning did not find malware ..."); + } catch (MalwareDetectedException e) { + resp.sendError( HttpServletResponse.SC_BAD_REQUEST, "Malware detected in the upload!" ); + repository.delete(slot.getUuid()); + Log.warn("... responded with BAD_REQUEST. Malware detected in upload of {}.", req.getRemoteAddr(), e); + return; + } catch (Throwable t) { + resp.sendError( HttpServletResponse.SC_BAD_REQUEST, "Malware scanning failed" ); + repository.delete(slot.getUuid()); + Log.info("... responded with BAD_REQUEST. Malware scanner execution failed.", t); + return; + } + } + try { resp.setHeader( "Location", SlotManager.getGetUrl(slot).toExternalForm() ); diff --git a/src/main/java/nl/goodbytes/xmpp/xep0363/clamav/ClamavMalwareScanner.java b/src/main/java/nl/goodbytes/xmpp/xep0363/clamav/ClamavMalwareScanner.java new file mode 100644 index 0000000..8d24820 --- /dev/null +++ b/src/main/java/nl/goodbytes/xmpp/xep0363/clamav/ClamavMalwareScanner.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2023 Guus der Kinderen. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nl.goodbytes.xmpp.xep0363.clamav; + +import nl.goodbytes.xmpp.xep0363.MalwareDetectedException; +import nl.goodbytes.xmpp.xep0363.MalwareScanner; +import nl.goodbytes.xmpp.xep0363.RepositoryManager; +import nl.goodbytes.xmpp.xep0363.SecureUniqueId; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import xyz.capybara.clamav.ClamavClient; +import xyz.capybara.clamav.ClamavException; +import xyz.capybara.clamav.commands.scan.result.ScanResult; + +import java.io.IOException; +import java.io.InputStream; +import java.time.Duration; + +public class ClamavMalwareScanner implements MalwareScanner +{ + private static final Logger Log = LoggerFactory.getLogger(ClamavMalwareScanner.class); + + private final String hostname; + + private final int port; + + private final Duration connectTimeout; + + private ClamavClient client; + + public ClamavMalwareScanner(final String hostname, final int port, final Duration connectTimeout) + { + this.hostname = hostname; + this.port = port; + this.connectTimeout = connectTimeout.toMillis() > Integer.MAX_VALUE ? Duration.ofMillis(Integer.MAX_VALUE) : connectTimeout; + } + + @Override + public synchronized void initialize() throws IOException + { + client = new ClamavClient(hostname, port); + if (!client.isReachable((int) connectTimeout.toMillis())) { + throw new IOException("Clamav daemon not reachable on " + hostname + ":" + port); + } + + try { + final String version = client.version(); + Log.info("Successfully connected to Clamav daemon " + version + "."); + } catch (Throwable t) { + Log.debug("Unable to determine Clamav daemon version."); + Log.info("Successfully connected to Clamav daemon!"); + } + } + + @Override + public synchronized void destroy() + { + } + + @Override + public void scan(final SecureUniqueId uuid) throws MalwareDetectedException, IOException + { + synchronized (this) { + try { + client.ping(); + } catch (ClamavException e) { + Log.info("Unsuccessful ping of the Clamav daemon. Trying to re-initialize the client.", e); + initialize(); + } + } + + try (final InputStream is = RepositoryManager.getInstance().getRepository().getInputStream(uuid)) { + final ScanResult scanResult = client.scan(is); + if (!(scanResult instanceof ScanResult.OK)) { + if (scanResult instanceof ScanResult.VirusFound) { + ((ScanResult.VirusFound) scanResult).getFoundViruses().values() + .forEach(malware -> Log.warn("Detected malware in slot '{}': {}", uuid, malware)); + } + throw new MalwareDetectedException(); + } + } + } +} diff --git a/src/main/java/nl/goodbytes/xmpp/xep0363/repository/AbstractFileSystemRepository.java b/src/main/java/nl/goodbytes/xmpp/xep0363/repository/AbstractFileSystemRepository.java index af92ec3..2259d48 100644 --- a/src/main/java/nl/goodbytes/xmpp/xep0363/repository/AbstractFileSystemRepository.java +++ b/src/main/java/nl/goodbytes/xmpp/xep0363/repository/AbstractFileSystemRepository.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017 Guus der Kinderen. All rights reserved. + * Copyright (c) 2017-2023 Guus der Kinderen. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -191,6 +191,13 @@ public OutputStream getOutputStream( SecureUniqueId uuid ) throws IOException return Files.newOutputStream( path, CREATE ); } + @Override + public boolean delete( SecureUniqueId uuid ) throws IOException + { + final Path path = Paths.get( repository.toString(), uuid.toString() ); + return Files.deleteIfExists( path ); + } + public void purge() throws IOException { final File[] files = repository.toFile().listFiles();