Allow users to set a combined certificte and key file for additional certificates in the SSL context

This commit is contained in:
Trenton H 2023-08-21 13:21:02 -07:00
parent d1ae82c5c2
commit 16adddc803
5 changed files with 83 additions and 7 deletions

View File

@ -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=<path>`
: 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/)

View File

@ -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()
)

View File

@ -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 #

View File

@ -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)

View File

@ -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