From 1c4f9b4f05de575cd99ac5bad68c6e0bbad25374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodolfo=20Garc=C3=ADa=20Pe=C3=B1as=20=28kix=29?= Date: Wed, 13 May 2026 18:35:54 +0200 Subject: [PATCH] Enhance STARTTLS handling with fallback for non-standard IMAP servers - Implement tolerance for non-standard IMAP servers that do not provide reliable CAPABILITY after STARTTLS. - Introduce configuration option 'allow_nonstandard_capabilities' to enable fallback using pre-TLS capabilities. --- offlineimap/imapserver.py | 110 +++++++++++++++++++++++++++------ offlineimap/repository/IMAP.py | 7 +++ 2 files changed, 98 insertions(+), 19 deletions(-) diff --git a/offlineimap/imapserver.py b/offlineimap/imapserver.py index fd866867..b29b5d6c 100644 --- a/offlineimap/imapserver.py +++ b/offlineimap/imapserver.py @@ -363,32 +363,95 @@ def __gsshandler(self, token): return None def __start_tls(self, imapobj): - if 'STARTTLS' in imapobj.capabilities and not self.usessl: - self.ui.debug('imap', 'Using STARTTLS connection') - # imaplib2.starttls() unconditionally calls _get_capabilities() right - # after the TLS handshake (before setting _tls_established=True). - # Servers like Protonmail Bridge do not respond to CAPABILITY at - # that stage, causing a 60-second hang. We bypass it by temporarily - # substituting a no-op; imaplib2 still sets _tls_established=True - # normally, and acquireconnection() already guards its own - # post-login CAPABILITY refresh against that flag (see below). - _orig_get_cap = imapobj._get_capabilities - imapobj._get_capabilities = lambda: None + """Upgrade connection to TLS if STARTTLS is configured. + + - Uses pre-TLS capabilities only to decide whether to perform STARTTLS. + - After STARTTLS, forces a CAPABILITY command and replaces internal + capabilities with the post-TLS response. + - If no reliable post-TLS CAPABILITY is available: + - In strict mode (allow_nonstandard_capabilities = no): abort. + - In tolerant mode (allow_nonstandard_capabilities = yes): + reuse pre-TLS capabilities, removing LOGINDISABLED. + """ + + # If the repository does not want STARTTLS, do nothing. + if not self.starttls or self.usessl: + return + + # Pre-TLS capabilities (from initial banner / CAPABILITY). + caps_pre = set(getattr(imapobj, '_offlineimap_capabilities_pre_tls', + getattr(imapobj, 'capabilities', []))) + + # If the server does not advertise STARTTLS, respect that. + if 'STARTTLS' not in caps_pre: + return + + # Execute STARTTLS. + self.ui.debug('imap', 'Using STARTTLS connection') + + # Disable _get_capabilities() during starttls() to prevent imaplib2 + # from issuing CAPABILITY before we can do it ourselves. + _orig_get_cap = imapobj._get_capabilities + imapobj._get_capabilities = lambda: None + try: try: imapobj.starttls() except imapobj.error as e: err = "Failed to start TLS connection: %s" % str(e) raise OfflineImapError(err, OfflineImapError.ERROR.REPO, exc_info()[2]) - finally: - # Always restore the original method, even on success. - imapobj._get_capabilities = _orig_get_cap - elif self.starttls and not self.usessl: + finally: + # Always restore the original method, even on error. + imapobj._get_capabilities = _orig_get_cap + + # At this point, the socket is encrypted. Now we must refresh CAPABILITY. + caps_post = None + try: + typ, data = imapobj.capability() + if typ == 'OK' and data: + # data is a list of bytes, e.g. [b'IMAP4rev1 IDLE AUTH=PLAIN'] + line = data[0] + if isinstance(line, bytes): + line = line.decode('ascii', 'ignore') + caps_post = set(line.upper().split()) + except Exception: + caps_post = None + + if caps_post is not None: + # Standard path: completely replace capabilities + imapobj.capabilities = caps_post + imapobj._offlineimap_capabilities_post_tls = caps_post + return + + # If we reach here, we do not have reliable post-TLS capabilities. + # Decide based on repository configuration. + allow_nonstandard = getattr(self.repos, + 'allow_nonstandard_capabilities', False) + + if not allow_nonstandard: + # Strict mode: abort with clear error. raise OfflineImapError( - "STARTTLS requested but server does not advertise STARTTLS capability. " - "Aborting to prevent sending password in plaintext (possible MITM attack). " - "If you REALLY want an insecure connection, set 'starttls = no'.", - OfflineImapError.ERROR.REPO) + "Server did not provide valid CAPABILITY after STARTTLS; " + "set 'allow_nonstandard_capabilities = yes' in repository " + "configuration to enable a non-standard fallback.", + OfflineImapError.ERROR.REPO + ) + + # Tolerant mode: best-effort using caps_pre. + caps_fallback = set(caps_pre) + if 'LOGINDISABLED' in caps_fallback: + # We assume that after STARTTLS, LOGINDISABLED no longer applies, + # so we remove it to allow LOGIN/AUTH configured by the user. + caps_fallback.remove('LOGINDISABLED') + + imapobj.capabilities = caps_fallback + imapobj._offlineimap_capabilities_post_tls = caps_fallback + + self.ui.warn( + "Server did not provide CAPABILITY after STARTTLS; " + "falling back to pre-TLS capabilities without LOGINDISABLED " + "due to allow_nonstandard_capabilities = yes." + ) # All __authn_* procedures are helpers that do authentication. # They are class methods that take one parameter, IMAP object. @@ -605,6 +668,15 @@ def acquireconnection(self): self.lastowner[imapobj] = curThread.ident self.connectionlock.release() + # Store the pre-TLS capabilities (from banner and initial CAPABILITY). + # This will be used only to decide STARTTLS and, in case of non-standard + # fallback, to reconstruct an approximate post-TLS capability list. + try: + caps_pre = set(getattr(imapobj, 'capabilities', [])) + except Exception: + caps_pre = set() + imapobj._offlineimap_capabilities_pre_tls = caps_pre + # Verify that the connection is still alive before returning it # to the caller. If not, clean up and recursively call # acquireconnection() to get a new one. diff --git a/offlineimap/repository/IMAP.py b/offlineimap/repository/IMAP.py index b1afe861..db64dae1 100644 --- a/offlineimap/repository/IMAP.py +++ b/offlineimap/repository/IMAP.py @@ -38,6 +38,13 @@ def __init__(self, reposname, account): self.idlefolders = None BaseRepository.__init__(self, reposname, account) # self.ui is being set by the BaseRepository + + # Allow tolerance for non-standard IMAP servers that do not provide + # reliable CAPABILITY after STARTTLS. Default is strict (False). + # If set to True, heuristics using pre-TLS capabilities are applied + # as a fallback. + self.allow_nonstandard_capabilities = self.getconfboolean( + 'allow_nonstandard_capabilities', False) self._host = None # Must be set before calling imapserver.IMAPServer(self) self.oauth2_request_url = None