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.
This commit is contained in:
2026-04-13 23:46:50 -04:00
parent fd62413935
commit b71db65149
2 changed files with 85 additions and 4 deletions

View File

@@ -87,9 +87,10 @@ class SMTPProtocol(asyncio.Protocol):
def data_received(self, data): def data_received(self, data):
self._buf += data self._buf += data
while b"\r\n" in self._buf: while b"\n" in self._buf:
line, self._buf = self._buf.split(b"\r\n", 1) line, self._buf = self._buf.split(b"\n", 1)
self._handle_line(line.decode(errors="replace")) # 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): def connection_lost(self, exc):
_log("disconnect", src=self._peer[0] if self._peer else "?") _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) self._data_buf.append(line[1:] if line.startswith(".") else line)
return 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": if self._auth_state == "await_user":
self._auth_user = base64.b64decode(line + "==").decode(errors="replace") self._auth_user = base64.b64decode(line + "==").decode(errors="replace")
self._auth_state = "await_pass" self._auth_state = "await_pass"

View File

@@ -301,3 +301,78 @@ def test_decode_auth_plain_garbage_no_raise(relay_mod):
user, pw = relay_mod._decode_auth_plain("!!!notbase64!!!") user, pw = relay_mod._decode_auth_plain("!!!notbase64!!!")
assert isinstance(user, str) assert isinstance(user, str)
assert isinstance(pw, 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:<hacker@evil.com>",
"RCPT TO:<admin@target.com>",
"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:<a@b.com>\nRCPT TO:<c@d.com>\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)