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:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user