Skip to content

Commit cba96af

Browse files
fix(outlook): skip mailbox-less accounts and stop SSL misconfig from aborting sync (#4085)
## Relates to elastic/sdh-search#1898 Fixes two independent failure modes in the Outlook Server connector that caused an entire sync job to abort when a single account or the SSL configuration was misconfigured. The guiding principle of this branch: **per-account problems skip that account and continue; connection-wide problems fail the sync loudly** (so they cannot silently empty the index). **Issue A — `ErrorNonExistentMailbox` kills the whole sync** When iterating over AD user accounts, `get_mails()` accesses `account.inbox`, which lazily resolves `account.root` via an EWS `GetFolder` call. For AD users whose SMTP address has no associated Exchange mailbox (a valid LDAP entry but no mailbox provisioned), `exchangelib` raises `ErrorNonExistentMailbox`. With no exception handling around the per-account block, this propagated up and terminated the sync, losing all remaining accounts. This is the still-open part of #2931. **Fix:** the per-account block in `get_docs()` is wrapped in `try/except ErrorNonExistentMailbox`. A missing mailbox is specific to a single account, so the account is skipped with a warning and the sync continues with the remaining accounts. **Issue B — `NO_CERTIFICATE_OR_CRL_FOUND` SSL crash** When `ssl_enabled=True` but no certificate is provided, `ssl_ca` is set to `""`. The old code wrote that empty string to the cert file and still selected `RootCAAdapter`. When urllib3 later called `context.load_verify_locations()` on the empty file it raised `NO_CERTIFICATE_OR_CRL_FOUND`, aborting the sync. The error occurs inside the HTTP layer (not inside `cert_verify()`), so `RootCAAdapter`'s own `try/except` never caught it. **Fix:** in `ExchangeUsers.get_user_accounts()`, the cert file is written and `RootCAAdapter` is selected only when `ssl_ca` is actually populated. When SSL is enabled but no certificate is supplied, the connector falls back to `NoVerifyHTTPAdapter` and logs a clear warning instead of crashing. ### Why connection-wide SSL errors are NOT skipped per account An earlier iteration of this branch also caught a custom `SSLFailed` exception inside the per-account loop to "skip" accounts on SSL problems. That approach was removed because it was both ineffective and dangerous: - **Ineffective:** `SSLFailed` is only raised inside `RootCAAdapter.cert_verify`, and `requests`' `cert_verify` does not actually load the certificate — it only records the CA path. A genuinely bad/expired cert fails later, during the TLS handshake, where `exchangelib` catches the underlying `requests.exceptions.SSLError` and re-raises it as `exchangelib.errors.TransportError` (and explicitly does **not** retry it). So `SSLFailed` was never raised in practice and the `except SSLFailed` clause was dead code. - **Dangerous:** an SSL/connection failure affects *every* account, not one. Silently skipping all accounts would produce an empty but "successful" sync, which the framework interprets as "all documents deleted" — wiping previously indexed data. Therefore only `ErrorNonExistentMailbox` (genuinely per-account) is skipped. Connection-wide failures — TLS errors surfaced as `TransportError`, and any other unexpected error — propagate and abort the sync loudly, so the misconfiguration is surfaced to the operator and existing indexed data is preserved. ## Checklists #### Pre-Review Checklist - [ ] this PR does NOT contain credentials of any kind, such as API keys or username/passwords (double check `config.yml.example`) - [x] this PR has a meaningful title - [x] this PR links to all relevant github issues that it fixes or partially addresses - [ ] if there is no GH issue, please create it. Each PR should have a link to an issue - [x] this PR has a thorough description - [x] Covered the changes with automated tests - [x] Tested the changes locally - [x] Added a label for each target release version (example: `v7.13.2`, `v7.14.0`, `v8.0.0`) - [ ] For bugfixes: backport safely to all minor branches still receiving patch releases - [ ] Considered corresponding documentation changes - [ ] Contributed any configuration settings changes to the configuration reference - [ ] if you added or changed Rich Configurable Fields for a Native Connector, you made a corresponding PR in [Kibana](https://github.com/elastic/kibana/blob/main/packages/kbn-search-connectors/types/native_connectors.ts) #### Changes Requiring Extra Attention - [x] Security-related changes (encryption, TLS, SSRF, etc) — the SSL cert fallback behaviour changes: `ssl_enabled=True` with no cert now falls back to `NoVerifyHTTPAdapter` (unverified connections) instead of crashing. A genuinely invalid/expired certificate now fails the sync loudly rather than being silently skipped. Reviewers should confirm the no-cert fallback posture is desired. ## Related Pull Requests * Partially addresses #2931 ## Release Note **Outlook Server connector**: sync jobs no longer abort when an Active Directory user has a valid SMTP address but no associated Exchange mailbox (`ErrorNonExistentMailbox`). The affected account is now skipped with a warning and the sync continues. Additionally, configuring SSL without providing a certificate no longer crashes with `NO_CERTIFICATE_OR_CRL_FOUND`; the connector falls back to unverified connections and logs a clear warning. Genuine certificate/connection errors still fail the sync loudly rather than silently emptying the index. Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent e038128 commit cba96af

4 files changed

Lines changed: 251 additions & 28 deletions

File tree

app/connectors_service/connectors/sources/outlook/datasource.py

Lines changed: 65 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@
44
# you may not use this file except in compliance with the Elastic License 2.0.
55
#
66

7+
import ssl
78
from copy import copy
89
from functools import cached_property, partial
910

10-
from connectors_sdk.source import BaseDataSource
11+
from connectors_sdk.source import BaseDataSource, ConfigurableFieldValueError
1112
from connectors_sdk.utils import (
1213
hash_id,
1314
iso_utc,
1415
)
16+
from exchangelib.errors import ErrorNonExistentMailbox
1517

1618
from connectors.access_control import ACCESS_CONTROL, es_access_control_query
1719
from connectors.sources.outlook.client import OutlookClient
@@ -294,6 +296,41 @@ def get_default_configuration(cls):
294296
},
295297
}
296298

