265 lines
8.8 KiB
Python
Executable File
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())
|