smtp: enforce per-connection command limit
Debian Trixie package bundle / packages (push) Successful in 22m15s

This commit is contained in:
Mario Fetka
2026-07-28 15:01:35 +02:00
parent c259153a12
commit 22ed4e14c2
5 changed files with 369 additions and 2 deletions
+347
View File
@@ -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:<smtp25@bongo.test>", 250)
connection.command(b"RCPT TO:<test1@bongo.test>", 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())
+17
View File
@@ -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;
}
+2 -2
View File
@@ -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)
@@ -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()
@@ -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)