smtp: enforce per-connection command limit
Debian Trixie package bundle / packages (push) Successful in 22m15s
Debian Trixie package bundle / packages (push) Successful in 22m15s
This commit is contained in:
Executable
+347
@@ -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())
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user