299+
async def validate_config(self):
300+
"""Validate the configuration and the SSL certificate content.
301+
302+
The base field checks only confirm the certificate is present; this also
303+
confirms it actually loads, so a bad certificate fails here instead of
304+
mid-sync with an opaque SSL error.
305+
306+
Raises:
307+
ConfigurableFieldValueError: if SSL is enabled for an Exchange server
308+
source but the certificate is not a loadable PEM certificate.
309+
"""
310+
await super().validate_config()
311+
self._validate_ssl_certificate()
312+
313+
def _validate_ssl_certificate(self):
314+
if (
315+
self.configuration["data_source"] != OUTLOOK_SERVER
316+
or not self.configuration["ssl_enabled"]
317+
):
318+
return
319+
320+
# Load the exact PEM string used at sync time through the same OpenSSL
321+
# loader: load_verify_locations raises ssl.SSLError for malformed content
322+
# and ValueError for empty data.
323+
try:
324+
ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT).load_verify_locations(
325+
cadata=self.client.ssl_ca
326+
)
327+
except (ssl.SSLError, ValueError) as exception:
328+
msg = (
329+
"The provided SSL certificate is not valid. Provide a valid "
330+
"PEM-encoded certificate."
331+
)
332+
raise ConfigurableFieldValueError(msg) from exception
333+
297334
def _dls_enabled(self):
298335
"""Check if document level security is enabled. This method checks whether document level security (DLS) is enabled based on the provided configuration.
299336
@@ -562,24 +599,34 @@ async def get_docs(self, filtering=None):
562599
"""
563600
async for account in self.client._get_user_instance.get_user_accounts():
564601
timezone = account.default_timezone or DEFAULT_TIMEZONE
602+
try:
603+
async for mail in self._fetch_mails(account=account, timezone=timezone):
604+
yield mail
565605

566-
async for mail in self._fetch_mails(account=account, timezone=timezone):
567-
yield mail
568-
569-
async for contact in self._fetch_contacts(
570-
account=account, timezone=timezone
571-
):
572-
yield contact
606+
async for contact in self._fetch_contacts(
607+
account=account, timezone=timezone
608+
):
609+
yield contact
573610

574-
async for task in self._fetch_tasks(account=account, timezone=timezone):
575-
yield task
611+
async for task in self._fetch_tasks(account=account, timezone=timezone):
612+
yield task
576613

577-
async for calendar in self._fetch_calendars(
578-
account=account, timezone=timezone
579-
):
580-
yield calendar
614+
async for calendar in self._fetch_calendars(
615+
account=account, timezone=timezone
616+
):
617+
yield calendar
581618

