From 16adddc80316666a04597d950aa8bbd400684803 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Mon, 21 Aug 2023 13:21:02 -0700 Subject: [PATCH] Allow users to set a combined certificte and key file for additional certificates in the SSL context --- docs/configuration.md | 13 ++++++++++++ src/paperless/checks.py | 19 ++++++++++++++++- src/paperless/settings.py | 17 ++++++++++++--- src/paperless/tests/test_checks.py | 33 +++++++++++++++++++++++++++++- src/paperless_mail/mail.py | 8 ++++++-- 5 files changed, 83 insertions(+), 7 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index c38221e50..13e628151 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -501,6 +501,19 @@ HTTP header/value expected by Django, eg `'["HTTP_X_FORWARDED_PROTO", "https"]'` Settings this value has security implications. Read the Django documentation and be sure you understand its usage before setting it. +`PAPERLESS_EMAIL_CERTIFICATE_FILE=` + +: Configures an additional SSL certificate file containing a [combined key and certificate](https://docs.python.org/3/library/ssl.html#combined-key-and-certificate) file +for validating SSL connections against mail providers. This is for use with self-signed certificates against +local IMAP servers. + + Defaults to None. + +!!! warning + + Settings this value has security implications for the security of your email. + Understand what it does and be sure you need to before setting. + ## OCR settings {#ocr} Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/) diff --git a/src/paperless/checks.py b/src/paperless/checks.py index cda14baad..d3009d036 100644 --- a/src/paperless/checks.py +++ b/src/paperless/checks.py @@ -177,6 +177,23 @@ def settings_values_check(app_configs, **kwargs): ) return msgs + def _email_certificate_validate(): + msgs = [] + # Existence checks + if ( + settings.EMAIL_CERTIFICATE_FILE is not None + and not settings.EMAIL_CERTIFICATE_FILE.is_file() + ): + msgs.append( + Error( + f"Email cert {settings.EMAIL_CERTIFICATE_FILE} is not a file", + ), + ) + return msgs + return ( - _ocrmypdf_settings_check() + _timezone_validate() + _barcode_scanner_validate() + _ocrmypdf_settings_check() + + _timezone_validate() + + _barcode_scanner_validate() + + _email_certificate_validate() ) diff --git a/src/paperless/settings.py b/src/paperless/settings.py index 7d2dda0d9..6b2ea56b2 100644 --- a/src/paperless/settings.py +++ b/src/paperless/settings.py @@ -67,11 +67,20 @@ def __get_float(key: str, default: float) -> float: return float(os.getenv(key, default)) -def __get_path(key: str, default: Union[PathLike, str]) -> Path: +def __get_path( + key: str, + default: Optional[Union[PathLike, str]] = None, +) -> Optional[Path]: """ - Return a normalized, absolute path based on the environment variable or a default + Return a normalized, absolute path based on the environment variable or a default, + if provided. If not set and no default, returns None """ - return Path(os.environ.get(key, default)).resolve() + if key in os.environ: + return Path(os.environ[key]).resolve() + elif default is not None: + return Path(default).resolve() + else: + return None def __get_list( @@ -477,6 +486,8 @@ CSRF_COOKIE_NAME = f"{COOKIE_PREFIX}csrftoken" SESSION_COOKIE_NAME = f"{COOKIE_PREFIX}sessionid" LANGUAGE_COOKIE_NAME = f"{COOKIE_PREFIX}django_language" +EMAIL_CERTIFICATE_FILE = __get_path("PAPERLESS_EMAIL_CERTIFICATE_FILE") + ############################################################################### # Database # diff --git a/src/paperless/tests/test_checks.py b/src/paperless/tests/test_checks.py index cd706c532..6aac1a4c6 100644 --- a/src/paperless/tests/test_checks.py +++ b/src/paperless/tests/test_checks.py @@ -1,9 +1,11 @@ import os +from pathlib import Path from django.test import TestCase from django.test import override_settings from documents.tests.utils import DirectoriesMixin +from documents.tests.utils import FileSystemAssertsMixin from paperless.checks import binaries_check from paperless.checks import debug_mode_check from paperless.checks import paths_check @@ -57,7 +59,7 @@ class TestChecks(DirectoriesMixin, TestCase): self.assertEqual(len(debug_mode_check(None)), 1) -class TestSettingsChecks(DirectoriesMixin, TestCase): +class TestSettingsChecksAgainstDefaults(DirectoriesMixin, TestCase): def test_all_valid(self): """ GIVEN: @@ -70,6 +72,8 @@ class TestSettingsChecks(DirectoriesMixin, TestCase): msgs = settings_values_check(None) self.assertEqual(len(msgs), 0) + +class TestOcrSettingsChecks(DirectoriesMixin, TestCase): @override_settings(OCR_OUTPUT_TYPE="notapdf") def test_invalid_output_type(self): """ @@ -160,6 +164,8 @@ class TestSettingsChecks(DirectoriesMixin, TestCase): self.assertIn('OCR clean mode "cleanme"', msg.msg) + +class TestTimezoneSettingsChecks(DirectoriesMixin, TestCase): @override_settings(TIME_ZONE="TheMoon\\MyCrater") def test_invalid_timezone(self): """ @@ -178,6 +184,8 @@ class TestSettingsChecks(DirectoriesMixin, TestCase): self.assertIn('Timezone "TheMoon\\MyCrater"', msg.msg) + +class TestBarcodeSettingsChecks(DirectoriesMixin, TestCase): @override_settings(CONSUMER_BARCODE_SCANNER="Invalid") def test_barcode_scanner_invalid(self): msgs = settings_values_check(None) @@ -200,3 +208,26 @@ class TestSettingsChecks(DirectoriesMixin, TestCase): def test_barcode_scanner_valid(self): msgs = settings_values_check(None) self.assertEqual(len(msgs), 0) + + +class TestEmailCertSettingsChecks(DirectoriesMixin, FileSystemAssertsMixin, TestCase): + @override_settings(EMAIL_CERTIFICATE_FILE=Path("/tmp/not_actually_here.pem")) + def test_not_valid_file(self): + """ + GIVEN: + - Default settings + - Email certificate is set + WHEN: + - Email certificate file doesn't exist + THEN: + - system check error reported for email certificate + """ + self.assertIsNotFile("/tmp/not_actually_here.pem") + + msgs = settings_values_check(None) + + self.assertEqual(len(msgs), 1) + + msg = msgs[0] + + self.assertIn("Email cert /tmp/not_actually_here.pem is not a file", msg.msg) diff --git a/src/paperless_mail/mail.py b/src/paperless_mail/mail.py index a0bda19ba..fd66ac91d 100644 --- a/src/paperless_mail/mail.py +++ b/src/paperless_mail/mail.py @@ -395,12 +395,16 @@ def get_mailbox(server, port, security) -> MailBox: """ Returns the correct MailBox instance for the given configuration. """ + ssl_context = ssl.create_default_context() + if settings.EMAIL_CERTIFICATE_FILE is not None: # pragma: nocover + ssl_context.load_cert_chain(certfile=settings.EMAIL_CERTIFICATE_FILE) + if security == MailAccount.ImapSecurity.NONE: mailbox = MailBoxUnencrypted(server, port) elif security == MailAccount.ImapSecurity.STARTTLS: - mailbox = MailBoxTls(server, port, ssl_context=ssl.create_default_context()) + mailbox = MailBoxTls(server, port, ssl_context=ssl_context) elif security == MailAccount.ImapSecurity.SSL: - mailbox = MailBox(server, port, ssl_context=ssl.create_default_context()) + mailbox = MailBox(server, port, ssl_context=ssl_context) else: raise NotImplementedError("Unknown IMAP security") # pragma: nocover return mailbox