tests: cover trusted SMTP fallback headers
This commit is contained in:
Executable
+264
@@ -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 <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())
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user