tests: cover trusted SMTP proxy metadata
This commit is contained in:
+493
@@ -0,0 +1,493 @@
|
||||
#!/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 outbound XCLIENT and XFORWARD metadata negotiation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass, field
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import smtplib
|
||||
import socketserver
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
TIMEOUT = float(os.environ.get("BONGO_TEST_TIMEOUT", "45"))
|
||||
FIXTURE_HOST = os.environ.get("BONGO_TEST_SMTP22_HOST", "127.0.0.1")
|
||||
FIXTURE_PORT = int(os.environ.get("BONGO_TEST_SMTP22_PORT", "25"))
|
||||
TOKEN = f"smtp22-{os.getpid()}-{time.time_ns()}"
|
||||
|
||||
|
||||
class SMTP22Error(RuntimeError):
|
||||
"""Raised when outbound connection metadata violates its contract."""
|
||||
|
||||
|
||||
def load_helpers():
|
||||
path = Path(__file__).with_name(
|
||||
"smtp-outbound-opportunistic-tls-check.py"
|
||||
)
|
||||
specification = importlib.util.spec_from_file_location(
|
||||
"bongo_smtp22_helpers", path
|
||||
)
|
||||
if specification is None or specification.loader is None:
|
||||
raise SMTP22Error(f"cannot load SMTP fixture helpers from {path}")
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
sys.modules[specification.name] = module
|
||||
specification.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
SMTP07 = load_helpers()
|
||||
|
||||
|
||||
def privileged(arguments: list[str]) -> list[str]:
|
||||
if os.geteuid() == 0:
|
||||
return arguments
|
||||
return ["/usr/bin/sudo", "-n", *arguments]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Delivery:
|
||||
marker: str
|
||||
commands: list[str]
|
||||
message: bytes
|
||||
|
||||
|
||||
@dataclass
|
||||
class Capture:
|
||||
lock: threading.Lock = field(default_factory=threading.Lock)
|
||||
deliveries: list[Delivery] = field(default_factory=list)
|
||||
sessions: list[list[str]] = field(default_factory=list)
|
||||
|
||||
def started(self, commands: list[str]) -> None:
|
||||
with self.lock:
|
||||
self.sessions.append(commands)
|
||||
|
||||
def delivered(
|
||||
self, marker: str, commands: list[str], message: bytes
|
||||
) -> None:
|
||||
with self.lock:
|
||||
self.deliveries.append(
|
||||
Delivery(marker, list(commands), message)
|
||||
)
|
||||
|
||||
def matching(self, marker: str) -> list[Delivery]:
|
||||
with self.lock:
|
||||
return [
|
||||
delivery for delivery in self.deliveries
|
||||
if delivery.marker == marker
|
||||
]
|
||||
|
||||
def session_snapshot(self) -> list[list[str]]:
|
||||
with self.lock:
|
||||
return [list(commands) for commands in self.sessions]
|
||||
|
||||
|
||||
class MetadataSMTPHandler(socketserver.BaseRequestHandler):
|
||||
"""Minimal ESMTP peer implementing Postfix-style proxy extensions."""
|
||||
|
||||
def handle(self) -> None:
|
||||
capture: Capture = self.server.capture # type: ignore[attr-defined]
|
||||
connection = self.request
|
||||
connection.settimeout(15)
|
||||
buffer = bytearray()
|
||||
commands: list[str] = []
|
||||
sender = b""
|
||||
recipients: list[bytes] = []
|
||||
message = bytearray()
|
||||
data_mode = False
|
||||
greeted = False
|
||||
capture.started(commands)
|
||||
|
||||
def send(line: bytes) -> None:
|
||||
connection.sendall(line + b"\r\n")
|
||||
|
||||
def read_line() -> bytes | None:
|
||||
while b"\n" not in buffer:
|
||||
chunk = connection.recv(8192)
|
||||
if not chunk:
|
||||
return None
|
||||
buffer.extend(chunk)
|
||||
if len(buffer) > 4 * 1024 * 1024:
|
||||
raise SMTP22Error("SMTP-22 fixture input exceeded limit")
|
||||
end = buffer.index(b"\n") + 1
|
||||
line = bytes(buffer[:end])
|
||||
del buffer[:end]
|
||||
return line.rstrip(b"\r\n")
|
||||
|
||||
send(b"220 smtp22.fixture ESMTP")
|
||||
while True:
|
||||
try:
|
||||
line = read_line()
|
||||
except (OSError, SMTP22Error):
|
||||
return
|
||||
if line is None:
|
||||
return
|
||||
if data_mode:
|
||||
if line == b".":
|
||||
source = bytes(message)
|
||||
marker = next(
|
||||
(
|
||||
value.decode("ascii")
|
||||
for value in source.split()
|
||||
if value.startswith(TOKEN.encode("ascii") + b"-")
|
||||
),
|
||||
"",
|
||||
).rstrip(">\r\n")
|
||||
capture.delivered(marker, commands, source)
|
||||
send(b"250 2.0.0 queued")
|
||||
data_mode = False
|
||||
sender = b""
|
||||
recipients.clear()
|
||||
message.clear()
|
||||
else:
|
||||
if line.startswith(b".."):
|
||||
line = line[1:]
|
||||
message.extend(line + b"\r\n")
|
||||
continue
|
||||
|
||||
decoded = line.decode("ascii", "replace")
|
||||
commands.append(decoded)
|
||||
command, _, argument = line.partition(b" ")
|
||||
command = command.upper()
|
||||
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")
|
||||
send(b"250 SIZE 10485760")
|
||||
elif command == b"HELO":
|
||||
greeted = True
|
||||
send(b"250 smtp22.fixture")
|
||||
elif command == b"XCLIENT":
|
||||
if not greeted or not argument:
|
||||
send(b"503 5.5.1 EHLO and attributes required")
|
||||
else:
|
||||
greeted = False
|
||||
sender = b""
|
||||
recipients.clear()
|
||||
send(b"220 smtp22.fixture ESMTP after XCLIENT")
|
||||
elif command == b"XFORWARD":
|
||||
if not greeted or not argument:
|
||||
send(b"503 5.5.1 EHLO and attributes required")
|
||||
else:
|
||||
send(b"250 2.0.0 XFORWARD accepted")
|
||||
elif command == b"MAIL":
|
||||
if not greeted:
|
||||
send(b"503 5.5.1 send EHLO after XCLIENT")
|
||||
else:
|
||||
sender = argument
|
||||
send(b"250 2.1.0 sender accepted")
|
||||
elif command == b"RCPT":
|
||||
if not sender:
|
||||
send(b"503 5.5.1 MAIL required")
|
||||
else:
|
||||
recipients.append(argument)
|
||||
send(b"250 2.1.5 recipient accepted")
|
||||
elif command == b"DATA":
|
||||
if not sender or not recipients:
|
||||
send(b"503 5.5.1 envelope required")
|
||||
else:
|
||||
message.clear()
|
||||
data_mode = True
|
||||
send(b"354 end with <CRLF>.<CRLF>")
|
||||
elif command == b"RSET":
|
||||
sender = b""
|
||||
recipients.clear()
|
||||
message.clear()
|
||||
data_mode = False
|
||||
send(b"250 2.0.0 reset")
|
||||
elif command == b"NOOP":
|
||||
send(b"250 2.0.0 ok")
|
||||
elif command == b"QUIT":
|
||||
send(b"221 2.0.0 bye")
|
||||
return
|
||||
else:
|
||||
send(b"502 5.5.1 command not implemented")
|
||||
|
||||
|
||||
class ThreadingSMTPServer(
|
||||
socketserver.ThreadingMixIn, socketserver.TCPServer
|
||||
):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
|
||||
def read_configuration(name: str) -> dict:
|
||||
completed = SMTP07.run(
|
||||
privileged([SMTP07.ADMIN, "__config-read", name])
|
||||
)
|
||||
try:
|
||||
value = json.loads(completed.stdout)
|
||||
except json.JSONDecodeError as error:
|
||||
raise SMTP22Error(
|
||||
f"Bongo returned invalid {name} configuration"
|
||||
) from error
|
||||
if not isinstance(value, dict):
|
||||
raise SMTP22Error(f"Bongo {name} configuration is not an object")
|
||||
return value
|
||||
|
||||
|
||||
def replace_configuration(name: str, value: dict) -> None:
|
||||
SMTP07.run(
|
||||
privileged([SMTP07.ADMIN, "__config-replace", name]),
|
||||
input_text=json.dumps(value, separators=(",", ":")),
|
||||
)
|
||||
|
||||
|
||||
def restart_bongo() -> None:
|
||||
SMTP07.run(
|
||||
privileged([SMTP07.SYSTEMCTL, "restart", "bongo.service"])
|
||||
)
|
||||
SMTP07.wait_for_bongo()
|
||||
|
||||
|
||||
def direct_configuration(
|
||||
original: dict,
|
||||
*,
|
||||
xclient: list[str],
|
||||
xforward: list[str],
|
||||
) -> dict:
|
||||
configuration = copy.deepcopy(original)
|
||||
configuration.update(
|
||||
{
|
||||
"use_relay_host": False,
|
||||
"xclient_trusted_destinations": xclient,
|
||||
"xforward_trusted_destinations": xforward,
|
||||
"proxy_header_trusted_destinations": [],
|
||||
}
|
||||
)
|
||||
return configuration
|
||||
|
||||
|
||||
def submit(host: str, port: int, case: str) -> str:
|
||||
marker = f"{TOKEN}-{case}"
|
||||
message = (
|
||||
"From: SMTP-22 fixture <smtp22@bongo.test>\r\n"
|
||||
f"To: capture-{case}@example.net\r\n"
|
||||
f"Subject: {marker}\r\n"
|
||||
f"Message-ID: <{marker}@bongo.test>\r\n"
|
||||
"\r\n"
|
||||
f"Outbound metadata fixture {marker}\r\n"
|
||||
).encode("ascii")
|
||||
with smtplib.SMTP(host, port, timeout=10) as client:
|
||||
client.ehlo("smtp22-source.bongo.test")
|
||||
refused = client.sendmail(
|
||||
"smtp22@bongo.test",
|
||||
[f"capture-{case}@[{FIXTURE_HOST}]"],
|
||||
message,
|
||||
)
|
||||
if refused:
|
||||
raise SMTP22Error(
|
||||
f"Bongo refused SMTP-22 ingress recipient: {refused}")
|
||||
return marker
|
||||
|
||||
|
||||
def wait_for_delivery(capture: Capture, marker: str) -> Delivery:
|
||||
deadline = time.monotonic() + TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
matching = capture.matching(marker)
|
||||
if len(matching) == 1:
|
||||
return matching[0]
|
||||
if len(matching) > 1:
|
||||
raise SMTP22Error(f"duplicate delivery for {marker}")
|
||||
time.sleep(0.2)
|
||||
raise SMTP22Error(
|
||||
f"metadata fixture did not receive {marker}; "
|
||||
f"sessions={capture.session_snapshot()!r}, "
|
||||
f"deliveries={[item.marker for item in capture.deliveries]!r}"
|
||||
)
|
||||
|
||||
|
||||
def command_of(delivery: Delivery, name: str) -> list[str]:
|
||||
prefix = f"{name} "
|
||||
return [
|
||||
command for command in delivery.commands
|
||||
if command.upper().startswith(prefix)
|
||||
]
|
||||
|
||||
|
||||
def validate_untrusted(delivery: Delivery) -> None:
|
||||
if command_of(delivery, "XCLIENT") or command_of(delivery, "XFORWARD"):
|
||||
raise SMTP22Error(
|
||||
"Bongo disclosed proxy metadata to an untrusted destination")
|
||||
|
||||
|
||||
def validate_xclient(delivery: Delivery, *, also_xforward: bool) -> None:
|
||||
commands = command_of(delivery, "XCLIENT")
|
||||
if len(commands) != 1:
|
||||
raise SMTP22Error(
|
||||
f"expected one XCLIENT command, received {commands!r}")
|
||||
command = commands[0]
|
||||
for attribute in ("HELO=smtp22-source.bongo.test", "PROTO=ESMTP",
|
||||
"ADDR="):
|
||||
if attribute not in command:
|
||||
raise SMTP22Error(
|
||||
f"XCLIENT omitted advertised attribute {attribute}")
|
||||
for attribute in ("NAME=", "PORT=", "LOGIN=", "DESTADDR=", "DESTPORT="):
|
||||
if attribute in command:
|
||||
raise SMTP22Error(
|
||||
f"XCLIENT sent unadvertised attribute {attribute}")
|
||||
xclient_index = delivery.commands.index(command)
|
||||
mail_index = next(
|
||||
index for index, value in enumerate(delivery.commands)
|
||||
if value.upper().startswith("MAIL ")
|
||||
)
|
||||
ehlo_after = [
|
||||
index for index, value in enumerate(delivery.commands)
|
||||
if value.upper().startswith("EHLO ") and
|
||||
xclient_index < index < mail_index
|
||||
]
|
||||
if len(ehlo_after) != 1:
|
||||
raise SMTP22Error("XCLIENT was not followed by exactly one fresh EHLO")
|
||||
if also_xforward:
|
||||
forward = command_of(delivery, "XFORWARD")
|
||||
if len(forward) != 1:
|
||||
raise SMTP22Error("XFORWARD was not sent after XCLIENT/EHLO")
|
||||
if not (ehlo_after[0] < delivery.commands.index(forward[0]) <
|
||||
mail_index):
|
||||
raise SMTP22Error("XFORWARD was sent in the wrong SMTP state")
|
||||
elif command_of(delivery, "XFORWARD"):
|
||||
raise SMTP22Error("unexpected XFORWARD command")
|
||||
|
||||
|
||||
def validate_xforward(delivery: Delivery) -> None:
|
||||
if command_of(delivery, "XCLIENT"):
|
||||
raise SMTP22Error("unexpected XCLIENT command")
|
||||
commands = command_of(delivery, "XFORWARD")
|
||||
if len(commands) != 1:
|
||||
raise SMTP22Error(
|
||||
f"expected one XFORWARD command, received {commands!r}")
|
||||
command = commands[0]
|
||||
for attribute in ("ADDR=", "PORT=", "IDENT=", "SOURCE=LOCAL"):
|
||||
if attribute not in command:
|
||||
raise SMTP22Error(
|
||||
f"XFORWARD omitted advertised attribute {attribute}")
|
||||
for attribute in ("PROTO=", "HELO=", "LOGIN=", "DESTADDR=", "DESTPORT="):
|
||||
if attribute in command:
|
||||
raise SMTP22Error(
|
||||
f"XFORWARD sent unadvertised attribute {attribute}")
|
||||
if delivery.commands.index(command) > next(
|
||||
index for index, value in enumerate(delivery.commands)
|
||||
if value.upper().startswith("MAIL ")
|
||||
):
|
||||
raise SMTP22Error("XFORWARD was sent after MAIL")
|
||||
|
||||
|
||||
def cleanup_queue() -> None:
|
||||
for identifier in SMTP07.queue_ids():
|
||||
message = SMTP07.queue("message", identifier, check=False)
|
||||
if message.returncode == 0 and TOKEN in message.stdout:
|
||||
SMTP07.queue("delete", identifier)
|
||||
|
||||
|
||||
def perform_test() -> None:
|
||||
original_smtp = read_configuration("smtp")
|
||||
original_global = 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))
|
||||
SMTP07.INTERNAL_HOST = internal_host
|
||||
SMTP07.INTERNAL_PORT = internal_port
|
||||
|
||||
capture = Capture()
|
||||
server = ThreadingSMTPServer(
|
||||
(FIXTURE_HOST, FIXTURE_PORT), MetadataSMTPHandler
|
||||
)
|
||||
server.capture = capture # type: ignore[attr-defined]
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
configuration_changed = False
|
||||
try:
|
||||
cases = (
|
||||
("untrusted", ["192.0.2.0/24"], ["198.51.100.0/24"]),
|
||||
("xclient", ["127.0.0.1"], []),
|
||||
("xforward", [], ["127.0.0.0/8"]),
|
||||
("both", ["127.0.0.0/8"], ["127.0.0.1"]),
|
||||
)
|
||||
for case, xclient, xforward in cases:
|
||||
replace_configuration(
|
||||
"smtp",
|
||||
direct_configuration(
|
||||
original_smtp, xclient=xclient, xforward=xforward
|
||||
),
|
||||
)
|
||||
configuration_changed = True
|
||||
restart_bongo()
|
||||
marker = submit(internal_host, internal_port, case)
|
||||
delivery = wait_for_delivery(capture, marker)
|
||||
if case == "untrusted":
|
||||
validate_untrusted(delivery)
|
||||
elif case == "xclient":
|
||||
validate_xclient(delivery, also_xforward=False)
|
||||
elif case == "xforward":
|
||||
validate_xforward(delivery)
|
||||
else:
|
||||
validate_xclient(delivery, also_xforward=True)
|
||||
finally:
|
||||
restoration_errors: list[str] = []
|
||||
if configuration_changed:
|
||||
try:
|
||||
replace_configuration("smtp", original_smtp)
|
||||
except (OSError, SMTP07.SMTP07Error) as error:
|
||||
restoration_errors.append(
|
||||
f"SMTP configuration restore failed: {error}")
|
||||
try:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
except OSError as error:
|
||||
restoration_errors.append(
|
||||
f"SMTP fixture shutdown failed: {error}")
|
||||
try:
|
||||
restart_bongo()
|
||||
cleanup_queue()
|
||||
except (OSError, SMTP07.SMTP07Error) as error:
|
||||
restoration_errors.append(
|
||||
f"service/Queue restore failed: {error}")
|
||||
if restoration_errors:
|
||||
raise SMTP22Error("; ".join(restoration_errors))
|
||||
|
||||
if read_configuration("smtp") != original_smtp:
|
||||
raise SMTP22Error("SMTP configuration was not restored exactly")
|
||||
print(
|
||||
"SMTP-22 PASS metadata=xclient/xforward "
|
||||
"trust=untrusted-blocked/ip/cidr attributes=advertised-only "
|
||||
"xclient=220/fresh-ehlo xforward=250/in-transaction "
|
||||
"combined=ordered duplicate=no queue-clean=yes "
|
||||
"config-restored=yes service-restored=active"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
perform_test()
|
||||
return 0
|
||||
except (
|
||||
json.JSONDecodeError,
|
||||
OSError,
|
||||
SMTP22Error,
|
||||
SMTP07.SMTP07Error,
|
||||
smtplib.SMTPException,
|
||||
StopIteration,
|
||||
ValueError,
|
||||
) as error:
|
||||
print(f"smtp-forward-metadata-check: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user