Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allows to choose SSL context for IMAP provider #33108

Merged
merged 3 commits into from
Aug 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions airflow/providers/imap/CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@
Changelog
---------

In case of IMAP SSL connection, the context now uses the "default" context

The "default" context is Python's ``default_ssl_context`` instead of previously used "none". The
``default_ssl_context`` provides a balance between security and compatibility but in some cases,
when certificates are old, self-signed or misconfigured, it might not work. This can be configured
by setting "ssl_context" in "imap" configuration of the provider. If it is not explicitly set,
it will default to "email", "ssl_context" setting in Airflow.

Setting it to "none" brings back the "none" setting that was used in previous versions of the provider,
but it is not recommended due to security reasons and this setting disables validation
of certificates and allows MITM attacks.

3.2.2
.....

Expand Down
37 changes: 28 additions & 9 deletions airflow/providers/imap/hooks/imap.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import imaplib
import os
import re
import ssl
from typing import Any, Iterable

from airflow.exceptions import AirflowException
Expand Down Expand Up @@ -78,16 +79,34 @@ def get_conn(self) -> ImapHook:
return self

def _build_client(self, conn: Connection) -> imaplib.IMAP4_SSL | imaplib.IMAP4:
IMAP: type[imaplib.IMAP4_SSL] | type[imaplib.IMAP4]
if conn.extra_dejson.get("use_ssl", True):
IMAP = imaplib.IMAP4_SSL
else:
IMAP = imaplib.IMAP4

if conn.port:
mail_client = IMAP(conn.host, conn.port)
mail_client: imaplib.IMAP4_SSL | imaplib.IMAP4
use_ssl = conn.extra_dejson.get("use_ssl", True)
if use_ssl:
from airflow.configuration import conf

ssl_context_string = conf.get("imap", "SSL_CONTEXT", fallback=None)
if ssl_context_string is None:
ssl_context_string = conf.get("email", "SSL_CONTEXT", fallback=None)
if ssl_context_string is None:
ssl_context_string = "default"
if ssl_context_string == "default":
ssl_context = ssl.create_default_context()
elif ssl_context_string == "none":
potiuk marked this conversation as resolved.
Show resolved Hide resolved
ssl_context = None
else:
raise RuntimeError(
f"The email.ssl_context configuration variable must "
f"be set to 'default' or 'none' and is '{ssl_context_string}'."
)
if conn.port:
mail_client = imaplib.IMAP4_SSL(conn.host, conn.port, ssl_context=ssl_context)
else:
mail_client = imaplib.IMAP4_SSL(conn.host, ssl_context=ssl_context)
else:
mail_client = IMAP(conn.host)
if conn.port:
mail_client = imaplib.IMAP4(conn.host, conn.port)
else:
mail_client = imaplib.IMAP4(conn.host)

return mail_client

Expand Down
23 changes: 23 additions & 0 deletions airflow/providers/imap/provider.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,26 @@ hooks:
connection-types:
- hook-class-name: airflow.providers.imap.hooks.imap.ImapHook
connection-type: imap

config:
imap:
description: "Options for IMAP provider."
options:
ssl_context:
description: |
ssl context to use when using SMTP and IMAP SSL connections. By default, the context is "default"
which sets it to ``ssl.create_default_context()`` which provides the right balance between
compatibility and security, it however requires that certificates in your operating system are
updated and that SMTP/IMAP servers of yours have valid certificates that have corresponding public
keys installed on your machines. You can switch it to "none" if you want to disable checking
of the certificates, but it is not recommended as it allows MITM (man-in-the-middle) attacks
if your infrastructure is not sufficiently secured. It should only be set temporarily while you
are fixing your certificate configuration. This can be typically done by upgrading to newer
version of the operating system you run Airflow components on,by upgrading/refreshing proper
certificates in the OS or by updating certificates for your mail servers.
If you do not set this option explicitly, it will use Airflow "email.ssl_context" configuration,
but if this configuration is not present, it will use "default" value.
type: string
version_added: 3.3.0
example: "default"
default: ~
18 changes: 18 additions & 0 deletions docs/apache-airflow-providers-imap/configurations-ref.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.. Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at

.. http://www.apache.org/licenses/LICENSE-2.0

.. Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.

.. include:: ../exts/includes/providers-configurations-ref.rst
1 change: 1 addition & 0 deletions docs/apache-airflow-providers-imap/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
:maxdepth: 1
:caption: References

