Files
bongo/contrib/testing/imap-auth-capability-check.py
T
Mario Fetka b5472bc6ec
Debian Trixie package bundle / packages (push) Successful in 22m26s
Align IMAP command parsing with current servers
2026-07-29 08:48:04 +02:00

378 lines
14 KiB
Python
Executable File

#!/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 Bongo IMAP capabilities, TLS transitions, and authentication."""
from __future__ import annotations
import base64
import os
import re
import select
import socket
import ssl
HOST = os.environ.get("BONGO_TEST_HOST", "127.0.0.1")
IMAP_PORT = int(os.environ.get("BONGO_TEST_IMAP_PORT", "143"))
IMAPS_PORT = int(os.environ.get("BONGO_TEST_IMAPS_PORT", "993"))
TIMEOUT = float(os.environ.get("BONGO_TEST_TIMEOUT", "10"))
USERNAME = os.environ.get("BONGO_TEST_USER", "test1")
PASSWORD = os.environ.get("BONGO_TEST_PASSWORD", "")
TAGGED = re.compile(rb"^([A-Za-z0-9]+) (OK|NO|BAD)(?: .*)?\r\n$")
BASE_CAPABILITIES = {
"IMAP4REV2", "IMAP4REV1", "IMAP4", "NAMESPACE", "ID", "IDLE",
"ENABLE", "UTF8=ACCEPT", "UIDPLUS", "MOVE", "SPECIAL-USE",
"CREATE-SPECIAL-USE", "LIST-EXTENDED", "LIST-STATUS", "STATUS=SIZE",
"QUOTA", "QUOTA=RES-STORAGE", "LITERAL+", "APPENDLIMIT=999999",
"XSENDER",
}
class IMAPCheckError(RuntimeError):
"""Raised when the live IMAP peer violates the tested contract."""
class IMAPConnection:
def __init__(self, port: int, implicit_tls: bool = False) -> None:
raw = socket.create_connection((HOST, port), timeout=TIMEOUT)
raw.settimeout(TIMEOUT)
self.socket: socket.socket | ssl.SSLSocket = raw
self.reader = raw.makefile("rb")
self.counter = 0
self.tls_version = ""
if implicit_tls:
self.start_tls()
greeting = self.line()
if not greeting.startswith(b"* OK "):
raise IMAPCheckError(f"unexpected IMAP greeting: {greeting!r}")
def close(self) -> None:
try:
self.reader.close()
finally:
self.socket.close()
def line(self) -> bytes:
line = self.reader.readline(65538)
if not line:
raise IMAPCheckError("connection closed while waiting for IMAP response")
if len(line) > 65536:
raise IMAPCheckError("IMAP response line exceeded 65536 bytes")
if not line.endswith(b"\r\n"):
raise IMAPCheckError(f"unterminated IMAP response: {line!r}")
return line
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 IMAPCheckError(
f"negotiated unexpected TLS version {self.tls_version!r}"
)
def command(
self, command: str, expected: tuple[str, ...] = ("OK",)
) -> list[bytes]:
self.counter += 1
tag = f"T{self.counter:04d}"
self.socket.sendall(f"{tag} {command}\r\n".encode("utf-8"))
lines: list[bytes] = []
while True:
line = self.line()
match = TAGGED.match(line)
if match is not None and match.group(1).decode("ascii") == tag:
status = match.group(2).decode("ascii")
if status not in expected:
raise IMAPCheckError(
f"{command!r} expected {expected}, received {line!r}"
)
return lines + [line]
lines.append(line)
def quote(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def capabilities(lines: list[bytes]) -> set[str]:
for line in lines:
if line.upper().startswith(b"* CAPABILITY "):
return {
item.upper()
for item in line.decode("ascii", "strict").strip().split()[2:]
}
raise IMAPCheckError(f"CAPABILITY response missing in {lines!r}")
def assert_capabilities(
advertised: set[str], *, cleartext: bool, authenticated: bool = False
) -> None:
missing = BASE_CAPABILITIES - advertised
if missing:
raise IMAPCheckError(f"missing IMAP capabilities: {sorted(missing)}")
if cleartext:
if "STARTTLS" not in advertised or "LOGINDISABLED" not in advertised:
raise IMAPCheckError(
"cleartext IMAP must advertise STARTTLS and LOGINDISABLED"
)
if any(item.startswith("AUTH=") for item in advertised):
raise IMAPCheckError("cleartext IMAP advertised authentication")
else:
if "STARTTLS" in advertised or "LOGINDISABLED" in advertised:
raise IMAPCheckError("TLS IMAP advertised a cleartext-only capability")
if not authenticated:
required = {"AUTH=PLAIN", "AUTH=LOGIN", "SASL-IR"}
if not required.issubset(advertised):
raise IMAPCheckError(
"missing TLS authentication capabilities: "
f"{sorted(required - advertised)}"
)
def check_starttls() -> str:
client = IMAPConnection(IMAP_PORT)
try:
assert_capabilities(capabilities(client.command("CAPABILITY")), cleartext=True)
client.command(
f"LOGIN {quote(USERNAME)} {quote(PASSWORD)}", expected=("NO",)
)
client.command("STARTTLS")
client.start_tls()
assert_capabilities(
capabilities(client.command("CAPABILITY")), cleartext=False
)
client.command("STARTTLS", expected=("NO", "BAD"))
client.command(f"LOGIN {quote(USERNAME)} {quote(PASSWORD)}")
client.command("LOGOUT")
return client.tls_version
finally:
client.close()
def check_starttls_pipeline_is_discarded() -> None:
client = IMAPConnection(IMAP_PORT)
try:
client.counter += 1
tag = f"T{client.counter:04d}"
client.socket.sendall(
f"{tag} STARTTLS\r\nT9999 LOGIN "
f"{quote(USERNAME)} {quote(PASSWORD)}\r\n".encode("utf-8")
)
line = client.line()
if not line.startswith(f"{tag} OK ".encode("ascii")):
raise IMAPCheckError(f"STARTTLS failed before pipeline test: {line!r}")
client.start_tls()
pending = (
client.socket.pending()
if isinstance(client.socket, ssl.SSLSocket)
else 0
)
readable, _, _ = select.select([client.socket], [], [], 0.5)
leaked = client.line() if pending or readable else b""
if leaked:
raise IMAPCheckError(
f"cleartext command crossed STARTTLS boundary: {leaked!r}"
)
client.command("LOGOUT")
finally:
client.close()
def auth_plain() -> str:
client = IMAPConnection(IMAPS_PORT, implicit_tls=True)
try:
assert_capabilities(
capabilities(client.command("CAPABILITY")), cleartext=False
)
payload = base64.b64encode(
b"\0" + USERNAME.encode() + b"\0" + PASSWORD.encode()
).decode("ascii")
client.command(f"AUTHENTICATE PLAIN {payload}")
client.command("LOGOUT")
return client.tls_version
finally:
client.close()
def auth_login() -> str:
client = IMAPConnection(IMAPS_PORT, implicit_tls=True)
try:
assert_capabilities(
capabilities(client.command("CAPABILITY")), cleartext=False
)
client.counter += 1
tag = f"T{client.counter:04d}"
client.socket.sendall(f"{tag} AUTHENTICATE LOGIN\r\n".encode("ascii"))
if not client.line().startswith(b"+ "):
raise IMAPCheckError("missing SASL LOGIN username challenge")
client.socket.sendall(base64.b64encode(USERNAME.encode()) + b"\r\n")
if not client.line().startswith(b"+ "):
raise IMAPCheckError("missing SASL LOGIN password challenge")
client.socket.sendall(base64.b64encode(PASSWORD.encode()) + b"\r\n")
completion = client.line()
if not completion.startswith(f"{tag} OK ".encode("ascii")):
raise IMAPCheckError(f"SASL LOGIN failed: {completion!r}")
client.command("LOGOUT")
return client.tls_version
finally:
client.close()
def auth_cancel() -> None:
client = IMAPConnection(IMAPS_PORT, implicit_tls=True)
try:
client.counter += 1
tag = f"T{client.counter:04d}"
client.socket.sendall(f"{tag} AUTHENTICATE LOGIN\r\n".encode("ascii"))
if not client.line().startswith(b"+ "):
raise IMAPCheckError("missing SASL challenge before cancellation")
client.socket.sendall(b"*\r\n")
completion = client.line()
if not completion.startswith(f"{tag} BAD ".encode("ascii")):
raise IMAPCheckError(
f"AUTHENTICATE cancellation was not tagged BAD: {completion!r}"
)
client.command("CAPABILITY")
client.command("LOGOUT")
finally:
client.close()
def auth_invalid_inputs() -> None:
client = IMAPConnection(IMAPS_PORT, implicit_tls=True)
try:
client.command("AUTHENTICATE DOES-NOT-EXIST", expected=("NO",))
client.command("AUTHENTICATE PLAIN not!base64", expected=("BAD",))
client.command("AUTHENTICATE PLAIN = extra", expected=("BAD",))
client.command("NOOP")
client.command("LOGOUT")
finally:
client.close()
def id_literal_plus(client: IMAPConnection) -> None:
name = b"Bongo literal client"
client.counter += 1
tag = f"T{client.counter:04d}"
client.socket.sendall(
f"{tag} ID ({{4+}}\r\n".encode("ascii")
+ b"name "
+ f"{{{len(name)}+}}\r\n".encode("ascii")
+ name
+ b")\r\n"
)
lines: list[bytes] = []
while True:
line = client.line()
if line.startswith(f"{tag} OK ".encode("ascii")):
break
if line.startswith(f"{tag} ".encode("ascii")):
raise IMAPCheckError(f"literal+ ID failed: {line!r}")
lines.append(line)
if not any(line.startswith(b'* ID ("name" "Bongo" ') for line in lines):
raise IMAPCheckError(f"literal+ ID response missing: {lines!r}")
def imaps_login() -> str:
client = IMAPConnection(IMAPS_PORT, implicit_tls=True)
try:
assert_capabilities(
capabilities(client.command("CAPABILITY")), cleartext=False
)
client.command(
f"LOGIN {quote(USERNAME)} {quote(PASSWORD)} extra",
expected=("BAD",),
)
client.command(f"LOGIN {quote(USERNAME)} {quote(PASSWORD)}")
assert_capabilities(
capabilities(client.command("CAPABILITY")),
cleartext=False,
authenticated=True,
)
client.command("CAPABILITY extra", expected=("BAD",))
client.command("NOOP")
client.command("NOOP extra", expected=("BAD",))
identity = client.command(
'ID ("name" "Bongo live test" "version" "0.7")'
)
if not any(
line.startswith(b'* ID ("name" "Bongo" "version" "')
for line in identity
):
raise IMAPCheckError(f"invalid ID response: {identity!r}")
id_literal_plus(client)
client.command('ID (NIL "value")', expected=("BAD",))
client.command('ID ("name""value")', expected=("BAD",))
client.command(
'ID ("name" "value""version" "0.7")', expected=("BAD",)
)
client.command(
'ID ("0123456789012345678901234567890" "value")',
expected=("BAD",),
)
client.command(
"ID ("
+ " ".join(f'"key{index}" "value"' for index in range(31))
+ ")",
expected=("BAD",),
)
client.command(
'ID ("comment" "' + ("x" * 1025) + '")',
expected=("BAD",),
)
client.command("NOOP")
namespace = client.command("NAMESPACE")
if b'* NAMESPACE (("" "/")) NIL NIL\r\n' not in namespace:
raise IMAPCheckError(f"invalid NAMESPACE response: {namespace!r}")
client.command("NAMESPACE extra", expected=("BAD",))
client.command("LOGOUT extra", expected=("BAD",))
client.command("NOOP")
logout = client.command("LOGOUT")
if not any(line.startswith(b"* BYE ") for line in logout):
raise IMAPCheckError(f"LOGOUT did not send BYE: {logout!r}")
return client.tls_version
finally:
client.close()
def main() -> int:
if not PASSWORD:
raise IMAPCheckError("BONGO_TEST_PASSWORD must be set")
starttls_version = check_starttls()
check_starttls_pipeline_is_discarded()
imaps_version = imaps_login()
if auth_plain() != imaps_version:
raise IMAPCheckError("IMAPS TLS version changed for SASL PLAIN")
if auth_login() != imaps_version:
raise IMAPCheckError("IMAPS TLS version changed for SASL LOGIN")
auth_cancel()
auth_invalid_inputs()
print(
"IMAP-01/02/03/04 PASS "
f"host={HOST} starttls={IMAP_PORT}/{starttls_version} "
f"imaps={IMAPS_PORT}/{imaps_version} "
"protocols=IMAP4rev2,IMAP4rev1,IMAP4 "
"pre-tls=LOGINDISABLED login=LOGIN,PLAIN,LOGIN-SASL "
"commands=CAPABILITY,NOOP,ID,NAMESPACE,LOGOUT "
"id=literal+,limits,nil-key-rejected "
"auth=cancel/unknown/base64/trailing/session-alive "
"noarg-syntax=capability/noop/namespace/logout "
"repeated-starttls=rejected pipeline=discarded"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())