From 22ed4e14c2ddf17ca4cafb599645c42a038fa2c2 Mon Sep 17 00:00:00 2001 From: Mario Fetka Date: Tue, 28 Jul 2026 15:01:35 +0200 Subject: [PATCH] smtp: enforce per-connection command limit --- contrib/testing/smtp-command-safety-check.py | 347 +++++++++++++++++++ src/agents/smtp/smtpd.c | 17 + src/agents/smtp/smtpd.h | 4 +- src/apps/config/tests/test_configuration.py | 2 + src/libs/python/bongo/configuration/model.py | 1 + 5 files changed, 369 insertions(+), 2 deletions(-) create mode 100755 contrib/testing/smtp-command-safety-check.py diff --git a/contrib/testing/smtp-command-safety-check.py b/contrib/testing/smtp-command-safety-check.py new file mode 100755 index 0000000..90b75c1 --- /dev/null +++ b/contrib/testing/smtp-command-safety-check.py @@ -0,0 +1,347 @@ +#!/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 bounded SMTP commands, malformed input, timeout, and disconnect.""" + +from __future__ import annotations + +import copy +import importlib.util +import json +import os +from pathlib import Path +import socket +import sys +import time + + +TIMEOUT = float(os.environ.get("BONGO_TEST_TIMEOUT", "10")) +COMMAND_LIMIT = 5 +SOCKET_TIMEOUT = 2 +TOKEN = f"smtp25-{os.getpid()}-{time.time_ns()}" + + +class SMTP25Error(RuntimeError): + """Raised when the SMTP command safety contract is violated.""" + + +def load_helpers(): + path = Path(__file__).with_name("smtp-forward-metadata-check.py") + specification = importlib.util.spec_from_file_location( + "bongo_smtp25_helpers", path + ) + if specification is None or specification.loader is None: + raise SMTP25Error(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 + + +SMTP22 = load_helpers() + + +class SMTPConnection: + def __init__(self, host: str, port: int) -> None: + self.socket = socket.create_connection((host, port), timeout=TIMEOUT) + self.socket.settimeout(TIMEOUT) + self.reader = self.socket.makefile("rb") + + def close(self) -> None: + try: + self.reader.close() + finally: + self.socket.close() + + def send(self, data: bytes) -> None: + self.socket.sendall(data) + + def response(self, expected: int) -> list[bytes]: + lines: list[bytes] = [] + while True: + line = self.reader.readline(8194) + if not line: + raise SMTP25Error( + f"connection closed while waiting for SMTP {expected}" + ) + if len(line) > 8192 or len(line) < 5: + raise SMTP25Error(f"invalid SMTP response: {line!r}") + try: + code = int(line[:3]) + except ValueError as error: + raise SMTP25Error( + f"non-numeric SMTP response: {line!r}" + ) from error + if code != expected or line[3:4] not in {b"-", b" "}: + raise SMTP25Error( + f"expected SMTP {expected}, received {line!r}" + ) + lines.append(line) + if line[3:4] == b" ": + return lines + + def command(self, command: bytes, expected: int) -> list[bytes]: + self.send(command + b"\r\n") + return self.response(expected) + + def expect_close(self, *, timeout: float = TIMEOUT) -> bytes: + self.socket.settimeout(timeout) + try: + trailing = self.reader.readline(8194) + except ConnectionResetError: + return b"" + except socket.timeout as error: + raise SMTP25Error("SMTP connection remained open") from error + if trailing and not trailing.startswith(b"421 "): + raise SMTP25Error( + f"unexpected data before SMTP close: {trailing!r}" + ) + return trailing + + +def open_smtp(host: str, port: int) -> SMTPConnection: + connection = SMTPConnection(host, port) + connection.response(220) + return connection + + +def check_malformed(host: str, port: int) -> None: + cases = ( + (b"", 500), + (b" EHLO invalid", 500), + (b"EHLO", 501), + (b"DATA extra", 501), + (b"STARTTLS extra", 501), + (b"GET / HTTP/1.0", 500), + (b"NOOP\x00INJECT", 500), + ) + for command, expected in cases: + connection = open_smtp(host, port) + try: + connection.command(command, expected) + connection.command(b"NOOP", 250) + connection.command(b"QUIT", 221) + finally: + connection.close() + + connection = open_smtp(host, port) + try: + connection.send(b"BOGUS\r\nNOOP\r\n") + connection.response(500) + connection.response(250) + connection.command(b"QUIT", 221) + finally: + connection.close() + + +def check_line_limit(host: str, port: int) -> None: + connection = open_smtp(host, port) + try: + exact = b"NOOP " + b"x" * 505 + if len(exact) != 510: + raise SMTP25Error("SMTP-25 exact command fixture is not 510 bytes") + connection.command(exact, 250) + connection.command(b"QUIT", 221) + finally: + connection.close() + + connection = open_smtp(host, port) + try: + oversized = b"NOOP " + b"x" * 506 + if len(oversized) != 511: + raise SMTP25Error( + "SMTP-25 oversized command fixture is not 511 bytes" + ) + connection.command(oversized, 500) + connection.expect_close() + finally: + connection.close() + + # Postfix >= 3.9 defaults to normalizing bare LF for compatibility. + connection = open_smtp(host, port) + try: + connection.send(b"NOOP\n") + connection.response(250) + connection.command(b"QUIT", 221) + finally: + connection.close() + + +def check_flood_limit(host: str, port: int) -> None: + connection = open_smtp(host, port) + try: + connection.send(b"NOOP\r\n" * (COMMAND_LIMIT + 2)) + for _ in range(COMMAND_LIMIT): + connection.response(250) + response = connection.response(421) + if not any(b"Too many SMTP commands" in line for line in response): + raise SMTP25Error("SMTP command limit returned an unclear reply") + connection.expect_close() + finally: + connection.close() + + connection = open_smtp(host, port) + try: + connection.send(b"UNKNOWN\r\n" * (COMMAND_LIMIT + 1)) + for _ in range(COMMAND_LIMIT): + connection.response(500) + connection.response(421) + connection.expect_close() + finally: + connection.close() + + +def check_timeout(host: str, port: int) -> None: + for partial in (None, b"NO"): + connection = open_smtp(host, port) + try: + if partial is not None: + connection.send(partial) + started = time.monotonic() + connection.expect_close(timeout=SOCKET_TIMEOUT + 3) + elapsed = time.monotonic() - started + if elapsed < 1.0 or elapsed > SOCKET_TIMEOUT + 3: + raise SMTP25Error( + f"SMTP idle timeout elapsed after {elapsed:.2f}s" + ) + finally: + connection.close() + + +def disconnect_transaction( + host: str, + port: int, + *, + bdat: bool, +) -> None: + connection = open_smtp(host, port) + try: + connection.command(b"EHLO smtp25.bongo.test", 250) + connection.command(b"MAIL FROM:", 250) + connection.command(b"RCPT TO:", 250) + if bdat: + connection.send(b"BDAT 100 LAST\r\n" + TOKEN.encode("ascii")) + else: + connection.command(b"DATA", 354) + connection.send( + b"From: smtp25@bongo.test\r\n" + b"To: test1@bongo.test\r\n" + b"Subject: interrupted SMTP-25\r\n\r\n" + + TOKEN.encode("ascii") + ) + finally: + connection.close() + + +def matching_queue_entries() -> list[str]: + matching: list[str] = [] + for identifier in SMTP22.SMTP07.queue_ids(): + message = SMTP22.SMTP07.queue( + "message", identifier, check=False + ) + if message.returncode == 0 and TOKEN in message.stdout: + matching.append(identifier) + return matching + + +def cleanup_queue() -> None: + for identifier in matching_queue_entries(): + SMTP22.SMTP07.queue("delete", identifier, check=False) + + +def check_disconnect_cleanup(host: str, port: int) -> None: + disconnect_transaction(host, port, bdat=False) + disconnect_transaction(host, port, bdat=True) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if not matching_queue_entries(): + time.sleep(0.5) + if not matching_queue_entries(): + return + time.sleep(0.2) + raise SMTP25Error( + f"interrupted SMTP transaction remained queued: " + f"{matching_queue_entries()!r}" + ) + + +def test_configuration(original: dict) -> dict: + configuration = copy.deepcopy(original) + configuration.update( + { + "max_flood_count": COMMAND_LIMIT, + "socket_timeout": SOCKET_TIMEOUT, + "stress_socket_timeout": SOCKET_TIMEOUT, + } + ) + return configuration + + +def perform_test() -> None: + original = SMTP22.read_configuration("smtp") + original_global = SMTP22.read_configuration("global") + host = str(original.get("internal_relay_bind_address", "")).strip() + if not host or host in {"0.0.0.0", "::"}: + host = str(original_global.get("hostaddr", "")).strip() + if not host or host in {"0.0.0.0", "::"}: + host = "127.0.0.1" + port = int(original.get("internal_relay_port", 26)) + if not original.get("internal_relay_enabled"): + raise SMTP25Error("SMTP-25 requires enabled trusted SMTP") + if not SMTP22.SMTP07.service_active(): + raise SMTP25Error("bongo.service must be active before SMTP-25") + + SMTP22.SMTP07.INTERNAL_HOST = host + SMTP22.SMTP07.INTERNAL_PORT = port + changed = False + try: + SMTP22.replace_configuration("smtp", test_configuration(original)) + changed = True + SMTP22.restart_bongo() + check_malformed(host, port) + check_line_limit(host, port) + check_flood_limit(host, port) + check_timeout(host, port) + check_disconnect_cleanup(host, port) + finally: + cleanup_queue() + if changed: + SMTP22.replace_configuration("smtp", original) + SMTP22.restart_bongo() + + if SMTP22.read_configuration("smtp") != original: + raise SMTP25Error("SMTP configuration was not restored exactly") + if not SMTP22.SMTP07.service_active(): + raise SMTP25Error("bongo.service is not active after restoration") + if matching_queue_entries(): + raise SMTP25Error("SMTP-25 left marked Queue entries") + print( + "SMTP-25 PASS malformed=500/501/resynchronized " + "command-line=510-accepted/511-closed bare-lf=normalized " + f"flood={COMMAND_LIMIT}-allowed/next-421 " + f"timeout={SOCKET_TIMEOUT}s-idle/partial-closed " + "disconnect=data/bdat-aborted queue-clean=yes " + "config-restored=yes service-restored=active" + ) + + +def main() -> int: + try: + perform_test() + return 0 + except ( + json.JSONDecodeError, + OSError, + SMTP25Error, + SMTP22.SMTP22Error, + SMTP22.SMTP07.SMTP07Error, + ValueError, + ) as error: + print(f"smtp-command-safety-check: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/agents/smtp/smtpd.c b/src/agents/smtp/smtpd.c index d667873..a40e9fc 100644 --- a/src/agents/smtp/smtpd.c +++ b/src/agents/smtp/smtpd.c @@ -1040,6 +1040,8 @@ HandleConnection (void *param) BOOL AllowAuth = TRUE; BOOL NullSender = FALSE; BOOL TooManyNullSenderRecips = FALSE; + unsigned int CommandCount = 0U; + unsigned int MaxCommandCount; SMTPCommand ParsedCommand; const char *CommandArguments = NULL; char GreetingHost[256]; @@ -1085,6 +1087,7 @@ HandleConnection (void *param) if (!SMTP.allow_auth || Client->InternalRelay) { AllowAuth = FALSE; } + MaxCommandCount = (unsigned int) SMTP.max_flood_count; XplRWReadLockRelease (&ConfigLock); @@ -1149,6 +1152,15 @@ HandleConnection (void *param) ConnFlush(Client->client.conn); return EndClientConnection(Client); } + CommandCount++; + if (CommandCount > MaxCommandCount) { + Log(LOG_WARN, + "SMTP command limit exceeded by host %s after %u commands", + LOGIP(Client->client.conn->socketAddress), CommandCount); + ConnWrite(Client->client.conn, MSG421FLOOD, MSG421FLOOD_LEN); + ConnFlush(Client->client.conn); + return EndClientConnection(Client); + } ParsedCommand = SMTPParseCommandLine(Client->Command, (size_t)count, &CommandArguments); if (ParsedCommand == SMTP_COMMAND_UNKNOWN) { @@ -5752,6 +5764,11 @@ ValidateListenerConfiguration(void) Log(LOG_ERROR, "SMTP maximum thread load must be greater than zero"); return FALSE; } + if (SMTP.max_flood_count < 1 || SMTP.max_flood_count > 1000000) { + Log(LOG_ERROR, + "SMTP per-connection command limit must be between 1 and 1000000"); + return FALSE; + } return TRUE; } diff --git a/src/agents/smtp/smtpd.h b/src/agents/smtp/smtpd.h index 7b3216a..b2403c1 100755 --- a/src/agents/smtp/smtpd.h +++ b/src/agents/smtp/smtpd.h @@ -121,8 +121,8 @@ #define MSG550SPAMBLOCK "554 5.7.1 Site blocked by local policy\r\n" #define MSG550SPAMBLOCK_LEN (sizeof(MSG550SPAMBLOCK) - 1U) -#define MSG554SPAMBLOCK "554 5.7.1 Connection rejected due to flooding\r\n" -#define MSG554SPAMBLOCK_LEN (sizeof(MSG554SPAMBLOCK) - 1U) +#define MSG421FLOOD "421 4.7.0 Too many SMTP commands\r\n" +#define MSG421FLOOD_LEN (sizeof(MSG421FLOOD) - 1U) #define MSG551SPAMBLOCK "554 5.7.1 Client rejected by local policy\r\n" #define MSG551SPAMBLOCK_LEN (sizeof(MSG551SPAMBLOCK) - 1U) diff --git a/src/apps/config/tests/test_configuration.py b/src/apps/config/tests/test_configuration.py index ea7ad66..bba257e 100644 --- a/src/apps/config/tests/test_configuration.py +++ b/src/apps/config/tests/test_configuration.py @@ -209,6 +209,8 @@ class ConfigurationModelTest(unittest.TestCase): ("stress_load_percent", 0), ("stress_load_percent", 101), ("max_thread_load", 0), + ("max_flood_count", 0), + ("max_flood_count", 1000001), ): with self.subTest(key=key, value=value): configs = templates() diff --git a/src/libs/python/bongo/configuration/model.py b/src/libs/python/bongo/configuration/model.py index f9829f0..0d55624 100644 --- a/src/libs/python/bongo/configuration/model.py +++ b/src/libs/python/bongo/configuration/model.py @@ -635,6 +635,7 @@ def _validate_smtp(config: Mapping[str, Any], issues: list[Issue], check_files: "file does not exist", "warning") _positive(issues, "smtp", config, "max_thread_load", 100000) + _positive(issues, "smtp", config, "max_flood_count", 1000000) _positive(issues, "smtp", config, "socket_timeout", 3600) _positive(issues, "smtp", config, "stress_socket_timeout", 3600) _positive(issues, "smtp", config, "stress_load_percent", 100)