Configuration <configurations-ref>
Connection types <connections/imap>
Python API <_api/airflow/providers/imap/index>

Expand Down
1 change: 1 addition & 0 deletions docs/apache-airflow/configurations-ref.rst
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ in the provider's documentation. The pre-installed providers that you may want t
* :doc:`Configuration Reference for Apache Hive Provider <apache-airflow-providers-apache-hive:configurations-ref>`
* :doc:`Configuration Reference for CNCF Kubernetes Provider <apache-airflow-providers-cncf-kubernetes:configurations-ref>`
* :doc:`Configuration Reference for SMTP Provider <apache-airflow-providers-smtp:configurations-ref>`
* :doc:`Configuration Reference for IMAP Provider <apache-airflow-providers-imap:configurations-ref>`

.. note::
For more information see :doc:`/howto/set-config`.
Expand Down
69 changes: 67 additions & 2 deletions tests/providers/imap/hooks/test_imap.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from airflow.models import Connection
from airflow.providers.imap.hooks.imap import ImapHook
from airflow.utils import db
from tests.test_utils.config import conf_vars

imaplib_string = "airflow.providers.imap.hooks.imap.imaplib"
open_string = "airflow.providers.imap.hooks.imap.open"
Expand Down Expand Up @@ -85,13 +86,77 @@ def setup_method(self):
)

@patch(imaplib_string)
def test_connect_and_disconnect(self, mock_imaplib):
@patch("ssl.create_default_context")
def test_connect_and_disconnect(self, create_default_context, mock_imaplib):
mock_conn = _create_fake_imap(mock_imaplib)

with ImapHook():
pass

mock_imaplib.IMAP4_SSL.assert_called_once_with("imap_server_address", 1993)
assert create_default_context.called
mock_imaplib.IMAP4_SSL.assert_called_once_with(
"imap_server_address", 1993, ssl_context=create_default_context.return_value
)
mock_conn.login.assert_called_once_with("imap_user", "imap_password")
assert mock_conn.logout.call_count == 1

@patch(imaplib_string)
@patch("ssl.create_default_context")
def test_connect_and_disconnect_imap_ssl_context_none(self, create_default_context, mock_imaplib):
mock_conn = _create_fake_imap(mock_imaplib)

with conf_vars({("imap", "ssl_context"): "none"}):
with ImapHook():
pass

assert not create_default_context.called
mock_imaplib.IMAP4_SSL.assert_called_once_with("imap_server_address", 1993, ssl_context=None)
mock_conn.login.assert_called_once_with("imap_user", "imap_password")
assert mock_conn.logout.call_count == 1

@patch(imaplib_string)
@patch("ssl.create_default_context")
def test_connect_and_disconnect_imap_ssl_context_default(self, create_default_context, mock_imaplib):
mock_conn = _create_fake_imap(mock_imaplib)

with conf_vars({("imap", "ssl_context"): "default"}):
with ImapHook():
pass

assert create_default_context.called
mock_imaplib.IMAP4_SSL.assert_called_once_with(
"imap_server_address", 1993, ssl_context=create_default_context.return_value
)
mock_conn.login.assert_called_once_with("imap_user", "imap_password")
assert mock_conn.logout.call_count == 1

@patch(imaplib_string)
@patch("ssl.create_default_context")
def test_connect_and_disconnect_email_ssl_context_none(self, create_default_context, mock_imaplib):
mock_conn = _create_fake_imap(mock_imaplib)

with conf_vars({("email", "ssl_context"): "none"}):
with ImapHook():
pass

assert not create_default_context.called
mock_imaplib.IMAP4_SSL.assert_called_once_with("imap_server_address", 1993, ssl_context=None)
mock_conn.login.assert_called_once_with("imap_user", "imap_password")
assert mock_conn.logout.call_count == 1

@patch(imaplib_string)
@patch("ssl.create_default_context")
def test_connect_and_disconnect_imap_ssl_context_override(self, create_default_context, mock_imaplib):
mock_conn = _create_fake_imap(mock_imaplib)

with conf_vars({("email", "ssl_context"): "none", ("imap", "ssl_context"): "default"}):
with ImapHook():
pass

assert create_default_context.called
mock_imaplib.IMAP4_SSL.assert_called_once_with(
"imap_server_address", 1993, ssl_context=create_default_context.return_value
)
mock_conn.login.assert_called_once_with("imap_user", "imap_password")
assert mock_conn.logout.call_count == 1

Expand Down
Loading