Verify authenticated SMTP submission
This commit is contained in:
@@ -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
@@ -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)
|
||||
@@ -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 | | | |
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user