pneumatube/scripts/security_test.py
2026-10-05 12:20:26 +03:00

270 lines
14 KiB
Python

#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["aiosmtpd>=1.4", "pynacl>=1.5", "noiseprotocol>=0.3.1"]
# ///
"""Adversarial testing for pneumatube, run against your own deployment.
Covers (see each check's docstring for detail):
- abuse/open-relay behaviour and the accept-gate mitigations for each
direction (PNEUMATUBE_REQUIRE_ACCEPTED_SENDER, _RECIPIENT), including
their explicit opt-out modes
- username enumeration via the Postfix lookup service's own protocol
- sender spoofing: confirms a forged MAIL FROM is never forwarded into
the relayed smolmail message (email -> smolmail carries no Email-From
field at all, precisely because MAIL FROM is unauthenticated)
The unit-style checks the Python version used to run here (address
validation fuzz, the frontmatter sanitizer, the CR/LF header backstop,
malformed-MIME resilience, poll-loop failure isolation) live in the Rust
implementation's `cargo test` suite now -- see the tests in
src/smtp_client.rs, src/email_to_smol.rs and src/smol_to_email.rs.
Each check prints PASS (a defense verified), RISK (confirmed, accepted
design tradeoff -- not a bug, but worth knowing it's real), or BUG
(unexpected, worth fixing).
Usage:
uv run scripts/security_test.py
Needs a `bunshin` and a `fumi` binary on PATH (or --server-bin/--fumi-bin),
a built pneumatube (--pneumatube-bin, default target/release/pneumatube),
and a `smolmail` checkout next to this repo for the load-driving client
(--smolmail-repo, default "../smolmail").
"""
from __future__ import annotations
import argparse
import contextlib
import os
import socket
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import unquote
from aiosmtpd.controller import Controller
from stress_test import (
REPO_ROOT, CountingSink, free_port, gateway_env,
load_smolmail, make_account, make_gateway_account, send_one_lmtp,
send_one_smol, start_bunshin, start_pneumatube, stop_proc,
)
@dataclass
class Finding:
check: str
kind: str # "pass" (defense verified), "risk" (confirmed accepted tradeoff), "bug" (unexpected)
detail: str
def print(self) -> None:
label = {"pass": "PASS", "risk": "RISK", "bug": "BUG "}[self.kind]
print(f"[{label}] {self.check}: {self.detail}")
FINDINGS: list[Finding] = []
def record(check: str, kind: str, detail: str) -> None:
f = Finding(check, kind, detail)
f.print()
FINDINGS.append(f)
def lookup_get(host: str, port: int, key: str) -> str:
with socket.create_connection((host, port), timeout=10) as s:
s.sendall(f"get {key}\n".encode())
return s.recv(4096).decode().strip()
def run_live_tests(args: argparse.Namespace) -> None:
smolmail_repo = Path(args.smolmail_repo).resolve()
smol = load_smolmail(smolmail_repo)
with tempfile.TemporaryDirectory(prefix="pneumatube-sectest-") as tmp:
tmp_dir = Path(tmp)
server = start_bunshin(args.server_bin, tmp_dir, rate_connections=6000, rate_sends=6000)
gateway = make_gateway_account(args.fumi_bin, tmp_dir, "gatewayuser", server)
victim = make_account(smol, tmp_dir, "victim", server)
spoof_victim = make_account(smol, tmp_dir, "spoofvictim", server)
sink = CountingSink()
sink_port = free_port()
sink_controller = Controller(sink, hostname="127.0.0.1", port=sink_port)
sink_controller.start()
# -- default config: both accept gates on --
listen_port, lookup_port = free_port(), free_port()
env = gateway_env(gateway, server, require_accepted_sender=True,
require_accepted_recipient=True)
proc = start_pneumatube(args.pneumatube_bin, env, listen_port, lookup_port, sink_port)
try:
# 15 messages from 15 DIFFERENT, never-before-seen sender
# identities (no accept token, no prior relationship), each
# addressed to a different external destination. None should
# reach the sink.
senders = [make_account(smol, tmp_dir, f"stranger{i}", server) for i in range(15)]
for i, sender in enumerate(senders):
send_one_smol(smol, sender, gateway.address, f"dest{i}@external.example",
f"relay test {i}")
time.sleep(4) # >= one PNEUMATUBE_FETCH_INTERVAL
delivered = sink.count()
record("open relay blocked by default (smolmail -> email)",
"pass" if delivered == 0 else "bug",
f"{delivered}/{len(senders)} first-contact messages reached the outbound "
"sink with PNEUMATUBE_REQUIRE_ACCEPTED_SENDER at its default (1) -- "
+ ("none did, as intended" if delivered == 0 else
"some did; the accept gate in smol_to_email is not holding"))
# "victim" is registered on the smolmail server (so user_exists
# passes) but has never accepted the gateway (no token held) --
# the all-or-nothing gate should reject this live, not queue it.
outcome, _lat = send_one_lmtp(
"127.0.0.1", listen_port, "sender@external.example",
"victim@gateway.example", b"Subject: hi\r\n\r\nbody\r\n")
record("relay blocked for unaccepted recipient (email -> smolmail)",
"pass" if outcome == "rejected" else "bug",
f"outcome={outcome} with PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT at its "
"default (1) for a registered-but-never-accepted recipient -- " +
("rejected live at RCPT TO, as intended" if outcome == "rejected" else
"was not rejected; the gate in the LMTP handler and lookup service "
"is not holding"))
# The lookup service answers definitively per username with no
# authentication and no rate limiting of its own -- by design,
# it's meant to be reachable only from Postfix on a private
# network. Confirm that property so the deployment docs'
# "loopback only" requirement is understood as load-bearing,
# not cosmetic. Both accept-gate replies ("550 5.7.1 ...") and
# "550 5.1.1 no such user" reveal which usernames exist, just
# as an "OK" would; the OK-vs-reject distinction Postfix acts
# on is a different axis from the exists-or-not distinction an
# enumerator wants.
results = {}
for name in ("victim", "spoofvictim", "carol", "admin", "root", "postmaster"):
reply = lookup_get("127.0.0.1", lookup_port, f"{name}@gateway.example")
# The reply's action text is %XX-quoted on the wire; a
# known username answers anything but "no such user".
action = unquote(reply.split(" ", 1)[1]) if " " in reply else reply
results[name] = action != "550 5.1.1 no such user"
distinguishable = (results["victim"] and results["spoofvictim"]
and not any(results[n] for n in
("carol", "admin", "root", "postmaster")))
record("lookup service enumeration", "risk" if distinguishable else "bug",
"the lookup protocol unambiguously reveals which usernames exist -- "
"expected, it must for check_recipient_access to work, but this is "
"only safe because the service is loopback-only; exposing it beyond "
"Postfix would be a username enumeration oracle" if distinguishable else
"lookup responses did not distinguish known from unknown usernames as "
"expected -- check_recipient_access would misbehave")
finally:
stop_proc(proc)
# -- both directions, explicit opt-in to the open-bridge mode --
# Confirms both flags at 0 still do what they document: restore
# "relay anyone" for an operator who deliberately wants it. victim
# and spoof_victim never accepted the gateway, so this also doubles
# as the opt-out confirmation for the recipient gate.
listen_port, lookup_port = free_port(), free_port()
env = gateway_env(gateway, server, require_accepted_sender=False,
require_accepted_recipient=False)
proc = start_pneumatube(args.pneumatube_bin, env, listen_port, lookup_port, sink_port)
try:
senders = [make_account(smol, tmp_dir, f"openbridge{i}", server) for i in range(15)]
for i, sender in enumerate(senders):
send_one_smol(smol, sender, gateway.address, f"dest2-{i}@external.example",
f"open bridge test {i}")
deadline = time.monotonic() + 15
while sink.count() < len(senders) and time.monotonic() < deadline:
time.sleep(0.5)
delivered = sink.count()
record("open relay via explicit opt-in (smolmail -> email)", "risk",
f"{delivered}/{len(senders)} first-contact senders were relayed to "
"arbitrary external addresses with PNEUMATUBE_REQUIRE_ACCEPTED_SENDER=0 "
"-- confirms the opt-in open-bridge mode works as documented; an operator "
"choosing it should understand this is what they're choosing")
# -- open relay / no throttling (email -> smolmail) --
ok = 0
total = 15
for i in range(total):
outcome, _lat = send_one_lmtp("127.0.0.1", listen_port,
f"stranger{i}@external.example",
"victim@gateway.example",
f"Subject: relay {i}\r\n\r\nbody {i}\r\n".encode())
ok += outcome == "ok"
record("open relay via explicit opt-in (email -> smolmail)", "risk",
f"{ok}/{total} messages from {total} distinct external senders were all "
"relayed to an unaccepted recipient with PNEUMATUBE_REQUIRE_ACCEPTED_"
"RECIPIENT=0 -- confirms the opt-out works as documented; an operator "
"choosing it should understand this is what they're choosing. There is "
"also no per-sender throttling regardless of this flag's setting.")
# -- sender spoofing --
# Own recipient account, untouched by the open-relay tests above,
# so finding the message isn't a race against unrelated mail.
# email_to_smol forwards no claim about the original sender at
# all (see its module docs) specifically because MAIL FROM is
# unauthenticated; confirm a forged one doesn't leak through
# into any frontmatter field of the relayed message.
forged = "security-team@trusted-bank.example"
outcome, _lat = send_one_lmtp(
"127.0.0.1", listen_port, forged, "spoofvictim@gateway.example",
b"Subject: Urgent: verify your account\r\n\r\nClick here...\r\n")
opened = None
deadline = time.monotonic() + 15
account = smol.load_account(argparse.Namespace(key=str(spoof_victim.key_path)),
smol.Store(str(spoof_victim.db_path)))
while time.monotonic() < deadline and opened is None:
store = smol.Store(str(spoof_victim.db_path))
smol.cmd_fetch(argparse.Namespace(key=str(spoof_victim.key_path),
db=str(spoof_victim.db_path),
reset=False, keep=False), store)
rows = store.mail("all")
if rows:
mid, envelope, _at, _r = rows[0]
opened = smol.describe(store, envelope, account.keys)
else:
time.sleep(0.5)
if opened is None:
record("sender spoofing (no Email-From claim forwarded)", "bug",
f"the message never arrived (outcome={outcome}) -- could not confirm")
else:
leaked = forged in opened["fields"].values() or "email-from" in opened["fields"]
record("sender spoofing (no Email-From claim forwarded)",
"pass" if not leaked else "bug",
"the relayed message carries no field reflecting the forged MAIL "
"FROM -- nothing here for a recipient to mistake for a verified "
"sender claim" if not leaked else
f"the forged MAIL FROM {forged!r} leaked into the relayed message's "
f"frontmatter: {opened['fields']}")
finally:
stop_proc(proc)
sink_controller.stop()
stop_proc(server.process)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--smolmail-repo", default="../smolmail")
parser.add_argument("--server-bin", default="bunshin")
parser.add_argument("--fumi-bin", default="fumi")
parser.add_argument("--pneumatube-bin", default=str(REPO_ROOT / "target/release/pneumatube"))
args = parser.parse_args()
run_live_tests(args)
print()
bugs = [f for f in FINDINGS if f.kind == "bug"]
risks = [f for f in FINDINGS if f.kind == "risk"]
print(f"{len(FINDINGS)} check(s): {len(FINDINGS) - len(bugs) - len(risks)} pass, "
f"{len(risks)} confirmed accepted risk(s), {len(bugs)} bug(s)")
if bugs:
raise SystemExit(1)
if __name__ == "__main__":
main()