Files
bongo/contrib/testing/smtp-forward-header-check.py
T
2026-07-28 14:07:37 +02:00

265 lines
8.8 KiB
Python
Executable File

#!/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 <smtp23@bongo.test>\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())