[WebUI][Daemon] Enhance TLS Security

This applies the following for both WebUI and Daemon:
1. Raised minimal TLS version to TLSv1.2
2. Added specific cipher suite list
3. Added support for ECDSA auth keys
4. Added support for ECDHE key exchange algorithm

We disabled the ability to perform TLS/SSL renegotiation and therefore
will prevent the clients from renegotiating, which can be exploit for
DoS attacks.

New security tests now will be skipped when running `pydef` and `trial`
testenvs. To run the test, use the testenv `security` or add the environment
variable `SECURITY_TESTS` before running the tests. Also should only run when
adding to the commit message the string `SECURITY_TEST`.
This commit is contained in:
DjLegolas 2018-06-24 20:39:04 +03:00 committed by Calum Lind
parent 456e720b75
commit b9a9e06c1d
7 changed files with 297 additions and 44 deletions

View File

@ -16,22 +16,30 @@ install:
- bash -c "echo $APTPACKAGES" - bash -c "echo $APTPACKAGES"
- sudo apt-get install $APTPACKAGES - sudo apt-get install $APTPACKAGES
- pip install tox - pip install tox
# the next part is for the security tests only.
- wget https://github.com/drwetter/testssl.sh/archive/v2.9.5-5.tar.gz -O testssl.tar.gz
- tar -xvzf testssl.tar.gz
- mv -t deluge/tests/data testssl.sh-2.9.5-5/testssl.sh testssl.sh-2.9.5-5/etc/
env: env:
global: global:
- APTPACKAGES="python-libtorrent" - APTPACKAGES="python-libtorrent"
- APTPACKAGES_GTKUI="python-gobject python-glade2" - APTPACKAGES_GTKUI="python-gobject python-glade2"
- DISPLAY=:99.0 - DISPLAY=:99.0
matrix:
- TOX_ENV=pydef matrix:
- TOX_ENV=flake8 include:
# - TOX_ENV=flake8-complexity - env: TOX_ENV=pydef
- TOX_ENV=docs - if: commit_message =~ SECURITY_TEST
# - TOX_ENV=todo env: TOX_ENV=security
- TOX_ENV=trial APTPACKAGES="$APTPACKAGES $APTPACKAGES_GTKUI" - env: TOX_ENV=flake8
- TOX_ENV=pygtkui APTPACKAGES="$APTPACKAGES $APTPACKAGES_GTKUI" #- env: TOX_ENV=flake8-complexity
# - TOX_ENV=testcoverage APTPACKAGES="$APTPACKAGES $APTPACKAGES_GTKUI" - env: TOX_ENV=docs
- TOX_ENV=plugins #- env: TOX_ENV=todo
- env: TOX_ENV=trial APTPACKAGES="$APTPACKAGES $APTPACKAGES_GTKUI"
- env: TOX_ENV=pygtkui APTPACKAGES="$APTPACKAGES $APTPACKAGES_GTKUI"
#- env: TOX_ENV=testcoverage APTPACKAGES="$APTPACKAGES $APTPACKAGES_GTKUI"
- env: TOX_ENV=plugins
virtualenv: virtualenv:
system_site_packages: true system_site_packages: true

View File

@ -10,6 +10,7 @@
names clashing beetween regular packages and deluge plugins. names clashing beetween regular packages and deluge plugins.
* Fix potential for host_id collision when creating hostlist entries. * Fix potential for host_id collision when creating hostlist entries.
* Add Option To Specify Outgoing Connection Interface. * Add Option To Specify Outgoing Connection Interface.
* Updated SSL/TLS Protocol parameters for better security.
==== Core ==== ==== Core ====
* Make the distinction between adding to the session new unmanaged torrents * Make the distinction between adding to the session new unmanaged torrents

View File

