diff --git a/email_extras/backends.py b/email_extras/backends.py index 5186dfb..3b1a14a 100644 --- a/email_extras/backends.py +++ b/email_extras/backends.py @@ -13,6 +13,9 @@ from django.core.mail.message import EmailMultiAlternatives from django.utils.encoding import smart_text +from .handlers import (handle_failed_message_encryption, + handle_failed_alternative_encryption, + handle_failed_attachment_encryption) from .settings import (GNUPG_HOME, GNUPG_ENCODING, USE_GNUPG, encrypt_kwargs) from .utils import EncryptionFailedError @@ -39,14 +42,6 @@ def open(self, body): webbrowser.open("file://" + temp.name) -class AttachmentEncryptionFailedError(EncryptionFailedError): - pass - - -class AlternativeEncryptionFailedError(EncryptionFailedError): - pass - - if USE_GNUPG: from gnupg import GPG @@ -102,12 +97,22 @@ def encrypt_attachment(address, attachment, use_asc): try: encrypted_content = encrypt(content, address) except EncryptionFailedError as e: - # SECURITY: We could include a piece of the content here, but that - # would leak information in logs and to the admins. So instead, we - # only try to include the filename. - raise AttachmentEncryptionFailedError( - "Encrypting attachment to %s failed: %s (%s)", address, - filename, e.msg) + # This function will need to decide what to do. Possibilities include + # one or more of: + # + # * Mail admins (possibly without encrypting the message to them) + # * Remove the offending key automatically + # * Set the body to a blank string + # * Set the body to the cleartext + # * Set the body to the cleartext, with a warning message prepended + # * Set the body to a custom error string + # * Reraise the exception + # + # However, the behavior will be very site-specific, because each site + # will have different attackers, different threat profiles, different + # compliance requirements, and different policies. + # + handle_failed_attachment_encryption(e) else: if use_asc and filename is not None: filename += ".asc" @@ -145,7 +150,10 @@ def encrypt_messages(email_messages): continue # Replace the message body with the encrypted message body - new_msg.body = encrypt(new_msg.body, address) + try: + new_msg.body = encrypt(new_msg.body, address) + except EncryptionFailedError as e: + handle_failed_message_encryption(e) # If the message has alternatives, encrypt them all alternatives = [] @@ -159,9 +167,7 @@ def encrypt_messages(email_messages): encrypted_alternative = encrypt(alt, address, **encrypt_kwargs) except EncryptionFailedError as e: - raise AlternativeEncryptionFailedError( - "Encrypting alternative to %s failed: %s (%s)", - address, alt, e.msg) + handle_failed_alternative_encryption(e) else: alternatives.append((encrypted_alternative, "application/gpg-encrypted")) diff --git a/email_extras/handlers.py b/email_extras/handlers.py new file mode 100644 index 0000000..eefd06e --- /dev/null +++ b/email_extras/handlers.py @@ -0,0 +1,109 @@ +import inspect + +from django.conf import settings +from django.core.mail import mail_admins + +from .models import Address +from .settings import FAILURE_HANDLERS + + +ADMIN_ADDRESSES = [admin[1] for admin in settings.ADMINS] + + +def get_variable_from_exception(exception, variable_name): + """ + Grab the variable from closest frame in the stack + """ + for frame in reversed(inspect.trace()): + try: + # From http://stackoverflow.com/a/9059407/6461688 + frame_variable = frame[0].f_locals[variable_name] + except KeyError: + pass + else: + return frame_variable + else: + raise KeyError("Variable '%s' not in any stack frames", variable_name) + + +def default_handle_failed_encryption(exception): + """ + Handle failures when trying to encrypt alternative content for messages + """ + raise exception + + +def default_handle_failed_alternative_encryption(exception): + """ + Handle failures when trying to encrypt alternative content for messages + """ + raise exception + + +def default_handle_failed_attachment_encryption(exception): + """ + Handle failures when trying to encrypt alternative content for messages + """ + raise exception + + +def force_mail_admins(unencrypted_message, address): + """ + Mail admins when encryption fails, and send the message unencrypted if + the recipient is an admin + """ + + if address in ADMIN_ADDRESSES: + # We assume that it is more important to mail the admin *without* + # encrypting the message + force_send_message(unencrypted_message) + else: + mail_admins( + "Failed encryption attempt", + """ + There was a problem encrypting an email message. + + Subject: "{subject}" + Address: "{address}" + """) + + +def force_delete_key(address): + """ + Delete the key from the keyring and the Key and Address objects from the + database + """ + address_object = Address.objects.get(address=address) + address_object.key.delete() + address_object.delete() + + +def force_send_message(unencrypted_message): + """ + Send the message unencrypted + """ + unencrypted_message.do_not_encrypt_this_message = True + unencrypted_message.send() + + +def import_function(key): + mod, _, function = FAILURE_HANDLERS[key].rpartition('.') + try: + # Python 3.4+ + from importlib import import_module + except ImportError: + # Python < 3.4 + # From http://stackoverflow.com/a/8255024/6461688 + mod = __import__(mod, globals(), locals(), [function]) + else: + mod = import_module(mod) + return getattr(mod, function) + +exception_handlers = { + 'message': 'handle_failed_message_encryption', + 'alternative': 'handle_failed_alternative_encryption', + 'attachment': 'handle_failed_attachment_encryption', +} + +for key, value in exception_handlers.items(): + locals()[value] = import_function(key) diff --git a/email_extras/settings.py b/email_extras/settings.py index badb81f..802bd3c 100644 --- a/email_extras/settings.py +++ b/email_extras/settings.py @@ -1,4 +1,3 @@ - from django.conf import settings from django.core.exceptions import ImproperlyConfigured @@ -7,6 +6,12 @@ USE_GNUPG = getattr(settings, "EMAIL_EXTRAS_USE_GNUPG", GNUPG_HOME is not None) ALWAYS_TRUST = getattr(settings, "EMAIL_EXTRAS_ALWAYS_TRUST_KEYS", False) +FAILURE_HANDLERS = { + 'message': 'email_extras.handlers.default_handle_failed_encryption', + 'alternative': 'email_extras.handlers.default_handle_failed_alternative_encryption', + 'attachment': 'email_extras.handlers.default_handle_failed_attachment_encryption', +} +FAILURE_HANDLERS.update(getattr(settings, "EMAIL_EXTRAS_FAILURE_HANDLERS", {})) GNUPG_ENCODING = getattr(settings, "EMAIL_EXTRAS_GNUPG_ENCODING", None) SIGNING_KEY_DATA = { 'key_type': "DSA",