Files
bongo/contrib/testing/sendmail-local-submission-check.py
T
Mario Fetka 785c89e42c
Debian Trixie package bundle / packages (push) Failing after 13m54s
Secure unprivileged local mail submission
2026-07-24 08:36:04 +02:00

192 lines
6.6 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 unprivileged local submission through sendmail and GNU mail."""
from __future__ import annotations
import os
from pathlib import Path
import shutil
import subprocess
import sys
import time
from bongo.store.StoreClient import StoreClient
HOST = os.environ.get("BONGO_TEST_HOST", "127.0.0.1")
STORE_PORT = int(os.environ.get("BONGO_TEST_STORE_PORT", "689"))
SENDER = os.environ.get("BONGO_TEST_USER1", "test1")
RECIPIENT = os.environ.get("BONGO_TEST_USER2", "test2")
DOMAIN = os.environ.get("BONGO_TEST_DOMAIN", "bongo.test")
PASSWORD = os.environ.get("BONGO_TEST_PASSWORD", "")
SENDMAIL = os.environ.get("BONGO_TEST_SENDMAIL", "/usr/sbin/sendmail")
MAIL = os.environ.get("BONGO_TEST_MAIL", "/usr/bin/mail")
QUEUE_TOOL = os.environ.get(
"BONGO_TEST_QUEUE_TOOL", "/usr/bin/bongo-queuetool")
TIMEOUT = float(os.environ.get("BONGO_TEST_TIMEOUT", "90"))
ALLOW_LIVE = os.environ.get("BONGO_ALLOW_LIVE_USER_TEST") == "1"
RUN_TOKEN = f"sendmail-local-{os.getpid()}-{int(time.time())}"
class LocalSubmissionError(RuntimeError):
"""The local sendmail compatibility check failed."""
def message(token: str, client: str) -> bytes:
return "\r\n".join([
f"From: {SENDER}@{DOMAIN}",
f"To: {RECIPIENT}@{DOMAIN}",
f"Bcc: {RECIPIENT}@{DOMAIN}",
f"Subject: Bongo local submission {client} {token}",
f"Message-ID: <{token}@{DOMAIN}>",
f"X-Bongo-Test: {token}",
"",
f"Unprivileged {client} submission {token}",
"",
]).encode("ascii")
def run(command: list[str], data: bytes) -> None:
try:
completed = subprocess.run(
command, input=data, capture_output=True, timeout=TIMEOUT,
check=False)
except subprocess.TimeoutExpired as error:
raise LocalSubmissionError(
f"{Path(command[0]).name} timed out after {TIMEOUT:g} seconds"
) from error
if completed.returncode:
detail = completed.stderr.decode("utf-8", "replace").strip()
raise LocalSubmissionError(
f"{Path(command[0]).name} exited with {completed.returncode}: "
f"{detail}")
def open_store() -> StoreClient:
return StoreClient(
RECIPIENT, RECIPIENT, authPassword=PASSWORD,
host=HOST, port=STORE_PORT)
def matching_documents(store: StoreClient, token: str) -> list[tuple[str, bytes]]:
identifiers = [entry.uid for entry in store.List("/mail/INBOX")]
matches: list[tuple[str, bytes]] = []
for identifier in identifiers:
try:
payload = store.Read(identifier)
except Exception:
continue
if token.encode("ascii") in payload:
matches.append((identifier, payload))
return matches
def wait_for_one(store: StoreClient, token: str) -> tuple[str, bytes]:
deadline = time.monotonic() + TIMEOUT
while time.monotonic() < deadline:
matches = matching_documents(store, token)
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
raise LocalSubmissionError(
f"{token} was delivered {len(matches)} times")
time.sleep(0.25)
raise LocalSubmissionError(f"{token} did not reach the Store INBOX")
def queue_cleanup(token: str) -> None:
listing = subprocess.run(
["sudo", "-n", "-u", "bongo", QUEUE_TOOL, "list"],
capture_output=True, text=True, check=False)
if listing.returncode:
return
for line in listing.stdout.splitlines():
fields = line.split()
if not fields:
continue
item = subprocess.run(
["sudo", "-n", "-u", "bongo", QUEUE_TOOL,
"message", fields[0]],
capture_output=True, check=False)
if token.encode("ascii") in item.stdout:
subprocess.run(
["sudo", "-n", "-u", "bongo", QUEUE_TOOL,
"delete", fields[0]],
capture_output=True, check=False)
def main() -> int:
if not ALLOW_LIVE:
raise LocalSubmissionError(
"set BONGO_ALLOW_LIVE_USER_TEST=1 for the disposable live system")
if not PASSWORD:
raise LocalSubmissionError("BONGO_TEST_PASSWORD is required")
for program in (SENDMAIL, MAIL):
if not Path(program).is_file() or not os.access(program, os.X_OK):
raise LocalSubmissionError(f"required executable {program} is missing")
if os.geteuid() == 0:
raise LocalSubmissionError(
"run this test as an unprivileged account, not root")
tokens = {
"sendmail": f"{RUN_TOKEN}-sendmail",
"mailutils": f"{RUN_TOKEN}-mailutils",
}
store = open_store()
delivered: list[str] = []
try:
run(
[SENDMAIL, "-oi", "-t", "-f", f"{SENDER}@{DOMAIN}"],
message(tokens["sendmail"], "sendmail"))
identifier, payload = wait_for_one(store, tokens["sendmail"])
if b"\nBcc:" in payload or b"\r\nBcc:" in payload:
raise LocalSubmissionError("sendmail leaked the Bcc header")
delivered.append(identifier)
mail_message = message(tokens["mailutils"], "GNU-mail")
run(
[
MAIL, "--no-config",
f"--mailer=sendmail:{SENDMAIL}",
"--to",
],
mail_message)
identifier, payload = wait_for_one(store, tokens["mailutils"])
if b"\nBcc:" in payload or b"\r\nBcc:" in payload:
raise LocalSubmissionError("GNU mail leaked the Bcc header")
delivered.append(identifier)
print(
"SMTP-31 PASS "
f"user={os.geteuid()} sendmail={SENDMAIL} "
"clients=sendmail,GNU-mail master-credential=unreadable "
f"recipient={RECIPIENT}@{DOMAIN} deliveries=2 duplicate=no "
"bcc-removed=yes store-clean=yes")
return 0
finally:
for identifier in delivered:
try:
store.Delete(identifier)
except Exception as error:
print(
f"sendmail cleanup warning for {identifier}: {error}",
file=sys.stderr)
for token in tokens.values():
queue_cleanup(token)
try:
store.Quit()
except Exception:
pass
if __name__ == "__main__":
try:
raise SystemExit(main())
except (LocalSubmissionError, OSError, ValueError) as error:
print(f"sendmail local submission check: {error}", file=sys.stderr)
raise SystemExit(1)