Verify authenticated SMTP submission

This commit is contained in:
Mario Fetka
2026-07-24 06:35:31 +02:00
parent 53bc369148
commit 4e08eb5519
4 changed files with 309 additions and 2 deletions
+4
View File
@@ -18,6 +18,10 @@ one-off files in `/tmp`:
the message through IMAP/POP over explicit and implicit TLS.
- `smtp-basic-check.py` exercises the port-25 greeting and the non-delivery
HELO/EHLO, NOOP, RSET, HELP, VRFY-policy, and QUIT command surface.
- `smtp-submission-auth-check.py` exercises port 587 STARTTLS and port 465
implicit TLS directly, including the shared GSASL PLAIN and LOGIN
mechanisms, pre-TLS AUTH rejection, invalid credentials, and repeated
STARTTLS rejection. It authenticates but does not submit a message.
- `sanitizer-protocol-session.sh` runs that protocol path under ASan/UBSan/LSan
or TSan and validates clean process shutdown.
- `asan-protocol-session.sh` and `tsan-protocol-session.sh` preserve the two
+268
View File
@@ -0,0 +1,268 @@
#!/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 authenticated SMTP submission over STARTTLS and implicit TLS."""
from __future__ import annotations
import base64
import os
import re
import socket
import ssl
HOST = os.environ.get("BONGO_TEST_HOST", "127.0.0.1")
SUBMISSION_PORT = int(os.environ.get("BONGO_TEST_SUBMISSION_PORT", "587"))
SMTPS_PORT = int(os.environ.get("BONGO_TEST_SMTPS_PORT", "465"))
TIMEOUT = float(os.environ.get("BONGO_TEST_TIMEOUT", "10"))
HELO_NAME = os.environ.get("BONGO_TEST_HELO", "client.bongo.test")
USERNAME = os.environ.get("BONGO_TEST_USER", "test1")
PASSWORD = os.environ.get("BONGO_TEST_PASSWORD", "")
WRONG_PASSWORD = os.environ.get(
"BONGO_TEST_WRONG_PASSWORD", "definitely-not-the-test-password"
)
RESPONSE = re.compile(rb"^([0-9]{3})([- ])(.*)\r\n$")
class SMTPCheckError(RuntimeError):
"""Raised when the live SMTP peer violates the tested contract."""
class SMTPConnection:
def __init__(self, port: int, implicit_tls: bool = False) -> None:
raw_socket = socket.create_connection((HOST, port), timeout=TIMEOUT)
raw_socket.settimeout(TIMEOUT)
self.socket: socket.socket | ssl.SSLSocket = raw_socket
self.reader = raw_socket.makefile("rb")
self.tls_version = ""
if implicit_tls:
self.start_tls()
def close(self) -> None:
try:
self.reader.close()
finally:
self.socket.close()
def start_tls(self) -> None:
self.reader.close()
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
context.minimum_version = ssl.TLSVersion.TLSv1_2
self.socket = context.wrap_socket(
self.socket, server_hostname=HOST
)
self.socket.settimeout(TIMEOUT)
self.reader = self.socket.makefile("rb")
self.tls_version = self.socket.version() or ""
if self.tls_version not in {"TLSv1.2", "TLSv1.3"}:
raise SMTPCheckError(
f"negotiated unexpected TLS version {self.tls_version!r}"
)
def response(self, *expected: int) -> tuple[int, list[bytes]]:
lines: list[bytes] = []
response_code = -1
while True:
line = self.reader.readline(8194)
if not line:
raise SMTPCheckError(
f"connection closed while waiting for SMTP {expected}"
)
if len(line) > 8192:
raise SMTPCheckError("SMTP response line exceeded 8192 bytes")
match = RESPONSE.match(line)
if match is None:
raise SMTPCheckError(f"malformed SMTP response: {line!r}")
code = int(match.group(1))
if response_code == -1:
response_code = code
elif code != response_code:
raise SMTPCheckError(
f"multiline SMTP response changed code: {line!r}"
)
if code not in expected:
raise SMTPCheckError(
f"expected SMTP {expected}, received {line!r}"
)
lines.append(match.group(3))
if match.group(2) == b" ":
return code, lines
def command(self, command: str, *expected: int) -> tuple[int, list[bytes]]:
self.socket.sendall(command.encode("ascii") + b"\r\n")
return self.response(*expected)
def capabilities(lines: list[bytes]) -> dict[str, list[str]]:
result: dict[str, list[str]] = {}
for line in lines[1:]:
fields = line.decode("ascii", "strict").upper().split()
if not fields:
continue
keyword = fields[0]
if keyword.startswith("AUTH="):
result.setdefault("AUTH", []).append(keyword[5:])
result["AUTH"].extend(fields[1:])
else:
result.setdefault(keyword, []).extend(fields[1:])
return result
def encode(value: bytes | str) -> str:
if isinstance(value, str):
value = value.encode("utf-8")
return base64.b64encode(value).decode("ascii")
def decode_challenge(lines: list[bytes]) -> bytes:
if len(lines) != 1:
raise SMTPCheckError(f"unexpected multiline AUTH challenge {lines!r}")
try:
return base64.b64decode(lines[0], validate=True)
except ValueError as error:
raise SMTPCheckError(
f"invalid base64 AUTH challenge {lines[0]!r}"
) from error
def connect(port: int, implicit_tls: bool = False) -> SMTPConnection:
client = SMTPConnection(port, implicit_tls)
client.response(220)
return client
def assert_auth_capabilities(
client: SMTPConnection, expect_starttls: bool
) -> None:
_, lines = client.command(f"EHLO {HELO_NAME}", 250)
advertised = capabilities(lines)
mechanisms = set(advertised.get("AUTH", []))
if not {"PLAIN", "LOGIN"}.issubset(mechanisms):
raise SMTPCheckError(
f"AUTH PLAIN LOGIN not advertised: {sorted(mechanisms)}"
)
if ("STARTTLS" in advertised) != expect_starttls:
raise SMTPCheckError(
"STARTTLS advertisement did not match the TLS state"
)
def auth_plain(port: int, implicit_tls: bool, password: str, expected: int) -> str:
client = connect(port, implicit_tls)
try:
if not implicit_tls:
client.command(f"EHLO {HELO_NAME}", 250)
client.command("STARTTLS", 220)
client.start_tls()
assert_auth_capabilities(client, False)
payload = encode(b"\0" + USERNAME.encode() + b"\0" + password.encode())
client.command(f"AUTH PLAIN {payload}", expected)
client.command("QUIT", 221)
return client.tls_version
finally:
client.close()
def auth_login(port: int, implicit_tls: bool) -> str:
client = connect(port, implicit_tls)
try:
if not implicit_tls:
client.command(f"EHLO {HELO_NAME}", 250)
client.command("STARTTLS", 220)
client.start_tls()
assert_auth_capabilities(client, False)
_, username_challenge = client.command("AUTH LOGIN", 334)
if decode_challenge(username_challenge) not in {
b"User Name",
b"Username:",
}:
raise SMTPCheckError(
f"unexpected LOGIN username challenge {username_challenge!r}"
)
_, password_challenge = client.command(encode(USERNAME), 334)
if decode_challenge(password_challenge) not in {
b"Password",
b"Password:",
}:
raise SMTPCheckError(
f"unexpected LOGIN password challenge {password_challenge!r}"
)
client.command(encode(PASSWORD), 235)
client.command("QUIT", 221)
return client.tls_version
finally:
client.close()
def check_submission() -> str:
client = connect(SUBMISSION_PORT)
try:
_, pre_tls_lines = client.command(f"EHLO {HELO_NAME}", 250)
pre_tls = capabilities(pre_tls_lines)
if "STARTTLS" not in pre_tls:
raise SMTPCheckError("submission did not advertise STARTTLS")
if "AUTH" in pre_tls:
raise SMTPCheckError("submission advertised AUTH before TLS")
client.command("AUTH PLAIN =", 538)
client.command("STARTTLS", 220)
client.start_tls()
assert_auth_capabilities(client, False)
client.command("STARTTLS", 554)
client.command("QUIT", 221)
tls_version = client.tls_version
finally:
client.close()
if auth_plain(SUBMISSION_PORT, False, PASSWORD, 235) != tls_version:
raise SMTPCheckError("submission TLS version changed between sessions")
auth_plain(SUBMISSION_PORT, False, WRONG_PASSWORD, 535)
auth_login(SUBMISSION_PORT, False)
return tls_version
def check_smtps() -> str:
client = connect(SMTPS_PORT, True)
try:
assert_auth_capabilities(client, False)
client.command("QUIT", 221)
tls_version = client.tls_version
finally:
client.close()
if auth_plain(SMTPS_PORT, True, PASSWORD, 235) != tls_version:
raise SMTPCheckError("SMTPS TLS version changed between sessions")
auth_plain(SMTPS_PORT, True, WRONG_PASSWORD, 535)
auth_login(SMTPS_PORT, True)
return tls_version
def main() -> int:
if not PASSWORD:
raise SMTPCheckError("BONGO_TEST_PASSWORD must be set")
if WRONG_PASSWORD == PASSWORD:
raise SMTPCheckError(
"BONGO_TEST_WRONG_PASSWORD must differ from BONGO_TEST_PASSWORD"
)
submission_tls = check_submission()
smtps_tls = check_smtps()
print(
"SMTP-02/03 PASS "
f"host={HOST} submission={SUBMISSION_PORT}/{submission_tls} "
f"smtps={SMTPS_PORT}/{smtps_tls} "
"pre-tls-auth=538 auth=PLAIN,LOGIN invalid=535 "
"repeated-starttls=554"
)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, UnicodeError, ValueError, SMTPCheckError) as error:
print(f"SMTP-02/03 FAIL: {error}")
raise SystemExit(1)
+2 -2
View File
@@ -124,8 +124,8 @@ inputs and outputs is absent from the ZIP.
| ID | Test | R1 | R2 | R3 |
| --- | --- | --- | --- | --- |
| SMTP-01 | Port 25 greeting, HELO, EHLO, NOOP, RSET, HELP, VRFY policy, QUIT | [PASS](test-evidence/0.7-r1.md#smtp-01) | | |
| SMTP-02 | Port 587 STARTTLS submission with shared GSASL authentication | | | |
| SMTP-03 | Port 465 implicit TLS submission | | | |
| SMTP-02 | Port 587 STARTTLS submission with shared GSASL authentication | [PASS](test-evidence/0.7-r1.md#smtp-0203) | | |
| SMTP-03 | Port 465 implicit TLS submission | [PASS](test-evidence/0.7-r1.md#smtp-0203) | | |
| SMTP-04 | Trusted port 26 accepts only configured networks and applies limits | | | |
| SMTP-05 | Internal sender/domain normalisation uses peer DNS/IP mapping safely | | | |
| SMTP-06 | Local authenticated delivery from test1 to test2 | | | |
+35
View File
@@ -2023,3 +2023,38 @@ SMTP-01 PASS host=127.0.0.1 port=25 greeting=yes helo-lines=1 ehlo-lines=11 noop
The focused native `smtp-capabilities` and `smtp-protocol` tests also passed.
The live test sends no envelope or message data and therefore leaves Store,
Queue and external systems unchanged.
## SMTP-02/03
Result: **PASS**
The reusable `smtp-submission-auth-check.py` exercises both authenticated
submission listeners without relying on a high-level SMTP client's mechanism
selection:
```sh
export BONGO_TEST_PASSWORD='<disposable-test-password>'
./contrib/testing/smtp-submission-auth-check.py
```
On port 587 the first EHLO advertised STARTTLS but did not advertise AUTH.
An explicit AUTH attempt containing no real credential was rejected with
`538`. After STARTTLS and the mandatory second EHLO, the server advertised
exactly the supported GSASL password mechanisms, including PLAIN and LOGIN.
Separate TLS sessions completed both mechanisms with the disposable `test1`
credential and rejected a deliberately wrong password with `535`. A repeated
STARTTLS request in an already encrypted session was rejected with `554`.
Port 465 performed TLS before the SMTP greeting, did not advertise STARTTLS,
and passed the same PLAIN, LOGIN, and invalid-password cases. Both listeners
negotiated TLS 1.3 while the test's client-side minimum was TLS 1.2. The
installed-service run reported:
```text
SMTP-02/03 PASS host=127.0.0.1 submission=587/TLSv1.3 smtps=465/TLSv1.3 pre-tls-auth=538 auth=PLAIN,LOGIN invalid=535 repeated-starttls=554
```
The focused native `sasl-password-mechanisms`, `smtp-capabilities`, and
`smtp-protocol` tests also passed. The live check authenticates only; it sends
no envelope or message data and therefore leaves Store, Queue, and external
systems unchanged.