582-
async for child_calendar in self._fetch_child_calendars(
583-
account=account, timezone=timezone
584-
):
585-
yield child_calendar
619+
async for child_calendar in self._fetch_child_calendars(
620+
account=account, timezone=timezone
621+
):
622+
yield child_calendar
623+
except ErrorNonExistentMailbox:
624+
# A missing mailbox is specific to this account, so skip it and
625+
# keep syncing the rest. Connection-wide failures (e.g. TLS
626+
# errors) are intentionally not caught here: they affect every
627+
# account, and silently skipping them would yield an empty but
628+
# "successful" sync that deletes previously indexed documents.
629+
self._logger.warning(
630+
f"Skipping account {account.primary_smtp_address}: "
631+
"the SMTP address has no associated mailbox."
632+
)

app/connectors_service/connectors/utils.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -558,11 +558,18 @@ def is_expired(expires_at):
558558

559559

560560
def get_pem_format(key, postfix="-----END CERTIFICATE-----"):
561-
"""Convert key into PEM format.
561+
"""Convert a key/certificate into PEM format.
562+
563+
Handles both formats users provide:
564+
565+
* Single-line, where PEM newlines were replaced by spaces: reflowed back
566+
into newlines.
567+
* Already multi-line PEM: only whitespace-normalized, since reflowing would
568+
break the space inside the BEGIN/END markers.
562569
563570
Args:
564-
key (str): Key in raw format.
565-
postfix (str): Certificate footer.
571+
key (str): Key/certificate in raw format.
572+
postfix (str): PEM footer used to detect single-line blocks.
566573
567574
Returns:
568575
string: PEM format
@@ -574,6 +581,13 @@ def get_pem_format(key, postfix="-----END CERTIFICATE-----"):
574581
PrivateKey
575582
-----END PRIVATE KEY-----"
576583
"""
584+
key = key.strip()
585+
586+
# Already multi-line: normalize whitespace instead of reflowing, which would
587+
# break the space inside the BEGIN/END markers.
588+
if "\n" in key:
589+
return "\n".join(line.strip() for line in key.splitlines() if line.strip())
590+
577591
pem_format = ""
578592
reverse_split = postfix.count(" ")
579593
if key.count(postfix) == 1:

app/connectors_service/tests/sources/test_outlook.py

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
import pytest
1313
from aiohttp import StreamReader
1414
from connectors_sdk.source import ConfigurableFieldValueError
15-
from exchangelib.errors import ErrorFolderNotFound
15+
from exchangelib.errors import (
16+
ErrorFolderNotFound,
17+
ErrorNonExistentMailbox,
18+
TransportError,
19+
)
1620

1721
from connectors.sources.outlook import OutlookDataSource
1822
from connectors.sources.outlook.client import (
@@ -770,6 +774,70 @@ async def test_exchange_get_user_accounts_normalizes_ldap_mail_list(mock_account
770774
)
771775

772776

777+
# Real self-signed certificate in the single-line form the connector receives.
778+
# load_verify_locations ignores validity dates, so it never expires for tests.
779+
VALID_SSL_CERTIFICATE = (
780+
"-----BEGIN CERTIFICATE----- "
781+
"MIICsjCCAZqgAwIBAgIUDznyN9v5Tk8muCxnL/Z2EFwtcBwwDQYJKoZIhvcNAQELBQAwEjEQMA4GA1UEAwwHdGVzdC1jYTAgFw0wMDAxMDEwMDAwMDBaGA8yMDk5MDEwMTAwMDAwMFowEjEQMA4GA1UEAwwHdGVzdC1jYTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKmQAqm3+hDpc9+OzTjhY4W/AASWa41qyeuKNL+K8kA6oh9TmT20YhPikxPzQCUxp/prm9pi9eym5VLh2GhNCCE8LR+TsrwZr2MpYGZph1Y/y4U5PVNZCOboCee44F/6f8huYtHRPSrOC1OHehvMwdfAC63MueN6oBtxIIOwktxlkuBbK5wY97QlY/utxMa72APdUh3TAyzA6GWum7rLvEafj1v7WRpJWkTpklFXhaGVm4u/SWeFiMfgIK+ciJgT04k0qbk8APwuPmLR5VmUNyMDOgLMtSLu9sbntVv+eLoAAiOFLk5ZpHs0Q8UPANdNMV03tgxaDnvtgzh7W0Qgvo8CAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAGPI/7KUIjHsNuRHUALIRtNVlhD80gdzKN27IEFLTu/jbiNEIGY59oV0qvx+iCPrLTLDnJkxHlnwApwB2WulXNg7+nYGHPP03jSLXKA+61GAN/ghPULl1DcA5Q+gunhPA4ITyqOr70i/3fphSXWjWfcX8hcym3pDcKzPIY3wV+dVeVdRdi9C1cTRuZ7zh2Chm7e4vM1SagLybMA4F8yckPJsRdVV5hZ+W6cI1H9fhjq/G1N0TyH4wG3FffRVniYVgAxY9m9RgMiQ5qCuc2PdktO7ovmNybijVG1aLVcHcYAS285f4JnZPIAJJMvCvW0NXDDphBNQPG5Nt1PHVgzUiNA== "
782+
"-----END CERTIFICATE-----"
783+
)
784+
785+
786+
@pytest.mark.asyncio
787+
async def test_validate_config_raises_when_ssl_enabled_without_certificate():
788+
# SSL enabled without a certificate must be rejected up front, not silently
789+
# downgraded or failed mid-sync.
790+
async with create_outlook_source(
791+
data_source=OUTLOOK_SERVER,
792+
username="foo.bar@gmail.com",
793+
password="abc@123",
794+
exchange_server="127.0.0.1",
795+
domain="gmail.com",
796+
active_directory_server="127.0.0.1",
797+
ssl_enabled=True,
798+
ssl_ca="",
799+
) as source:
800+
with pytest.raises(ConfigurableFieldValueError) as exc_info:
801+
await source.validate_config()
802+
803+
assert "SSL certificate" in str(exc_info.value)
804+
805+
806+
@pytest.mark.asyncio
807+
async def test_validate_config_raises_when_certificate_is_invalid():
808+
# A present but unloadable certificate would otherwise crash mid-sync with an
809+
# opaque X509 error.
810+
async with create_outlook_source(
811+
data_source=OUTLOOK_SERVER,
812+
username="foo.bar@gmail.com",
813+
password="abc@123",
814+
exchange_server="127.0.0.1",
815+
domain="gmail.com",
816+
active_directory_server="127.0.0.1",
817+
ssl_enabled=True,
818+
ssl_ca="this is not a certificate",
819+
) as source:
820+
with pytest.raises(ConfigurableFieldValueError) as exc_info:
821+
await source.validate_config()
822+
823+
assert "SSL certificate is not valid" in str(exc_info.value)
824+
825+
826+
@pytest.mark.asyncio
827+
async def test_validate_config_passes_when_ssl_enabled_with_valid_certificate():
828+
async with create_outlook_source(
829+
data_source=OUTLOOK_SERVER,
830+
username="foo.bar@gmail.com",
831+
password="abc@123",
832+
exchange_server="127.0.0.1",
833+
domain="gmail.com",
834+
active_directory_server="127.0.0.1",
835+
ssl_enabled=True,
836+
ssl_ca=VALID_SSL_CERTIFICATE,
837+
) as source:
838+
await source.validate_config()
839+
840+
773841
@pytest.mark.asyncio
774842
async def test_get_docs():
775843
async with create_outlook_source() as source:
@@ -780,6 +848,61 @@ async def test_get_docs():
780848
assert document in EXPECTED_RESPONSE
781849

782850

851+
def _account_raising_on_inbox(exception, smtp):
852+
"""Build a mock account whose first folder access (inbox) raises."""
853+
account = MagicMock()
854+
account.default_timezone = "UTC"
855+
account.primary_smtp_address = smtp
856+
type(account).inbox = mock.PropertyMock(side_effect=exception)
857+
return account
858+
859+
860+
@pytest.mark.asyncio
861+
async def test_get_docs_skips_account_without_mailbox_and_continues():
862+
async with create_outlook_source() as source:
863+
bad_account = _account_raising_on_inbox(
864+
ErrorNonExistentMailbox("no mailbox"), smtp="no.mailbox@example.com"
865+
)
866+
source.client._get_user_instance.get_user_accounts = AsyncIterator(
867+
[bad_account, MockAccount()]
868+
)
869+
source._logger = MagicMock()
870+
871+
documents = [document async for document, _ in source.get_docs()]
872+
873+
# The healthy account is still fully synced past the mail stage.
874+
assert all(document in EXPECTED_RESPONSE for document in documents)
875+
assert any(document["_id"] == "contact_1" for document in documents)
876+
877+
source._logger.warning.assert_called_once()
878+
warning_message = source._logger.warning.call_args.args[0]
879+
assert "no.mailbox@example.com" in warning_message
880+
assert "no associated mailbox" in warning_message
881+
882+
883+
@pytest.mark.asyncio
884+
@pytest.mark.parametrize(
885+
"exception",
886+
[
887+
# Connection-wide failures (e.g. exchangelib wraps TLS errors as
888+
# TransportError) must abort the sync rather than be silently skipped,
889+
# which would empty the index.
890+
TransportError("TLS verification failed"),
891+
RuntimeError("boom"),
892+
],
893+
)
894+
async def test_get_docs_reraises_connection_wide_error(exception):
895+
async with create_outlook_source() as source:
896+
bad_account = _account_raising_on_inbox(exception, smtp="broken@example.com")
897+
source.client._get_user_instance.get_user_accounts = AsyncIterator(
898+
[bad_account, MockAccount()]
899+
)
900+
901+
with pytest.raises(type(exception)):
902+
async for _document, _ in source.get_docs():
903+
pass
904+
905+
783906
@pytest.mark.asyncio
784907
async def test_get_contacts_resolves_via_distinguished_folder_id():
785908
async with create_outlook_source() as source:

app/connectors_service/tests/test_utils.py

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,12 @@ def test_evaluate_timedelta():
847847
assert expected_response == "2023-02-19T14:25:05.158843"
848848

849849

850+
MULTILINE_PEM_CERTIFICATE = """-----BEGIN CERTIFICATE-----
851+
Certificate1
852+
Certificate2
853+
-----END CERTIFICATE-----"""
854+
855+
850856
def test_get_pem_format_with_postfix():
851857
expected_formatted_pem_key = """-----BEGIN PRIVATE KEY-----
852858
PrivateKey
@@ -860,14 +866,9 @@ def test_get_pem_format_with_postfix():
860866

