wip: mail hardening: stalwart PROXY /32 + MTA-STS/TLS-RPT #679
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "sec-mail"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
Two inbound-mail hardening changes on beefcake's stalwart / the
lyte.devzone. Not deployed — build-checked only, for review + deploy by you.packages/hosts/beefcake/stalwart.nix)lib/modules/nixos/dns-zones.nix,packages/hosts/beefcake/caddy.nix,packages/hosts/beefcake.nix)Build-checked:
nix build .#nixosConfigurations.beefcake.config.system.build.toplevelsucceeds. The caddyadapt/fmtvalidation and the zone render both run as part of that build and pass. The rendered policy body and TXT records were inspected in the store (below).1. Stalwart PROXY trust:
100.64.0.0/10→100.64.0.15/32overrideProxyTrustedNetworkspreviously trusted the entire tailnet CGNAT range (100.64.0.0/10) to send PROXY-protocol headers on inbound:25. A PROXY header claims an arbitrary client IP, so anything in that range that reached:25could forge the sender IP and defeat SPF/DNSBL/reputation. The only host that legitimately sends PROXY headers is pebble, the MX front:Narrowed to pebble's tailnet /32. Your three questions:
(a) Is pebble's tailnet IP stable? Yes. headscale assigns node IPs persistently — a node keeps its address across reboots/reconnects; it only changes if the node is deleted and re-registered. Confirmed current value
100.64.0.15two ways:ssh root@pebble 'tailscale ip -4'→100.64.0.15headscale nodes list→pebble … 100.64.0.15, fd7a:115c:a1e0::fThe code comment calls out that if headscale ever does reassign it, this /32 must be updated in lockstep or PROXY-passed mail gets rejected as untrusted.
(b) Is a secret required instead? No — there's nothing to configure. PROXY protocol has no authentication or shared secret (HAProxy PROXY protocol spec — "The receiver … may choose to accept the connection … based on the sender's address"; there is no signature/token field). Trust is purely by source IP. So the correct control is a tight source-IP allowlist: the
/32here + the headscale ACL restricting who can even open:25. There is no stronger knob available at this layer.(c) Why was this rated LOW? Because the headscale ACL already restricts
:25to pebble (src: [pebble], dst: [beefcake:25,2526]inheadscale-acl.json), the broad/10trust was only reachable by a host that is both (i) an already-compromised tailnet node and (ii) permitted by the ACL to reach:25— i.e. it required a prior compromise to exploit at all. The/32removes that residual defense-in-depth gap so the two controls agree, rather than fixing an open hole. Hence LOW, not higher.2. MTA-STS + TLS-RPT (M-low)
Today inbound mail TLS is opportunistic STARTTLS with no published policy and no reporting: an on-path attacker can strip the STARTTLS advertisement (downgrade to cleartext) and we'd neither prevent nor detect it.
dig TXT _mta-sts.lyte.dev/dig TXT _smtp._tls.lyte.devboth currently return empty.What these are:
_mta-sts) and fetched over HTTPS (so the policy itself can't be forged by a DNS attacker — it's cert-validated).What this PR adds:
_mta-sts.lyte.devTXT →v=STSv1; id=20260701000000(theidis an opaque version tag; bump it whenever the policy file body changes so senders re-fetch).mta-sts.lyte.devserving the policy athttps://mta-sts.lyte.dev/.well-known/mta-sts.txt(auto-provisioned cert, same asfiles.lyte.dev). Rendered body (verified in the built,caddy fmt'd Caddyfile — no leading whitespace):_smtp._tls.lyte.devTXT →v=TLSRPTv1; rua=mailto:postmaster@lyte.dev.mta-stsadded to beefcake'slyte.dns-updater.recordsso the policy host resolves to this box.Recommendation — start in
testing, thenenforce. I setmode: testingdeliberately: in testing mode senders evaluate the policy and report failures via TLS-RPT but do not reject mail on a TLS failure. So we get visibility with zero delivery risk. Once TLS-RPT reports come in clean for a while (pebble's cert forpebble.lyte.devis valid and matches the MX), flip the Caddyfile tomode: enforceand bump theidindns-zones.nixin the same change so senders pick up the new policy.Deploy notes (for when you deploy)
--hostname 192.168.0.9), not the VPN.mta-sts.lyte.devA record is published by the dns-updater run, and caddy provisions the cert on first hit — the well-known URL won't serve valid TLS until both happen.🤖 Generated with Claude Code
Two inbound-mail hardening changes on beefcake's stalwart / lyte.dev: - Narrow overrideProxyTrustedNetworks from 100.64.0.0/10 (the whole tailnet CGNAT range) to pebble's tailnet /32 (100.64.0.15). Only pebble, the MX front, ever sends PROXY v2 headers (MX -> pebble HAProxy -> beefcake:25). PROXY protocol has no auth; source-IP trust is the only control, so a /32 plus the existing headscale ACL (src: [pebble] -> beefcake:25) are the layered controls. Removes the residual where a compromised tailnet node that could also reach :25 could forge the client IP and defeat SPF/DNSBL/reputation. - Add MTA-STS + TLS-RPT for lyte.dev inbound mail (previously opportunistic STARTTLS only, strippable by an on-path attacker with no policy or reporting): - _mta-sts.lyte.dev TXT (v=STSv1) + a caddy vhost mta-sts.lyte.dev serving the policy at /.well-known/mta-sts.txt (mode: testing, mx: pebble.lyte.dev, max_age 7d). testing is report-only; flip to enforce once TLS-RPT is clean. - _smtp._tls.lyte.dev TXT (v=TLSRPTv1; rua=mailto:postmaster@lyte.dev) for aggregate TLS-failure reports. - mta-sts added to the beefcake dns-updater dynamic records so the policy host resolves here. Build-checked (nixosConfigurations.beefcake toplevel); caddy adapt + zone render validated. Not deployed.mail hardening: stalwart PROXY /32 + MTA-STS/TLS-RPTto wip: mail hardening: stalwart PROXY /32 + MTA-STS/TLS-RPTView command line instructions
Checkout
From your project repository, check out a new branch and test the changes.