feat: add sender/recipient accept gates, security+stress test harnesses, nix flake, and docs

This commit is contained in:
randogoth 2026-10-03 18:03:43 +03:00
parent a0950fab52
commit 5dda6567af
18 changed files with 1764 additions and 44 deletions

480
scripts/security_test.py Normal file
View file

@ -0,0 +1,480 @@
#!/usr/bin/env python3
"""Adversarial testing for pneumatube, run against your own deployment.
Covers (see each test_* function's docstring for detail):
- injection fuzzing of the Email-To / subject paths
- malformed-MIME resilience of extract_text
- per-message failure isolation in the smolmail -> email poll loop
- username enumeration via the Postfix lookup service's own protocol
- abuse/open-relay behaviour and the accept-gate mitigations for each
direction (PNEUMATUBE_REQUIRE_ACCEPTED_SENDER, _RECIPIENT), including
their explicit opt-out modes
- 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)
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 python scripts/security_test.py
uv run python scripts/security_test.py --skip-live # unit-style checks only
Needs a `smolmail` checkout next to this repo for the live checks (same
default pneumatube itself uses: PNEUMATUBE_SMOLMAIL_REPO, default
"../smolmail").
"""
from __future__ import annotations
import argparse
import contextlib
import email as email_module
import logging
import os
import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass
from email.message import EmailMessage
from pathlib import Path
from aiosmtpd.controller import Controller
sys.path.insert(0, str(Path(__file__).resolve().parent))
from stress_test import ( # noqa: E402
REPO_ROOT, CountingSink, SmolAccount, SmolServer, free_port, make_account,
send_one_lmtp, send_one_smol, start_smolmaild, wait_for_port,
)
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
from pneumatube import smtp_client, vendor # noqa: E402
from pneumatube.email_to_smol import EmailToSmolBridge, _single_line # noqa: E402
from pneumatube.lookup_server import _lookup # noqa: E402
@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)
# --- injection fuzzing (no live infra needed) -----------------------------------
INJECTION_PAYLOADS = [
("bare-lf", "evil@example.com\nX-Injected: 1"),
("bare-cr", "evil@example.com\rX-Injected: 1"),
("crlf", "evil@example.com\r\nBcc: evil@example.com"),
("nul-byte", "evil@example.com\x00X-Injected: 1"),
("unicode-line-sep", "evil@example.com
X-Injected: 1"),
("unicode-para-sep", "evil@example.com
X-Injected: 1"),
("very-long", "a" * 10_000 + "@example.com"),
]
def test_address_validation_fuzz() -> None:
"""smtp_client.validate() is the only gate between an attacker-supplied
Email-To frontmatter value and smtplib.SMTP's RCPT TO -- it must reject
every CR/LF variant, not just the literal \\r\\n case the unit tests
already cover."""
failures = []
for name, payload in INJECTION_PAYLOADS:
try:
smtp_client.validate(payload)
# unicode-line-sep/para-sep and very-long aren't CR/LF and may
# legitimately pass the regex; only bare-lf/bare-cr/crlf/nul-byte
# must always be rejected.
if name in ("bare-lf", "bare-cr", "crlf", "nul-byte"):
failures.append(name)
except smtp_client.InvalidAddress:
pass
record("address validation fuzz", "pass" if not failures else "bug",
"all CR/LF/NUL variants rejected" if not failures
else f"accepted dangerous payloads: {failures}")
def test_single_line_fuzz() -> None:
"""email_to_smol._single_line() is what stops a forged email Subject
from injecting extra smolmail frontmatter fields (SPEC.md S5.5: one
`Key: value` per line) into an otherwise-trusted message. (MAIL FROM no
longer flows into frontmatter at all -- see email_to_smol.py's module
docstring -- but Subject still does.)"""
failures = []
for name, payload in INJECTION_PAYLOADS:
cleaned = _single_line(payload)
if "\n" in cleaned or "\r" in cleaned:
failures.append(name)
record("frontmatter sanitizer fuzz", "pass" if not failures else "bug",
"no payload left a raw CR/LF in the sanitized value" if not failures
else f"raw CR/LF survived sanitization: {failures}")
def test_outbound_header_injection() -> None:
"""Even though frontmatter values can't structurally contain a raw
newline (smolmail.py's parser splits the body into single lines before a
value is ever extracted), smtp_client.send() has no explicit sanitizer
of its own -- it currently relies entirely on that upstream guarantee
plus Python's EmailMessage rejecting embedded CR/LF. Confirm the second
layer actually holds, since the first is an implicit dependency on
vendored code outside this project."""
try:
m = EmailMessage()
m["Subject"] = "hi\r\nBcc: evil@attacker.example"
rejected = False
except ValueError:
rejected = True
record("stdlib header-injection backstop", "pass" if rejected else "bug",
"EmailMessage rejects embedded CR/LF in header values (confirmed)" if rejected
else "EmailMessage accepted an embedded CR/LF -- smtp_client.send() has no "
"sanitizer of its own and would forward it verbatim")
# --- malformed MIME resilience (no live infra needed) ---------------------------
def test_malformed_mime_resilience() -> None:
"""extract_text() runs on every inbound email before any validation --
it must not crash or hang on adversarial MIME, since a single crash
taking down the LMTP handler would be a trivial DoS against every other
in-flight recipient in the same message."""
cases = {
"empty": b"",
"no-body": b"Subject: hi\r\n\r\n",
"non-utf8-bytes": b"Subject: hi\r\nContent-Type: text/plain\r\n\r\n\xff\xfe\x00\x01",
"multipart-no-text-part": (
b"Content-Type: multipart/mixed; boundary=X\r\n\r\n--X\r\n"
b"Content-Type: application/octet-stream\r\n\r\nbinary\r\n--X--\r\n"),
"deeply-nested-multipart": _nested_multipart(50),
"declared-huge-missing-body": (
b"Content-Type: text/plain\r\nContent-Length: 999999999\r\n\r\nshort"),
}
failures = []
for name, raw in cases.items():
try:
message = email_module.message_from_bytes(raw)
t0 = time.monotonic()
EmailToSmolBridge.extract_text(message)
if time.monotonic() - t0 > 2.0:
failures.append(f"{name} (slow: possible algorithmic DoS)")
except Exception as exc:
failures.append(f"{name} ({exc!r})")
record("malformed MIME resilience", "pass" if not failures else "bug",
"all cases handled without raising or stalling" if not failures
else f"crashed or stalled on: {failures}")
def _nested_multipart(depth: int) -> bytes:
body = b"leaf"
for i in range(depth):
boundary = f"B{i}".encode()
body = (b"Content-Type: multipart/mixed; boundary=" + boundary + b"\r\n\r\n--"
+ boundary + b"\r\n" + body + b"\r\n--" + boundary + b"--\r\n")
return b"Content-Type: multipart/mixed; boundary=B0\r\n\r\n--B0\r\n" + body + b"\r\n--B0--\r\n"
# --- per-message failure isolation (no live infra needed) ----------------------
def test_poll_loop_isolates_failures() -> None:
"""A message that makes _relay() raise anything other than
InvalidAddress/OSError must not block every message behind it in the
smolmail -> email direction -- see smol_to_email.py's poll_once() for
why (a poisoned message is never deleted, so it sorts first forever)."""
from types import SimpleNamespace
from pneumatube.smol_to_email import SmolToEmailBridge
class StubSmol:
SmolError = RuntimeError
def cmd_fetch(self, args, store):
pass
class StubStore:
def mail(self, box):
return [(b"poison", b"", 0, None), (b"ok", b"", 1, None)]
bridge = SmolToEmailBridge(StubSmol(), StubStore(), account=None,
config=SimpleNamespace(smolmail_key="k", smolmail_db="d",
require_accepted_sender=True))
relayed = []
def fake_relay(mid, envelope):
if mid == b"poison":
raise ValueError("simulated")
relayed.append(mid)
bridge._relay = fake_relay
# poll_once() logs the simulated failure via logger.exception(), which is
# the whole point of the fix -- quiet it here so it doesn't look like an
# unhandled crash in this script's own output.
logging.getLogger("pneumatube.smol_to_email").setLevel(logging.CRITICAL)
try:
bridge.poll_once()
record("poll loop failure isolation", "pass" if relayed == [b"ok"] else "bug",
"later messages still processed after an earlier one raised" if relayed == [b"ok"]
else "poll_once stopped after the first failure -- queue-jam DoS")
except Exception as exc:
record("poll loop failure isolation", "bug",
f"poll_once raised instead of isolating the failure: {exc!r}")
# --- username enumeration via the lookup service (no live infra needed) --------
def test_lookup_enumeration() -> None:
"""The Postfix lookup service (lookup_server.py) 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."""
import asyncio
class StubBridge:
def user_exists(self, username):
return username in ("alice", "bob")
def recipient_accepted_gateway(self, username):
return True
class InlineWorker:
async def run(self, fn, *args):
return fn(*args)
from types import SimpleNamespace
config = SimpleNamespace(email_domain="gateway.example", require_accepted_recipient=True)
bridge, worker = StubBridge(), InlineWorker()
# tcp_table(5) status is "200" either way (it means "I have an answer");
# the OK-vs-reject distinction Postfix actually acts on is in the value.
results = {}
for name in ("alice", "bob", "carol", "admin", "root", "postmaster"):
reply = asyncio.run(_lookup(bridge, worker, config, f"{name}@gateway.example"))
_code, _, value = reply.partition(" ")
results[name] = value == "OK"
distinguishable = results["alice"] and results["bob"] 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")
# --- live tests: abuse/throttling and sender spoofing ---------------------------
def _start_pneumatube(smolmail_repo: Path, gateway: SmolAccount, server: SmolServer,
sink_port: int, require_accepted_sender: bool = True,
require_accepted_recipient: bool = True
) -> tuple[subprocess.Popen, int]:
listen_port = free_port()
env = {**os.environ,
"PNEUMATUBE_SMOLMAIL_REPO": str(smolmail_repo),
"PNEUMATUBE_SMOLMAIL_KEY": str(gateway.key_path),
"PNEUMATUBE_SMOLMAIL_DB": str(gateway.db_path),
"PNEUMATUBE_SMOLMAIL_DOMAIN": f"{server.host}:{server.port}",
"PNEUMATUBE_EMAIL_DOMAIN": "gateway.example",
"PNEUMATUBE_FETCH_INTERVAL": "2",
"PNEUMATUBE_REQUIRE_ACCEPTED_SENDER": "1" if require_accepted_sender else "0",
"PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT": "1" if require_accepted_recipient else "0",
"PNEUMATUBE_LISTEN_PROTOCOL": "lmtp",
"PNEUMATUBE_LISTEN_HOST": "127.0.0.1",
"PNEUMATUBE_LISTEN_PORT": str(listen_port),
"PNEUMATUBE_LOOKUP_HOST": "127.0.0.1",
"PNEUMATUBE_LOOKUP_PORT": str(free_port()),
"PNEUMATUBE_SMTP_RELAY_HOST": "127.0.0.1",
"PNEUMATUBE_SMTP_RELAY_PORT": str(sink_port),
"PNEUMATUBE_SMTP_RELAY_STARTTLS": "0",
"PNEUMATUBE_GATEWAY_FROM_ADDRESS": "gateway@gateway.example"}
proc = subprocess.Popen(["uv", "run", "pneumatube"], cwd=REPO_ROOT, env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
wait_for_port("127.0.0.1", listen_port)
return proc, listen_port
def _stop(proc: subprocess.Popen) -> None:
proc.terminate()
with contextlib.suppress(Exception):
proc.wait(timeout=5)
def run_live_tests(smolmail_repo: Path) -> None:
with tempfile.TemporaryDirectory(prefix="pneumatube-sectest-") as tmp:
tmp_dir = Path(tmp)
server = start_smolmaild(smolmail_repo, tmp_dir, rate_connections=6000, rate_sends=6000)
smol = vendor.load(smolmail_repo)
gateway = make_account(smol, 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()
# -- smolmail -> email, default config (require_accepted_sender=True) --
# 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.
proc, listen_port = _start_pneumatube(smolmail_repo, gateway, server, sink_port,
require_accepted_sender=True)
try:
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.py is not holding"))
# -- email -> smolmail, default config (require_accepted_recipient=True) --
# "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 handle_RCPT/_lookup is not holding"))
finally:
_stop(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.
proc, listen_port = _start_pneumatube(smolmail_repo, gateway, server, sink_port,
require_accepted_sender=False,
require_accepted_recipient=False)
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.py forwards no claim about the original sender at
# all (see its module docstring) 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)
sink_controller.stop()
_stop(server.process)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--skip-live", action="store_true",
help="run only the unit-style checks (no subprocess/network)")
parser.add_argument("--smolmail-repo", default="../smolmail")
args = parser.parse_args()
test_address_validation_fuzz()
test_single_line_fuzz()
test_outbound_header_injection()
test_malformed_mime_resilience()
test_poll_loop_isolates_failures()
test_lookup_enumeration()
if not args.skip_live:
run_live_tests(Path(args.smolmail_repo).resolve())
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 __name__ == "__main__":
main()