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
+2
View File
@@ -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. hundred long lines were megabytes held per connection.
- **A link's token stays out of the duplicate-cookie log line** (audit A4), - **A link's token stays out of the duplicate-cookie log line** (audit A4),
as it already did out of the origin check's. 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 - **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 three `dmarc_*` maps root creates at the start were missing from the list
of paths where a symbolic link is removed first. of paths where a symbolic link is removed first.
+7
View File
@@ -323,11 +323,18 @@ postconf -e \
# and the banner names no distribution (Debian's adds "(Debian)": postfix # 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 # 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"). # 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 \ postconf -e \
"smtpd_tls_protocols=>=TLSv1.2" \ "smtpd_tls_protocols=>=TLSv1.2" \
"smtpd_tls_mandatory_protocols=>=TLSv1.2" \ "smtpd_tls_mandatory_protocols=>=TLSv1.2" \
"smtp_tls_mandatory_protocols=>=TLSv1.2" \ "smtp_tls_mandatory_protocols=>=TLSv1.2" \
"disable_vrfy_command=yes" \ "disable_vrfy_command=yes" \
"smtpd_etrn_restrictions=reject" \
"smtpd_discard_ehlo_keywords=etrn" \
'smtpd_banner=$myhostname ESMTP' 'smtpd_banner=$myhostname ESMTP'
# SASL: Cyrus with the local sasldb2 the panel maintains. The realm is left # SASL: Cyrus with the local sasldb2 the panel maintains. The realm is left
+4 -1
View File
@@ -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 `smtpd_milters` is always overridden on this service, because main.cf's
submission chain would otherwise let a signing outage defer relayed mail and 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 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 then the panel's **inbound journal milter** (unix socket
`INBOUND_JOURNAL_MILTER_SOCKET`, default `INBOUND_JOURNAL_MILTER_SOCKET`, default
`/run/selfpost/inbound-journal.sock`, `default_action=accept`); with DMARC `/run/selfpost/inbound-journal.sock`, `default_action=accept`); with DMARC
+4 -2
View File
@@ -709,9 +709,11 @@ the argument.
- [x] **A4.** Duplicate-cookie line through `loggedPath`. — **Sonnet**, 0.25 h. - [x] **A4.** Duplicate-cookie line through `loggedPath`. — **Sonnet**, 0.25 h.
*Done 2026-10-17:* `loggedPath` moved to `auth.LoggedPath`, used by both *Done 2026-10-17:* `loggedPath` moved to `auth.LoggedPath`, used by both
lines; TestDuplicateCookieLineHidesALinksToken. 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 = 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. — - [ ] **X1.** SECURITY.md's table: `latest 2.x` supported, `1.x` not. —
**Sonnet**, 0.25 h. **Sonnet**, 0.25 h.
+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 // 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 // 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. // 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)) 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") token := uniqueToken("inbound")
if res := deliverPort25("someone@stranger.e2e.test", inboundRecipient, message(inboundRecipient, token)); !res.ok() { 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()) 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 // checkPostfixHardening reads back what postfix-config.sh sets against
// downgrade and enumeration (audit 2026-10-12, L31). // downgrade and enumeration (audit 2026-10-12, L31).
func checkPostfixHardening(s *stack) error { 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 { if err != nil {
return fmt.Errorf("postconf: %v\n%s", err, out) return fmt.Errorf("postconf: %v\n%s", err, out)
} }
got := strings.Split(strings.TrimSpace(out), "\n") 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, "|") { if strings.Join(got, "|") != strings.Join(want, "|") {
return fmt.Errorf("postconf -h of the hardened settings = %q, want %q", got, want) return fmt.Errorf("postconf -h of the hardened settings = %q, want %q", got, want)
} }