diff --git a/trytond/doc/topics/configuration.rst b/trytond/doc/topics/configuration.rst
index 211735728e..7dd2bf4e65 100644
--- a/trytond/doc/topics/configuration.rst
+++ b/trytond/doc/topics/configuration.rst
@@ -304,6 +304,24 @@ The name of the similarity function.
Default: ``similarity``
+binary_scanner
+~~~~~~~~~~~~~~
+
+The command used to scan the content of Binary fields.
+The command receive the quarantine directory as last argument.
+If the command returns a non zero status, it is considered that at least one
+file in the directory is malicious.
+
+binary_scanner_directory
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+The directory where the binary content are stored before being checked
+by the scanner.
+
+Default: The temporary directory as determined by Python's tempfile_ module.
+
+.. _tempfile: https://docs.python.org/3/library/tempfile.html#tempfile.gettempdir
+
.. _config-request:
request
diff --git a/trytond/trytond/ir/message.xml b/trytond/trytond/ir/message.xml
index c30a6f5bb8..13d270012b 100644
--- a/trytond/trytond/ir/message.xml
+++ b/trytond/trytond/ir/message.xml
@@ -361,6 +361,9 @@ this repository contains the full copyright notices and license terms. -->
Condition "%(condition)s" is not a valid PYSON expression for trigger "%(trigger)s".
+
+ The content of field "%(field)s" has been detected as malicious.
+
Failed to save, please retry.
diff --git a/trytond/trytond/model/fields/binary.py b/trytond/trytond/model/fields/binary.py
index 94919c0b69..951f16f809 100644
--- a/trytond/trytond/model/fields/binary.py
+++ b/trytond/trytond/model/fields/binary.py
@@ -1,13 +1,22 @@
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
+import logging
+import shlex
+import subprocess
+import tempfile
+
from sql import Column, Null
+from trytond.config import config
from trytond.filestore import filestore
+from trytond.i18n import gettext
from trytond.tools import cached_property, grouped_slice, reduce_ids
from trytond.transaction import Transaction
from .field import Field
+logger = logging.getLogger(__name__)
+
def caster(d):
if isinstance(d, bytes):
@@ -17,6 +26,32 @@ def caster(d):
return bytes(d, encoding='utf8')
+def check_content(field_name, *binaries):
+ from trytond.model.modelstorage import BinaryScanError
+
+ scanner = config.get('database', 'binary_scanner')
+ scanner_dir = config.get(
+ 'database', 'binary_scanner_directory', default=tempfile.gettempdir())
+ if not scanner:
+ return
+
+ with tempfile.TemporaryDirectory(dir=scanner_dir) as tempdir:
+ for binary in binaries:
+ with tempfile.NamedTemporaryFile(dir=tempdir, delete=False) as fd:
+ fd.write(binary)
+
+ try:
+ subprocess.check_call(
+ shlex.split(scanner) + [tempdir],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ except subprocess.CalledProcessError as error:
+ logger.critical(
+ "'%s %s' exited with code '%s'",
+ scanner, tempdir, error.returncode)
+ raise BinaryScanError(gettext(
+ 'ir.msg_malicious_binary', field=field_name))
+
+
class Binary(Field):
_type = 'binary'
_sql_type = 'BLOB'
@@ -112,6 +147,9 @@ def set(self, Model, name, ids, value, *args):
if prefix is None:
prefix = transaction.database.name
+ self._check_contents(
+ Model.__names__(name)['field'], *((ids, value) + args)[1::2])
+
args = iter((ids, value) + args)
for ids, value in zip(args, args):
if self.file_id:
@@ -124,6 +162,10 @@ def set(self, Model, name, ids, value, *args):
cursor.execute(*table.update(columns, values,
where=reduce_ids(table.id, ids)))
+ @classmethod
+ def _check_contents(cls, field, *values):
+ check_content(field, *values)
+
def definition(self, model, language):
definition = super().definition(model, language)
definition['searchable'] = False
diff --git a/trytond/trytond/model/modelstorage.py b/trytond/trytond/model/modelstorage.py
index 137f8287dd..15a3f136ff 100644
--- a/trytond/trytond/model/modelstorage.py
+++ b/trytond/trytond/model/modelstorage.py
@@ -98,6 +98,10 @@ def __init__(self, record):
self.record = record
+class BinaryScanError(ValidationError):
+ pass
+
+
def is_leaf(expression):
return (isinstance(expression, (list, tuple))
and len(expression) > 2