@ -18,13 +18,14 @@ import traceback
from collections import namedtuple from collections import namedtuple
from types import FunctionType from types import FunctionType
from OpenSSL import SSL, crypto from OpenSSL import crypto
from twisted.internet import defer, reactor from twisted.internet import defer, reactor
from twisted.internet.protocol import Factory, connectionDone from twisted.internet.protocol import Factory, connectionDone
import deluge.component as component import deluge.component as component
import deluge.configmanager import deluge.configmanager
from deluge.core.authmanager import AUTH_LEVEL_ADMIN, AUTH_LEVEL_DEFAULT, AUTH_LEVEL_NONE from deluge.core.authmanager import AUTH_LEVEL_ADMIN, AUTH_LEVEL_DEFAULT, AUTH_LEVEL_NONE
from deluge.crypto_utils import get_context_factory
from deluge.error import DelugeError, IncompatibleClient, NotAuthorizedError, WrappedException, _ClientSideRecreateError from deluge.error import DelugeError, IncompatibleClient, NotAuthorizedError, WrappedException, _ClientSideRecreateError
from deluge.event import ClientDisconnectedEvent from deluge.event import ClientDisconnectedEvent
from deluge.transfer import DelugeTransferProtocol from deluge.transfer import DelugeTransferProtocol
@ -91,22 +92,6 @@ def format_request(call):
return s return s
class ServerContextFactory(object):
def getContext(self): # NOQA: N802
"""
Create an SSL context.
This loads the servers cert/private key SSL files for use with the
SSL transport.
"""
ssl_dir = deluge.configmanager.get_config_dir('ssl')
ctx = SSL.Context(SSL.SSLv23_METHOD)
ctx.set_options(SSL.OP_NO_SSLv2 | SSL.OP_NO_SSLv3)
ctx.use_certificate_file(os.path.join(ssl_dir, 'daemon.cert'))
ctx.use_privatekey_file(os.path.join(ssl_dir, 'daemon.pkey'))
return ctx
class DelugeRPCProtocol(DelugeTransferProtocol): class DelugeRPCProtocol(DelugeTransferProtocol):
def __init__(self): def __init__(self):
super(DelugeRPCProtocol, self).__init__() super(DelugeRPCProtocol, self).__init__()
@ -391,8 +376,11 @@ class RPCServer(component.Component):
# Check for SSL keys and generate some if needed # Check for SSL keys and generate some if needed
check_ssl_keys() check_ssl_keys()
cert = os.path.join(deluge.configmanager.get_config_dir('ssl'), 'daemon.cert')
pkey = os.path.join(deluge.configmanager.get_config_dir('ssl'), 'daemon.pkey')
try: try:
reactor.listenSSL(port, self.factory, ServerContextFactory(), interface=hostname) reactor.listenSSL(port, self.factory, get_context_factory(cert, pkey), interface=hostname)
except Exception as ex: except Exception as ex:
log.debug('Daemon already running or port not available.: %s', ex) log.debug('Daemon already running or port not available.: %s', ex)
raise raise

72
deluge/crypto_utils.py Normal file
View File

@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2007,2008 Andrew Resch <andrewresch@gmail.com>
#
# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with
# the additional special exception to link portions of this program with the OpenSSL library.
# See LICENSE for more details.
#
from __future__ import division, print_function, unicode_literals
from OpenSSL.crypto import FILETYPE_PEM
from twisted.internet.ssl import AcceptableCiphers, Certificate, CertificateOptions, KeyPair, TLSVersion
# A TLS ciphers list.
# Sources for more information on TLS ciphers:
# - https://wiki.mozilla.org/Security/Server_Side_TLS
# - https://www.ssllabs.com/projects/best-practices/index.html
# - https://hynek.me/articles/hardening-your-web-servers-ssl-ciphers/
#
# This list was inspired by the `urllib3` library
# - https://github.com/urllib3/urllib3/blob/master/urllib3/util/ssl_.py#L79
#
# The general intent is:
# - prefer cipher suites that offer perfect forward secrecy (ECDHE),
# - prefer AES-GCM over ChaCha20 because hardware-accelerated AES is common,
# - disable NULL authentication, MD5 MACs and DSS for security reasons.
TLS_CIPHERS = ':'.join([
'ECDH+AESGCM',
'ECDH+CHACHA20',
'AES256-GCM-SHA384',
'AES128-GCM-SHA256',
'!DSS'
'!aNULL',
'!eNULL',
'!MD5'
])
# This value tells OpenSSL to disable all SSL/TLS renegotiation.
SSL_OP_NO_RENEGOTIATION = 0x40000000
def get_context_factory(cert_path, pkey_path):
"""OpenSSL context factory.
Generates an OpenSSL context factory using Twisted's CertificateOptions class.
This will keep a server cipher order.
Args:
cert_path (string): The path to the certificate file
pkey_path (string): The path to the private key file
Returns:
twisted.internet.ssl.CertificateOptions: An OpenSSL context factory
"""
with open(cert_path) as cert:
certificate = Certificate.loadPEM(cert.read()).original
with open(pkey_path) as pkey:
private_key = KeyPair.load(pkey.read(), FILETYPE_PEM).original
ciphers = AcceptableCiphers.fromOpenSSLCipherString(TLS_CIPHERS)
cert_options = CertificateOptions(
privateKey=private_key,
certificate=certificate,
raiseMinimumTo=TLSVersion.TLSv1_2,
acceptableCiphers=ciphers,
)
ctx = cert_options.getContext()
ctx.use_certificate_chain_file(cert_path)
ctx.set_options(SSL_OP_NO_RENEGOTIATION)
return cert_options

