From b71db6514972744f9f1a1dacd2d91cb1099f726d Mon Sep 17 00:00:00 2001 From: anti Date: Mon, 13 Apr 2026 23:46:50 -0400 Subject: [PATCH] fix: SMTP server handles bare LF line endings and AUTH PLAIN continuation Two bugs fixed: - data_received only split on CRLF, so clients sending bare LF (telnet, nc, some libraries) got no responses at all. Now splits on LF and strips trailing CR, matching real Postfix behavior. - AUTH PLAIN without inline credentials set state to "await_plain" but no handler existed for that state, causing the next line to be dispatched as a normal command. Added the missing state handler. --- templates/smtp/server.py | 14 ++++-- tests/service_testing/test_smtp.py | 75 ++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/templates/smtp/server.py b/templates/smtp/server.py index b5b2232..3f524fe 100644 --- a/templates/smtp/server.py +++ b/templates/smtp/server.py @@ -87,9 +87,10 @@ class SMTPProtocol(asyncio.Protocol): def data_received(self, data): self._buf += data - while b"\r\n" in self._buf: - line, self._buf = self._buf.split(b"\r\n", 1) - self._handle_line(line.decode(errors="replace")) + while b"\n" in self._buf: + line, self._buf = self._buf.split(b"\n", 1) + # Strip trailing \r so both CRLF and bare LF work + self._handle_line(line.rstrip(b"\r").decode(errors="replace")) def connection_lost(self, exc): _log("disconnect", src=self._peer[0] if self._peer else "?") @@ -118,7 +119,12 @@ class SMTPProtocol(asyncio.Protocol): self._data_buf.append(line[1:] if line.startswith(".") else line) return - # ── AUTH multi-step (LOGIN mechanism) ───────────────────────────────── + # ── AUTH multi-step (LOGIN / PLAIN continuation) ───────────────────── + if self._auth_state == "await_plain": + user, password = _decode_auth_plain(line) + self._finish_auth(user, password) + self._auth_state = "" + return if self._auth_state == "await_user": self._auth_user = base64.b64decode(line + "==").decode(errors="replace") self._auth_state = "await_pass" diff --git a/tests/service_testing/test_smtp.py b/tests/service_testing/test_smtp.py index 8a6e93a..b4005e3 100644 --- a/tests/service_testing/test_smtp.py +++ b/tests/service_testing/test_smtp.py @@ -301,3 +301,78 @@ def test_decode_auth_plain_garbage_no_raise(relay_mod): user, pw = relay_mod._decode_auth_plain("!!!notbase64!!!") assert isinstance(user, str) assert isinstance(pw, str) + + +# ── Bare LF line endings ──────────────────────────────────────────────────── + +def _send_bare_lf(proto, *lines: str) -> None: + """Feed LF-only terminated lines to the protocol (simulates telnet/nc).""" + for line in lines: + proto.data_received((line + "\n").encode()) + + +def test_ehlo_works_with_bare_lf(relay_mod): + """Clients sending bare LF (telnet, nc) must get EHLO responses.""" + proto, _, written = _make_protocol(relay_mod) + _send_bare_lf(proto, "EHLO attacker.com") + combined = b"".join(written).decode() + assert "250" in combined + assert "AUTH" in combined + + +def test_full_session_with_bare_lf(relay_mod): + """A complete relay session using bare LF line endings.""" + proto, _, written = _make_protocol(relay_mod) + _send_bare_lf( + proto, + "EHLO attacker.com", + "MAIL FROM:", + "RCPT TO:", + "DATA", + "Subject: test", + "", + "body", + ".", + "QUIT", + ) + replies = _replies(written) + assert any("queued as" in r for r in replies) + assert any(r.startswith("221") for r in replies) + + +def test_mixed_line_endings(relay_mod): + """A single data_received call containing a mix of CRLF and bare LF.""" + proto, _, written = _make_protocol(relay_mod) + proto.data_received(b"EHLO test.com\r\nMAIL FROM:\nRCPT TO:\r\n") + replies = _replies(written) + assert any("250" in r for r in replies) + assert any(r.startswith("250 2.1.0") for r in replies) + assert any(r.startswith("250 2.1.5") for r in replies) + + +# ── AUTH PLAIN continuation (no inline credentials) ────────────────────────── + +def test_auth_plain_continuation_relay(relay_mod): + """AUTH PLAIN without inline creds should prompt then accept on next line.""" + proto, _, written = _make_protocol(relay_mod) + _send(proto, "AUTH PLAIN") + replies = _replies(written) + assert any(r.startswith("334") for r in replies), "Expected 334 continuation" + written.clear() + creds = base64.b64encode(b"\x00admin\x00password").decode() + _send(proto, creds) + replies = _replies(written) + assert any(r.startswith("235") for r in replies), "Expected 235 auth success" + + +def test_auth_plain_continuation_harvester(harvester_mod): + """AUTH PLAIN continuation in harvester mode should reject with 535.""" + proto, _, written = _make_protocol(harvester_mod) + _send(proto, "AUTH PLAIN") + replies = _replies(written) + assert any(r.startswith("334") for r in replies) + written.clear() + creds = base64.b64encode(b"\x00admin\x00password").decode() + _send(proto, creds) + replies = _replies(written) + assert any(r.startswith("535") for r in replies)