diff --git a/CHANGELOG.md b/CHANGELOG.md index d361063..6258d75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version hundred long lines were megabytes held per connection. - **A link's token stays out of the duplicate-cookie log line** (audit A4), as it already did out of the origin check's. +- **ETRN is refused and no longer offered** (audit S6): Postfix's default + let anyone on port 25 flush the queue toward an inbound domain's upstream. - **The entrypoint follows no link at the DMARC maps** (audit I2): the three `dmarc_*` maps root creates at the start were missing from the list of paths where a symbolic link is removed first. diff --git a/build/postfix-config.sh b/build/postfix-config.sh index 2305239..9323bb7 100644 --- a/build/postfix-config.sh +++ b/build/postfix-config.sh @@ -323,11 +323,18 @@ postconf -e \ # and the banner names no distribution (Debian's adds "(Debian)": postfix # 3.10.13-0+deb13u1, debian/main.cf.in:15 "(@DEB_VENDOR@)", filled in by # debian/rules:118; the upstream default is "$myhostname ESMTP $mail_name"). +# ETRN is refused to everyone and not offered: Postfix's default lets any +# client flush the queue toward an inbound domain's upstream +# (fast_flush_domains = $relay_domains), and nothing here needs it (security +# audit 2026-10-17, S6; Postfix 3.10.13, src/smtpd/smtpd.c, etrn_cmd() asks +# smtpd_check_etrn(), whose "reject" answers reject_code, 554). postconf -e \ "smtpd_tls_protocols=>=TLSv1.2" \ "smtpd_tls_mandatory_protocols=>=TLSv1.2" \ "smtp_tls_mandatory_protocols=>=TLSv1.2" \ "disable_vrfy_command=yes" \ + "smtpd_etrn_restrictions=reject" \ + "smtpd_discard_ehlo_keywords=etrn" \ 'smtpd_banner=$myhostname ESMTP' # SASL: Cyrus with the local sasldb2 the panel maintains. The realm is left diff --git a/docs/architecture.md b/docs/architecture.md index 5da1378..fd54c1b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -103,7 +103,10 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the `smtpd_milters` is always overridden on this service, because main.cf's submission chain would otherwise let a signing outage defer relayed mail and would file inbound mail into the outbound send log under the sender's - domain. With the inbound relay on the chain is the optional antispam milter, + domain. `ETRN` is refused on every listener and not offered in the EHLO + reply (`smtpd_etrn_restrictions=reject`, `smtpd_discard_ehlo_keywords=etrn`): + Postfix's default lets any client flush the queue toward an inbound + domain's upstream. With the inbound relay on the chain is the optional antispam milter, then the panel's **inbound journal milter** (unix socket `INBOUND_JOURNAL_MILTER_SOCKET`, default `/run/selfpost/inbound-journal.sock`, `default_action=accept`); with DMARC diff --git a/docs/security-audit-2026-10-17.md b/docs/security-audit-2026-10-17.md index 3b7f76f..c411a7f 100644 --- a/docs/security-audit-2026-10-17.md +++ b/docs/security-audit-2026-10-17.md @@ -709,9 +709,11 @@ the argument. - [x] **A4.** Duplicate-cookie line through `loggedPath`. — **Sonnet**, 0.25 h. *Done 2026-10-17:* `loggedPath` moved to `auth.LoggedPath`, used by both lines; TestDuplicateCookieLineHidesALinksToken. -- [ ] **S6.** `smtpd_etrn_restrictions = reject`, `smtpd_discard_ehlo_keywords +- [x] **S6.** `smtpd_etrn_restrictions = reject`, `smtpd_discard_ehlo_keywords = etrn` in postfix-config.sh; architecture.md § Postfix one line. Done when - an e2e `ETRN` on 25 gets `554`. — **Opus**, 0.5 h. + an e2e `ETRN` on 25 gets `554`. — **Opus**, 0.5 h. *Done 2026-10-17:* + testInboundRelay sends ETRN for the inbound domain; checkPostfixHardening + reads both settings back. - [ ] **X1.** SECURITY.md's table: `latest 2.x` supported, `1.x` not. — **Sonnet**, 0.25 h. diff --git a/test/e2e/port25_client.go b/test/e2e/port25_client.go index b6afead..a7615fc 100644 --- a/test/e2e/port25_client.go +++ b/test/e2e/port25_client.go @@ -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. diff --git a/test/e2e/port25_features_test.go b/test/e2e/port25_features_test.go index 803f86f..e4d1fed 100644 --- a/test/e2e/port25_features_test.go +++ b/test/e2e/port25_features_test.go @@ -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()) diff --git a/test/e2e/process_check.go b/test/e2e/process_check.go index 91de726..007d072 100644 --- a/test/e2e/process_check.go +++ b/test/e2e/process_check.go @@ -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) }