View File

@ -0,0 +1,184 @@
# -*- coding: utf-8 -*-
#
# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with
# the additional special exception to link portions of this program with the OpenSSL library.
# See LICENSE for more details.
#
from __future__ import print_function, unicode_literals
import os
import pytest
from twisted.internet.utils import getProcessOutputAndValue
import deluge.component as component
import deluge.ui.web.server
from deluge import configmanager
from deluge.common import windows_check
from deluge.ui.web.server import DelugeWeb
from .basetest import BaseTestCase
from .common import get_test_data_file
from .common_web import WebServerTestBase
from .daemon_base import DaemonBase
SECURITY_TESTS = bool(os.getenv('SECURITY_TESTS', False))
class SecurityBaseTestCase(object):
if windows_check():
skip = 'windows can`t run .sh files'
elif not SECURITY_TESTS:
skip = 'Skipping security tests'
http_err = 'can\'t run http tests on daemon'
def __init__(self):
self.home_dir = os.path.expanduser('~')
self.port = 8112
def _run_test(self, test):
d = getProcessOutputAndValue(
'bash',
[
get_test_data_file('testssl.sh'),
'--quiet',
'--nodns',
'--color',
'0',
test,
'127.0.0.1:%d' % self.port,
])
def on_result(results):
if test == '-e':
results = results[0].split('\n')[7:-6]
self.assertTrue(len(results) > 3)
else:
self.assertIn('OK', results[0])
self.assertNotIn('NOT ok', results[0])
d.addCallback(on_result)
return d
def test_secured_webserver_protocol(self):
return self._run_test('-p')
def test_secured_webserver_standard_ciphers(self):
return self._run_test('-s')
def test_secured_webserver_heartbleed_vulnerability(self):
return self._run_test('-H')
def test_secured_webserver_css_injection_vulnerability(self):
return self._run_test('-I')
def test_secured_webserver_ticketbleed_vulnerability(self):
return self._run_test('-T')
def test_secured_webserver_renegotiation_vulnerabilities(self):
return self._run_test('-R')
def test_secured_webserver_crime_vulnerability(self):
return self._run_test('-C')
def test_secured_webserver_breach_vulnerability(self):
return self._run_test('-B')
def test_secured_webserver_poodle_vulnerability(self):
return self._run_test('-O')
def test_secured_webserver_tls_fallback_scsv_mitigation_vulnerability(self):
return self._run_test('-Z')
def test_secured_webserver_sweet32_vulnerability(self):
return self._run_test('-W')
def test_secured_webserver_beast_vulnerability(self):
return self._run_test('-A')
def test_secured_webserver_lucky13_vulnerability(self):
return self._run_test('-L')
def test_secured_webserver_freak_vulnerability(self):
return self._run_test('-F')
def test_secured_webserver_logjam_vulnerability(self):
return self._run_test('-J')
def test_secured_webserver_drown_vulnerability(self):
return self._run_test('-D')
def test_secured_webserver_forward_secrecy_settings(self):
return self._run_test('-f')
def test_secured_webserver_rc4_ciphers(self):
return self._run_test('-4')
def test_secured_webserver_preference(self):
return self._run_test('-P')
def test_secured_webserver_headers(self):
return self._run_test('-h')
def test_secured_webserver_ciphers(self):
return self._run_test('-e')
@pytest.mark.security
class DaemonSecurityTestCase(BaseTestCase, DaemonBase, SecurityBaseTestCase):
if windows_check():
skip = 'windows can\'t start_core not enough arguments for format string'
def __init__(self, testname):
super(DaemonSecurityTestCase, self).__init__(testname)
DaemonBase.__init__(self)
SecurityBaseTestCase.__init__(self)
def setUp(self):
skip = False
for not_http_test in ('breach', 'headers', 'ticketbleed'):
if not_http_test in self.id().split('.')[-1]:
self.skipTest(SecurityBaseTestCase.http_err)
skip = True
if not skip:
super(DaemonSecurityTestCase, self).setUp()
def set_up(self):
d = self.common_set_up()
self.port = self.listen_port
d.addCallback(self.start_core)
d.addErrback(self.terminate_core)
return d
def tear_down(self):
d = component.shutdown()
d.addCallback(self.terminate_core)
return d
@pytest.mark.security
class WebUISecurityTestBase(WebServerTestBase, SecurityBaseTestCase):
def __init__(self, testname):
super(WebUISecurityTestBase, self).__init__(testname)
SecurityBaseTestCase.__init__(self)
def start_webapi(self, arg):
self.port = self.webserver_listen_port = 8999
config_defaults = deluge.ui.web.server.CONFIG_DEFAULTS.copy()
config_defaults['port'] = self.webserver_listen_port
config_defaults['https'] = True
self.config = configmanager.ConfigManager('web.conf', config_defaults)
self.deluge_web = DelugeWeb(daemon=False)
host = list(self.deluge_web.web_api.hostlist.config['hosts'][0])
host[2] = self.listen_port
self.deluge_web.web_api.hostlist.config['hosts'][0] = tuple(host)
self.host_id = host[0]
self.deluge_web.start()