861867

862868
def test_get_pem_format_multiline():
863-
expected_formatted_certificate = """-----BEGIN CERTIFICATE-----
864-
Certificate1
865-
Certificate2
866-
-----END CERTIFICATE-----"""
867869
certificate = "-----BEGIN CERTIFICATE----- Certificate1 Certificate2 -----END CERTIFICATE-----"
868870

869-
formatted_certificate = get_pem_format(key=certificate)
870-
assert formatted_certificate == expected_formatted_certificate
871+
assert get_pem_format(key=certificate) == MULTILINE_PEM_CERTIFICATE
871872

872873

873874
def test_get_pem_format_multiple_certificates():
@@ -884,6 +885,44 @@ def test_get_pem_format_multiple_certificates():
884885
assert formatted_multi_certificate == expected_formatted_multiple_certificates
885886

886887

888+
def test_get_pem_format_already_multiline_certificate_is_preserved():
889+
# Copied verbatim from a .pem file: the intact END marker must not be broken.
890+
assert get_pem_format(key=MULTILINE_PEM_CERTIFICATE) == MULTILINE_PEM_CERTIFICATE
891+
892+
893+
def test_get_pem_format_already_multiline_private_key_is_preserved():
894+
private_key = """-----BEGIN PRIVATE KEY-----
895+
PrivateKey
896+
-----END PRIVATE KEY-----"""
897+
898+
assert (
899+
get_pem_format(key=private_key, postfix="-----END PRIVATE KEY-----")
900+
== private_key
901+
)
902+
903+
904+
def test_get_pem_format_multiline_with_surrounding_whitespace_is_normalized():
905+
certificate = (
906+
"\n -----BEGIN CERTIFICATE----- \n"
907+
"Certificate1\n"
908+
"\n"
909+
"Certificate2\n"
910+
" -----END CERTIFICATE----- \n"
911+
)
912+
913+
assert get_pem_format(key=certificate) == MULTILINE_PEM_CERTIFICATE
914+
915+
916+
def test_get_pem_format_single_line_with_trailing_newline_is_reflowed():
917+
# A trailing newline alone must not be mistaken for multi-line content.
918+
certificate = (
919+
"-----BEGIN CERTIFICATE----- Certificate1 Certificate2 "
920+
"-----END CERTIFICATE-----\n"
921+
)
922+
923+
assert get_pem_format(key=certificate) == MULTILINE_PEM_CERTIFICATE
924+
925+
887926
def test_truncate_id():
888927
long_id = "something-12341361361-21905128510263"
889928
truncated_id = truncate_id(long_id)

0 commit comments

Comments
 (0)