From 813bc146640056dd4ce2e073900576cd05693e93 Mon Sep 17 00:00:00 2001 From: Mario Fetka Date: Tue, 28 Jul 2026 14:07:37 +0200 Subject: [PATCH] tests: cover trusted SMTP fallback headers --- contrib/testing/smtp-forward-header-check.py | 264 ++++++++++++++++++ .../testing/smtp-forward-metadata-check.py | 7 +- 2 files changed, 269 insertions(+), 2 deletions(-) create mode 100755 contrib/testing/smtp-forward-header-check.py diff --git a/contrib/testing/smtp-forward-header-check.py b/contrib/testing/smtp-forward-header-check.py new file mode 100755 index 0000000..dbc09c2 --- /dev/null +++ b/contrib/testing/smtp-forward-header-check.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +# This program is free software, licensed under the terms of the GNU GPL. +# See the Bongo COPYING file for full details. +# Copyright (c) 2026 Bongo Project contributors + +"""Verify trusted client-address header fallback and spoof protection.""" + +from __future__ import annotations + +import copy +import importlib.util +import ipaddress +import json +import os +from pathlib import Path +import re +import smtplib +import sys +import threading +import time + + +TIMEOUT = float(os.environ.get("BONGO_TEST_TIMEOUT", "45")) +TOKEN = f"smtp23-{os.getpid()}-{time.time_ns()}" +FORGED_CLIENT = "203.0.113.66" +FORGED_FORWARDED = "198.51.100.77" + + +class SMTP23Error(RuntimeError): + """Raised when fallback trace headers violate their trust contract.""" + + +def load_smtp22(): + path = Path(__file__).with_name("smtp-forward-metadata-check.py") + specification = importlib.util.spec_from_file_location( + "bongo_smtp23_smtp22", path + ) + if specification is None or specification.loader is None: + raise SMTP23Error(f"cannot load SMTP-22 fixture from {path}") + module = importlib.util.module_from_spec(specification) + sys.modules[specification.name] = module + specification.loader.exec_module(module) + return module + + +SMTP22 = load_smtp22() +SMTP22.TOKEN = TOKEN + + +def fallback_configuration( + original: dict, + *, + headers: list[str], + xforward: list[str], +) -> dict: + configuration = copy.deepcopy(original) + configuration.update( + { + "use_relay_host": False, + "port": SMTP22.TEMPORARY_EXTERNAL_PORT, + "xclient_trusted_destinations": [], + "xforward_trusted_destinations": xforward, + "proxy_header_trusted_destinations": headers, + } + ) + return configuration + + +def submit(host: str, port: int, case: str) -> str: + marker = f"{TOKEN}-{case}" + message = ( + "From: SMTP-23 fixture \r\n" + f"To: capture-{case}@[{SMTP22.FIXTURE_HOST}]\r\n" + f"Subject: {marker}\r\n" + f"Message-ID: <{marker}@bongo.test>\r\n" + f"X-Client-Addr: {FORGED_CLIENT}\r\n" + f"\tforged continuation {FORGED_FORWARDED}\r\n" + f"X-Forwarded-Addr: {FORGED_FORWARDED}\r\n" + "\r\n" + f"Outbound fallback fixture {marker}\r\n" + ).encode("ascii") + with smtplib.SMTP(host, port, timeout=10) as client: + client.ehlo("smtp23-source.bongo.test") + refused = client.sendmail( + "smtp23@bongo.test", + [f"capture-{case}@[{SMTP22.FIXTURE_HOST}]"], + message, + ) + if refused: + raise SMTP23Error( + f"Bongo refused SMTP-23 ingress recipient: {refused}" + ) + return marker + + +def header_values(message: bytes, name: str) -> list[str]: + pattern = re.compile( + rb"^" + re.escape(name.encode("ascii")) + rb":[ \t]*([^\r\n]*)", + re.IGNORECASE | re.MULTILINE, + ) + return [ + value.decode("ascii", "strict").strip() + for value in pattern.findall(message) + ] + + +def validate_untrusted(delivery) -> None: + if header_values(delivery.message, "X-Client-Addr"): + raise SMTP23Error("untrusted delivery retained X-Client-Addr") + if header_values(delivery.message, "X-Forwarded-Addr"): + raise SMTP23Error("untrusted delivery retained X-Forwarded-Addr") + + +def validate_fallback(delivery, expected_address: str) -> None: + client = header_values(delivery.message, "X-Client-Addr") + forwarded = header_values(delivery.message, "X-Forwarded-Addr") + if client != [expected_address] or forwarded != [expected_address]: + raise SMTP23Error( + "trusted fallback headers are missing, duplicated, or incorrect: " + f"client={client!r} forwarded={forwarded!r}" + ) + try: + ipaddress.IPv4Address(expected_address) + except ipaddress.AddressValueError as error: + raise SMTP23Error( + f"fallback contains an invalid IPv4 address: {expected_address}" + ) from error + if FORGED_CLIENT.encode("ascii") in delivery.message or \ + FORGED_FORWARDED.encode("ascii") in delivery.message: + raise SMTP23Error("forged client-address trace survived sanitization") + + +def validate_xforward_suppression(delivery) -> None: + commands = SMTP22.command_of(delivery, "XFORWARD") + if len(commands) != 1 or "ADDR=" not in commands[0]: + raise SMTP23Error( + f"trusted XFORWARD address was not negotiated: {commands!r}" + ) + if header_values(delivery.message, "X-Client-Addr") or \ + header_values(delivery.message, "X-Forwarded-Addr"): + raise SMTP23Error( + "fallback headers were duplicated after XFORWARD carried ADDR" + ) + + +def perform_test() -> None: + original_smtp = SMTP22.read_configuration("smtp") + original_global = SMTP22.read_configuration("global") + internal_host = str( + original_smtp.get("internal_relay_bind_address", "") + ).strip() + if not internal_host or internal_host in {"0.0.0.0", "::"}: + internal_host = str(original_global.get("hostaddr", "")).strip() + if not internal_host or internal_host in {"0.0.0.0", "::"}: + internal_host = "127.0.0.1" + internal_port = int(original_smtp.get("internal_relay_port", 26)) + SMTP22.SMTP07.INTERNAL_HOST = internal_host + SMTP22.SMTP07.INTERNAL_PORT = internal_port + + capture = SMTP22.Capture() + server = None + thread = None + configuration_changed = False + try: + SMTP22.replace_configuration( + "smtp", + fallback_configuration( + original_smtp, + headers=["192.0.2.0/24"], + xforward=[], + ), + ) + configuration_changed = True + SMTP22.restart_bongo() + server = SMTP22.ThreadingSMTPServer( + (SMTP22.FIXTURE_HOST, SMTP22.FIXTURE_PORT), + SMTP22.MetadataSMTPHandler, + ) + server.capture = capture + server.advertise_proxy_extensions = False + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + cases = ( + ("untrusted", ["192.0.2.0/24"], [], False), + ("fallback", ["127.0.0.1"], [], False), + ("xforward", ["127.0.0.0/8"], ["127.0.0.1"], True), + ) + for case, headers, xforward, advertise in cases: + server.advertise_proxy_extensions = advertise + SMTP22.replace_configuration( + "smtp", + fallback_configuration( + original_smtp, + headers=headers, + xforward=xforward, + ), + ) + SMTP22.restart_bongo() + marker = submit(internal_host, internal_port, case) + delivery = SMTP22.wait_for_delivery(capture, marker) + if case == "untrusted": + validate_untrusted(delivery) + elif case == "fallback": + validate_fallback(delivery, internal_host) + else: + validate_xforward_suppression(delivery) + finally: + restoration_errors: list[str] = [] + if configuration_changed: + try: + SMTP22.replace_configuration("smtp", original_smtp) + except (OSError, SMTP22.SMTP07.SMTP07Error) as error: + restoration_errors.append( + f"SMTP configuration restore failed: {error}" + ) + if server is not None: + try: + server.shutdown() + server.server_close() + if thread is not None: + thread.join(timeout=5) + except OSError as error: + restoration_errors.append( + f"SMTP fixture shutdown failed: {error}" + ) + try: + SMTP22.restart_bongo() + SMTP22.cleanup_queue() + except (OSError, SMTP22.SMTP07.SMTP07Error) as error: + restoration_errors.append( + f"service/Queue restore failed: {error}" + ) + if restoration_errors: + raise SMTP23Error("; ".join(restoration_errors)) + + if SMTP22.read_configuration("smtp") != original_smtp: + raise SMTP23Error("SMTP configuration was not restored exactly") + print( + "SMTP-23 PASS fallback=trusted-only exact-ip/cidr " + "spoofed-headers=stripped generated=single/correct " + "xforward-addr=suppresses-fallback duplicate=no queue-clean=yes " + "config-restored=yes service-restored=active" + ) + + +def main() -> int: + try: + perform_test() + return 0 + except ( + json.JSONDecodeError, + OSError, + SMTP23Error, + SMTP22.SMTP07.SMTP07Error, + smtplib.SMTPException, + ValueError, + ) as error: + print(f"smtp-forward-header-check: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/contrib/testing/smtp-forward-metadata-check.py b/contrib/testing/smtp-forward-metadata-check.py index 8231112..2261798 100755 --- a/contrib/testing/smtp-forward-metadata-check.py +++ b/contrib/testing/smtp-forward-metadata-check.py @@ -164,8 +164,11 @@ class MetadataSMTPHandler(socketserver.BaseRequestHandler): if command == b"EHLO": greeted = True send(b"250-smtp22.fixture") - send(b"250-XCLIENT HELO PROTO ADDR") - send(b"250-XFORWARD NAME ADDR PORT IDENT SOURCE") + if getattr( + self.server, "advertise_proxy_extensions", True + ): + send(b"250-XCLIENT HELO PROTO ADDR") + send(b"250-XFORWARD NAME ADDR PORT IDENT SOURCE") send(b"250 SIZE 10485760") elif command == b"HELO": greeted = True