Harden mail connector network boundaries

This commit is contained in:
2026-07-21 12:10:32 +02:00
parent b2c013174a
commit e67da95df6
5 changed files with 209 additions and 12 deletions

View File

@@ -12,6 +12,13 @@ from email.message import EmailMessage
from email.parser import BytesParser
from typing import Any
from govoplan_core.security.outbound_http import (
OutboundHttpError,
create_outbound_connection,
response_limit,
validate_outbound_host,
)
from govoplan_mail.backend.config import ImapConfig, TransportSecurity
from govoplan_mail.backend.dev.mock_mailbox import (
MOCK_IMAP_FOLDERS,
@@ -25,6 +32,31 @@ from govoplan_mail.backend.dev.mock_mailbox import (
logger = logging.getLogger(__name__)
class _OutboundPolicyIMAP4(imaplib.IMAP4):
def _create_socket(self, timeout: float | None): # type: ignore[no-untyped-def]
return create_outbound_connection(
self.host,
self.port,
timeout=timeout,
label="IMAP connector",
)
class _OutboundPolicyIMAP4SSL(imaplib.IMAP4_SSL):
def _create_socket(self, timeout: float | None): # type: ignore[no-untyped-def]
sock = create_outbound_connection(
self.host,
self.port,
timeout=timeout,
label="IMAP connector",
)
try:
return self.ssl_context.wrap_socket(sock, server_hostname=self.host)
except Exception:
sock.close()
raise
class ImapConfigurationError(ValueError):
"""Raised when IMAP settings are incomplete or inconsistent."""
@@ -163,13 +195,22 @@ def _require_imap_config(config: ImapConfig) -> tuple[str, int]:
def _open_imap(config: ImapConfig) -> imaplib.IMAP4:
host, port = _require_imap_config(config)
try:
validate_outbound_host(host, port=port, label="IMAP connector")
except OutboundHttpError as exc:
raise ImapConfigurationError(str(exc)) from exc
context = ssl.create_default_context()
try:
if config.security == TransportSecurity.TLS:
client: imaplib.IMAP4 = imaplib.IMAP4_SSL(host=host, port=port, timeout=config.timeout_seconds, ssl_context=context)
client: imaplib.IMAP4 = _OutboundPolicyIMAP4SSL(
host=host,
port=port,
timeout=config.timeout_seconds,
ssl_context=context,
)
else:
client = imaplib.IMAP4(host=host, port=port, timeout=config.timeout_seconds)
client = _OutboundPolicyIMAP4(host=host, port=port, timeout=config.timeout_seconds)
if config.security == TransportSecurity.STARTTLS:
typ, data = client.starttls(ssl_context=context)
if typ != "OK":
@@ -704,18 +745,22 @@ def _select_readonly(client: imaplib.IMAP4, folder: str) -> tuple[int, str | Non
def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[str], int | None, bytes]:
typ, data = client.uid("fetch", str(uid), "(UID FLAGS RFC822.SIZE BODY.PEEK[])")
max_bytes = response_limit("file")
typ, data = client.uid("fetch", str(uid), f"(UID FLAGS RFC822.SIZE BODY.PEEK[]<0.{max_bytes + 1}>)")
if typ != "OK":
raise ImapAppendError(f"IMAP message {uid!r} fetch failed: {data!r}", temporary=True)
fetched_uid, flags, size_bytes, raw = _parse_fetch_response(data)
_ensure_imap_payload_within_limit(raw, declared_size=size_bytes, max_bytes=max_bytes, label="IMAP message")
return fetched_uid or str(uid), flags, size_bytes, raw
def _fetch_message_by_sequence(client: imaplib.IMAP4, sequence: str) -> tuple[str, list[str], int | None, bytes]:
typ, data = client.fetch(str(sequence), "(UID FLAGS RFC822.SIZE BODY.PEEK[])")
max_bytes = response_limit("file")
typ, data = client.fetch(str(sequence), f"(UID FLAGS RFC822.SIZE BODY.PEEK[]<0.{max_bytes + 1}>)")
if typ != "OK":
raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch failed: {data!r}", temporary=True)
fetched_uid, flags, size_bytes, raw = _parse_fetch_response(data)
_ensure_imap_payload_within_limit(raw, declared_size=size_bytes, max_bytes=max_bytes, label="IMAP message")
if not fetched_uid:
raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch returned no UID", temporary=True)
return fetched_uid, flags, size_bytes, raw
@@ -734,12 +779,19 @@ def _sequence_set(sequences: list[str]) -> str:
def _fetch_message_summaries_by_sequence(client: imaplib.IMAP4, sequences: list[str], folder: str) -> list[ImapMailboxMessageSummary]:
if not sequences:
return []
typ, data = client.fetch(_sequence_set(sequences), "(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)])")
max_bytes = response_limit("structured")
per_message_bytes = max(1, max_bytes // len(sequences)) + 1
typ, data = client.fetch(
_sequence_set(sequences),
f"(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)]<0.{per_message_bytes}>)",
)
if typ != "OK":
raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True)
by_sequence: dict[str, ImapMailboxMessageSummary] = {}
for sequence, fetched_uid, flags, size_bytes, headers in _parse_fetch_parts_with_sequence(data):
parsed_parts = _parse_fetch_parts_with_sequence(data)
_ensure_imap_batch_within_limit(parsed_parts, max_bytes=max_bytes, label="IMAP message summary response")
for sequence, fetched_uid, flags, size_bytes, headers in parsed_parts:
if not sequence or not fetched_uid:
continue
by_sequence[sequence] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes)
@@ -791,12 +843,20 @@ def _paged_descending_uids(
def _fetch_message_summaries_by_uid(client: imaplib.IMAP4, uids: list[str], folder: str) -> list[ImapMailboxMessageSummary]:
if not uids:
return []
typ, data = client.uid("fetch", _sequence_set(uids), "(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)])")
max_bytes = response_limit("structured")
per_message_bytes = max(1, max_bytes // len(uids)) + 1
typ, data = client.uid(
"fetch",
_sequence_set(uids),
f"(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)]<0.{per_message_bytes}>)",
)
if typ != "OK":
raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True)
by_uid: dict[str, ImapMailboxMessageSummary] = {}
for _sequence, fetched_uid, flags, size_bytes, headers in _parse_fetch_parts_with_sequence(data):
parsed_parts = _parse_fetch_parts_with_sequence(data)
_ensure_imap_batch_within_limit(parsed_parts, max_bytes=max_bytes, label="IMAP message summary response")
for _sequence, fetched_uid, flags, size_bytes, headers in parsed_parts:
if not fetched_uid:
continue
by_uid[fetched_uid] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes)
@@ -812,6 +872,27 @@ def _fetch_message_summaries_by_uid(client: imaplib.IMAP4, uids: list[str], fold
return summaries
def _ensure_imap_payload_within_limit(
payload: bytes,
*,
declared_size: int | None,
max_bytes: int,
label: str,
) -> None:
if (declared_size is not None and declared_size > max_bytes) or len(payload) > max_bytes:
raise ImapAppendError(f"{label} exceeds the deployment limit of {max_bytes} bytes", temporary=False)
def _ensure_imap_batch_within_limit(
parts: list[tuple[str | None, str | None, list[str], int | None, bytes]],
*,
max_bytes: int,
label: str,
) -> None:
if sum(len(part[4]) for part in parts) > max_bytes:
raise ImapAppendError(f"{label} exceeds the deployment limit of {max_bytes} bytes", temporary=False)
def _mock_record_folder(record: dict[str, Any]) -> str:
folder = str(record.get("folder") or "").strip()
if folder:

View File

@@ -8,6 +8,12 @@ from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formataddr
from govoplan_core.security.outbound_http import (
OutboundHttpError,
create_outbound_connection,
validate_outbound_host,
)
from govoplan_mail.backend.config import SmtpConfig, TransportSecurity
from govoplan_mail.backend.dev.mock_mailbox import (
consume_fail_next_smtp,
@@ -19,6 +25,33 @@ from govoplan_mail.backend.dev.mock_mailbox import (
logger = logging.getLogger(__name__)
class _OutboundPolicySMTP(smtplib.SMTP):
def _get_socket(self, host: str, port: int, timeout: float | None): # type: ignore[no-untyped-def]
return create_outbound_connection(
host,
port,
timeout=timeout,
source_address=self.source_address,
label="SMTP connector",
)
class _OutboundPolicySMTPSSL(smtplib.SMTP_SSL):
def _get_socket(self, host: str, port: int, timeout: float | None): # type: ignore[no-untyped-def]
sock = create_outbound_connection(
host,
port,
timeout=timeout,
source_address=self.source_address,
label="SMTP connector",
)
try:
return self.context.wrap_socket(sock, server_hostname=self._host)
except Exception:
sock.close()
raise
class SmtpConfigurationError(ValueError):
"""Raised when SMTP settings are incomplete or inconsistent."""
@@ -75,14 +108,23 @@ def _require_smtp_config(config: SmtpConfig) -> tuple[str, int]:
def _open_smtp(config: SmtpConfig) -> smtplib.SMTP:
host, port = _require_smtp_config(config)
try:
validate_outbound_host(host, port=port, label="SMTP connector")
except OutboundHttpError as exc:
raise SmtpConfigurationError(str(exc)) from exc
context = ssl.create_default_context()
try:
if config.security == TransportSecurity.TLS:
smtp: smtplib.SMTP = smtplib.SMTP_SSL(host=host, port=port, timeout=config.timeout_seconds, context=context)
smtp: smtplib.SMTP = _OutboundPolicySMTPSSL(
host=host,
port=port,
timeout=config.timeout_seconds,
context=context,
)
smtp.ehlo()
else:
smtp = smtplib.SMTP(host=host, port=port, timeout=config.timeout_seconds)
smtp = _OutboundPolicySMTP(host=host, port=port, timeout=config.timeout_seconds)
smtp.ehlo()
if config.security == TransportSecurity.STARTTLS:
smtp.starttls(context=context)