192 lines
6.6 KiB
Python
Executable File
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)
|