postfix: ETRN refused and not offered (audit S6)

Security audit 2026-10-17, finding S6 (low), stage 0 - proven from
outside: `ETRN <an inbound domain>` on port 25 answered "250 Queuing
started". smtpd_etrn_restrictions was empty (Postfix allows every client)
and fast_flush_domains = $relay_domains, so a stranger could flush the
deferred queue toward an inbound domain's upstream at will. Now
smtpd_etrn_restrictions=reject (etrn_cmd -> smtpd_check_etrn -> reject_code
554, Postfix 3.10.13 src/smtpd/smtpd.c) and smtpd_discard_ehlo_keywords=etrn.

e2e: testInboundRelay sends EHLO + ETRN for the inbound domain on port 25
and expects 554 and no ETRN in the EHLO reply (etrnPort25);
checkPostfixHardening reads both settings back. To be run on the server.

Docs: architecture.md § Postfix; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-17 21:36:55 +03:00
co-authored by Claude Opus 5.5
parent 5210cb19f2
commit 8820294472
7 changed files with 57 additions and 5 deletions
+31
View File
@@ -82,6 +82,37 @@ func waitForPort25Ready(timeout time.Duration) error {
})
}
// etrnPort25 sends EHLO and then ETRN domain on port 25 and returns ETRN's
// reply code and what EHLO offered.
func etrnPort25(domain string) (int, string, error) {
conn, err := net.DialTimeout("tcp", port25Addr, 10*time.Second)
if err != nil {
return 0, "", fmt.Errorf("dial %s: %w", port25Addr, err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
tp := textproto.NewConn(conn)
if _, _, err := tp.ReadResponse(220); err != nil {
return 0, "", err
}
if err := tp.PrintfLine("EHLO mx.stranger.e2e.test"); err != nil {
return 0, "", err
}
_, ehlo, err := tp.ReadResponse(250)
if err != nil {
return 0, ehlo, err
}
if err := tp.PrintfLine("ETRN %s", domain); err != nil {
return 0, ehlo, err
}
code, _, err := tp.ReadResponse(0)
_ = tp.PrintfLine("QUIT")
if code != 0 {
err = nil // any code is the answer asked for
}
return code, ehlo, err
}
// deliverPort25Raw is deliverPort25 with the DATA bytes written as they
// stand. net/smtp's data writer would turn a bare LF into CRLF and stuff a
// lone "." (net/textproto, DotWriter), which is the very input this is for.
+6
View File
@@ -262,6 +262,12 @@ func testInboundRelay(t *testing.T, sc *scenario) {
to, token, time.Now().UTC().Format(time.RFC1123Z))
}
// A stranger cannot flush the queue toward the domain's upstream: ETRN is
// refused and not offered (security audit 2026-10-17, S6).
if code, ehlo, err := etrnPort25(inboundDomain); err != nil || code != 554 || strings.Contains(strings.ToUpper(ehlo), "ETRN") {
t.Errorf("ETRN %s on port 25 = %d (%v), EHLO offers:\n%s", inboundDomain, code, err, ehlo)
}
token := uniqueToken("inbound")
if res := deliverPort25("someone@stranger.e2e.test", inboundRecipient, message(inboundRecipient, token)); !res.ok() {
t.Fatalf("port 25 did not accept mail for %s: %v", inboundRecipient, res.firstErr())
+3 -2
View File
@@ -149,12 +149,13 @@ func checkDKIMTree(s *stack) error {
// checkPostfixHardening reads back what postfix-config.sh sets against
// downgrade and enumeration (audit 2026-10-12, L31).
func checkPostfixHardening(s *stack) error {
out, err := s.execIn("selfpost", "postconf", "-h", "smtpd_tls_protocols", "smtpd_tls_mandatory_protocols", "disable_vrfy_command", "smtpd_banner")
out, err := s.execIn("selfpost", "postconf", "-h", "smtpd_tls_protocols", "smtpd_tls_mandatory_protocols", "disable_vrfy_command", "smtpd_banner",
"smtpd_etrn_restrictions", "smtpd_discard_ehlo_keywords")
if err != nil {
return fmt.Errorf("postconf: %v\n%s", err, out)
}
got := strings.Split(strings.TrimSpace(out), "\n")
want := []string{">=TLSv1.2", ">=TLSv1.2", "yes", "$myhostname ESMTP"}
want := []string{">=TLSv1.2", ">=TLSv1.2", "yes", "$myhostname ESMTP", "reject", "etrn"}
if strings.Join(got, "|") != strings.Join(want, "|") {
return fmt.Errorf("postconf -h of the hardened settings = %q, want %q", got, want)
}