From 400ffa73e61778fea2af9e7fe037ca23a5af9b12 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 5 Feb 2015 13:06:56 -0500 Subject: [PATCH] Add SSL cert and key validation --- requirements-nover.txt | 1 + requirements.txt | 1 + static/css/core-ui.css | 4 ++++ util/config/provider.py | 11 +++++++++ util/config/validator.py | 50 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 67 insertions(+) diff --git a/requirements-nover.txt b/requirements-nover.txt index d4d21f0f5..59ee9b2fb 100644 --- a/requirements-nover.txt +++ b/requirements-nover.txt @@ -41,3 +41,4 @@ git+https://github.com/DevTable/anunidecode.git git+https://github.com/DevTable/avatar-generator.git git+https://github.com/DevTable/pygithub.git gipc +pyOpenSSL diff --git a/requirements.txt b/requirements.txt index 8fc83d033..27adbe222 100644 --- a/requirements.txt +++ b/requirements.txt @@ -44,6 +44,7 @@ python-dateutil==2.4.0 python-ldap==2.4.19 python-magic==0.4.6 pytz==2014.10 +pyOpenSSL==0.14 raven==5.1.1 redis==2.10.3 reportlab==2.7 diff --git a/static/css/core-ui.css b/static/css/core-ui.css index ebe86d595..a89f07f39 100644 --- a/static/css/core-ui.css +++ b/static/css/core-ui.css @@ -601,6 +601,10 @@ text-align: left; } +.co-dialog .modal-footer.working .btn { + float: right; +} + .co-dialog .modal-footer.working .cor-loader-inline { margin-right: 10px; } diff --git a/util/config/provider.py b/util/config/provider.py index 24380eab0..5a2d92757 100644 --- a/util/config/provider.py +++ b/util/config/provider.py @@ -2,6 +2,7 @@ import os import yaml import logging import json +from StringIO import StringIO logger = logging.getLogger(__name__) @@ -55,6 +56,10 @@ class BaseProvider(object): """ Returns whether the file with the given name exists under the config override volume. """ raise NotImplementedError + def get_volume_file(self, filename, mode='r'): + """ Returns a Python file referring to the given name under the config override volumne. """ + raise NotImplementedError + def save_volume_file(self, filename, flask_file): """ Saves the given flask file to the config override volume, with the given filename. @@ -107,6 +112,9 @@ class FileConfigProvider(BaseProvider): def volume_file_exists(self, filename): return os.path.exists(os.path.join(self.config_volume, filename)) + def get_volume_file(self, filename, mode='r'): + return open(os.path.join(self.config_volume, filename), mode) + def save_volume_file(self, filename, flask_file): flask_file.save(os.path.join(self.config_volume, filename)) @@ -152,6 +160,9 @@ class TestConfigProvider(BaseProvider): def save_volume_file(self, filename, flask_file): self.files[filename] = '' + def get_volume_file(self, filename, mode='r'): + return StringIO(self.files[filename]) + def requires_restart(self, app_config): return False diff --git a/util/config/validator.py b/util/config/validator.py index da533ec01..385539899 100644 --- a/util/config/validator.py +++ b/util/config/validator.py @@ -3,7 +3,10 @@ import os import json import ldap import peewee +import OpenSSL +import logging +from fnmatch import fnmatch from data.users import LDAPConnection from flask import Flask from flask.ext.mail import Mail, Message @@ -13,6 +16,8 @@ from app import app, CONFIG_PROVIDER from auth.auth_context import get_authenticated_user from util.oauth import GoogleOAuthConfig, GithubOAuthConfig +logger = logging.getLogger(__name__) + SSL_FILENAMES = ['ssl.cert', 'ssl.key'] def get_storage_provider(config): @@ -35,6 +40,7 @@ def validate_service_for_config(service, config): 'status': True } except Exception as ex: + logger.exception('Validation exception') return { 'status': False, 'reason': str(ex) @@ -150,6 +156,50 @@ def _validate_ssl(config): if not CONFIG_PROVIDER.volume_file_exists(filename): raise Exception('Missing required SSL file: %s' % filename) + with CONFIG_PROVIDER.get_volume_file(SSL_FILENAMES[0]) as f: + cert_contents = f.read() + + # Validate the certificate. + try: + cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, cert_contents) + except: + raise Exception('Could not parse certificate file. Is it a valid PEM certificate?') + + if cert.has_expired(): + raise Exception('The specified SSL certificate has expired.') + + private_key_path = None + with CONFIG_PROVIDER.get_volume_file(SSL_FILENAMES[1]) as f: + private_key_path = f.name + + if not private_key_path: + # Only in testing. + return + + # Validate the private key with the certificate. + context = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD) + context.use_certificate(cert) + + try: + context.use_privatekey_file(private_key_path) + except: + raise Exception('Could not parse key file. Is it a valid PEM private key?') + + try: + context.check_privatekey() + except OpenSSL.SSL.Error as e: + raise Exception('SSL key failed to validate: %s' % str(e)) + + # Verify the hostname matches the name in the certificate. + common_name = cert.get_subject().commonName + if common_name is None: + raise Exception('Missing CommonName (CN) from SSL certificate') + + if not fnmatch(config['SERVER_HOSTNAME'], common_name): + raise Exception('CommonName (CN) "%s" in SSL cert does not match server hostname "%s"' % + (common_name, config['SERVER_HOSTNAME'])) + + def _validate_ldap(config): """ Validates the LDAP connection. """