View File

@ -16,15 +16,14 @@ import mimetypes
import os import os
import tempfile import tempfile
from OpenSSL.crypto import FILETYPE_PEM
from twisted.application import internet, service from twisted.application import internet, service
from twisted.internet import defer, reactor from twisted.internet import defer, reactor
from twisted.internet.ssl import SSL, Certificate, CertificateOptions, KeyPair, TLSVersion
from twisted.web import http, resource, server, static from twisted.web import http, resource, server, static
from deluge import common, component, configmanager from deluge import common, component, configmanager
from deluge.common import is_ipv6 from deluge.common import is_ipv6
from deluge.core.rpcserver import check_ssl_keys from deluge.core.rpcserver import check_ssl_keys
from deluge.crypto_utils import get_context_factory
from deluge.ui.tracker_icons import TrackerIcons from deluge.ui.tracker_icons import TrackerIcons
from deluge.ui.translations_util import set_language, setup_translations from deluge.ui.translations_util import set_language, setup_translations
from deluge.ui.web.auth import Auth from deluge.ui.web.auth import Auth
@ -623,6 +622,8 @@ class DelugeWeb(component.Component):
setup_translations(setup_gettext=True, setup_pygtk=False) setup_translations(setup_gettext=True, setup_pygtk=False)
# Remove twisted version number from 'server' http-header for security reasons
server.version = 'TwistedWeb'
self.site = server.Site(self.top_level) self.site = server.Site(self.top_level)
self.web_api = WebApi() self.web_api = WebApi()
self.web_utils = WebUtils() self.web_utils = WebUtils()
@ -684,20 +685,15 @@ class DelugeWeb(component.Component):
check_ssl_keys() check_ssl_keys()
log.debug('Enabling SSL with PKey: %s, Cert: %s', self.pkey, self.cert) log.debug('Enabling SSL with PKey: %s, Cert: %s', self.pkey, self.cert)
with open(configmanager.get_config_dir(self.cert)) as cert: cert = configmanager.get_config_dir(self.cert)
certificate = Certificate.loadPEM(cert.read()).original pkey = configmanager.get_config_dir(self.pkey)
with open(configmanager.get_config_dir(self.pkey)) as pkey:
private_key = KeyPair.load(pkey.read(), FILETYPE_PEM).original
options = CertificateOptions(
privateKey=private_key,
certificate=certificate,
raiseMinimumTo=TLSVersion.TLSv1_2,
)
ctx = options.getContext()
ctx.set_options(SSL.OP_NO_SSLv2 | SSL.OP_NO_SSLv3)
ctx.use_certificate_chain_file(configmanager.get_config_dir(self.cert))
self.socket = reactor.listenSSL(self.port, self.site, options, interface=self.interface) self.socket = reactor.listenSSL(
self.port,
self.site,
get_context_factory(cert, pkey),
interface=self.interface
)
ip = self.socket.getHost().host ip = self.socket.getHost().host
ip = '[%s]' % ip if is_ipv6(ip) else ip ip = '[%s]' % ip if is_ipv6(ip) else ip
log.info('Serving at https://%s:%s%s', ip, self.port, self.base) log.info('Serving at https://%s:%s%s', ip, self.port, self.base)

View File

@ -52,7 +52,11 @@ log_cli_level = CRITICAL
[testenv:pydef] [testenv:pydef]
commands = commands =
python -c "import libtorrent as lt; print(lt.__version__)" python -c "import libtorrent as lt; print(lt.__version__)"
pytest -v --basetemp=_pytest_temp -s -m "not (todo or gtkui)" deluge/tests pytest -v --basetemp=_pytest_temp -s -m "not (todo or gtkui or security)" deluge/tests
[testenv:security]
setenv = SECURITY_TESTS = True
commands = pytest -v --basetemp=_pytest_temp -s -m "security" deluge/tests/
[testenv:pygtkui] [testenv:pygtkui]
commands = pytest -v --basetemp=_pytest_temp -s -m "gtkui" deluge/tests commands = pytest -v --basetemp=_pytest_temp -s -m "gtkui" deluge/tests