Files
selfpost/test/e2e/negative_test.go
T
mixandClaude Opus 5.5 1c6e04b7d3
test / test (push) Canceled after 0s
base-trixie: the image on Debian trixie, Postfix 3.10.13; Received: hidden on 465/587
The runtime base is debian:trixie-slim, the build stage golang:1.26-trixie:
Postfix 3.10.13 with upstream's 2026 security fixes up to the August
batch, libmilter 8.18.1, OpenSSL 3.5. Every citation of another program in
code, scripts and documents was re-read against the trixie sources
(dev/upstream; bookworm's kept in dev/upstream-bookworm) and updated;
development.md's cited-package table holds the trixie versions.

What the re-reading changed in behaviour:
- logtail: reject lines with sasl_method/sasl_username/sasl_sender
  (smtpd_check.c:1019-1026); cleanup's "removed (canceled)" forgets a
  port-25 id (cleanup_api.c:363-364).
- internal/postfix/retry.go reads postconf's stdout only: since 3.9 a
  deprecated-parameter warning on stderr made one value too many and the
  panel fell back to the compiled-in retry policy.
- logrotate-loop.sh: logrotate 3.22 exits 1 when it ignores a config;
  the loop refuses on that output whatever the status.
- postfix-config.sh: cyrus_sasl_config_path set explicitly (bookworm's
  07_sasl_config.diff is gone); maillog_file_permissions=0640; mynetworks
  explicit; Dockerfile: SYNC_CHROOT= in /etc/default/postfix,
  opendkim-tools dropped, openssl named, systemd-standalone-sysusers so
  cron does not bring the whole of systemd.
- The selfpost group is 10998, outside the system range 100-999: in
  trixie a package took 998 and the build failed (owner's decision:
  a conflict-free id, the distribution's own ids untouched). The
  entrypoint hands /data back by name when the user or the group differs.
  Guide, backup refusal text, help (design commit 68ec8b2), e2e.
- smtpd_hide_client_session=yes on smtps and submission (owner's
  decision): Received: names no client, TLS, login or protocol; port 25
  keeps the full header. e2e checks both.
- test/upstream/opendkim-probe.sh makes keys with openssl genrsa.

Tests: on the server, make e2e exit 0 (674 s, the image built on trixie),
go vet + go test 27 packages ok. CHANGELOG [Unreleased], guide,
architecture.md, security.md, roadmap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:31:35 +03:00

239 lines
9.9 KiB
Go

package e2e
import (
"fmt"
"testing"
"time"
)
// testLevel2RateLimit is plan C.4 negative check 5: a limit set through the
// panel (not the environment) rejects the message that exceeds it, and the
// rejection is visible in the send log — the panel -> DB -> milter path.
func testLevel2RateLimit(t *testing.T, sc *scenario) {
ip, err := lastSMTPClientIP(h)
if err != nil {
t.Fatalf("determine observed SMTP client IP: %v", err)
}
sc.clientIP = ip
login, password, err := sc.panel.addApplication(sc.domainID, "l2app", "wildcard", "")
if err != nil {
t.Fatalf("add application: %v", err)
}
if err := waitForSenderMap(login, password, "billing@"+senderDomain); err != nil {
t.Fatal(err)
}
sc.l2AppLogin, sc.l2AppPassword = login, password
appID, err := sc.panel.applicationID(sc.domainID, login)
if err != nil {
t.Fatal(err)
}
if err := sc.panel.setApplicationLimit(sc.domainID, appID, 1, 3600); err != nil {
t.Fatalf("save application rate limit: %v", err)
}
from := "billing@" + senderDomain
first := attemptSend(sendAttempt{
authLogin: login, authPassword: password,
from: from, to: recipient, subject: "e2e " + uniqueToken("l2-first"), body: "ok",
})
if !first.ok() {
t.Fatalf("first message under the level-2 limit was rejected: %v", first.firstErr())
}
second := attemptSend(sendAttempt{
authLogin: login, authPassword: password,
from: from, to: recipient, subject: "e2e " + uniqueToken("l2-second"), body: "should be rejected",
})
// The milter answers tempfail at MAIL FROM, which Postfix words as below
// (Postfix 3.10.13, src/milter/milter8.c, milter8_event(), SMFIR_TEMPFAIL:
// "451 4.7.1 Service unavailable - try again later").
if err := second.refusedAt("MAIL", 451, "4.7.1 Service unavailable"); err != nil {
t.Fatalf("the second message over the level-2 limit: %v", err)
}
// The limit holds for every message of a connection, not for its first
// only. The milter library hands the milter a fresh session after any reply
// that is not "continue" - the refusal above is one - and Postfix does not
// introduce the client again, so a second MAIL FROM on the same connection
// used to arrive with no client address and was let through (found by the
// technical review of 2026-10-12; the same hole let an application past its
// client-address list). Postfix's half: 3.10.13, src/smtpd/smtpd.c,
// smtpd_proto(), calls milter_conn_event() once per connection.
errs := mailTwiceOnOneConnection(login, password, from)
for i, err := range errs {
if err := replyIs(err, 451, "4.7.1 Service unavailable"); err != nil {
t.Fatalf("over the level-2 limit, MAIL FROM %d of one connection: %v", i+1, err)
}
}
if err := waitFor("a rate-limited row for l2app in the send log", 15*time.Second, 500*time.Millisecond, func() (bool, error) {
rows, err := sc.panel.sendLogRows(senderDomain, login)
if err != nil {
return false, err
}
if containsCell(rows, "rate-limited") {
return true, nil
}
return false, fmt.Errorf("no rate-limited row for l2app yet")
}); err != nil {
t.Fatal(err)
}
}
// testSenderLoginMismatch is plan C.4 negative check 2: an authenticated
// application cannot send as a sender it does not own (architecture.md § Mail
// path, reject_sender_login_mismatch) — the core anti-spoofing control.
func testSenderLoginMismatch(t *testing.T, sc *scenario) {
res := attemptSend(sendAttempt{
authLogin: sc.appLogin, authPassword: sc.appPassword,
from: "someone@not-" + senderDomain, to: recipient,
subject: "e2e " + uniqueToken("mismatch"), body: "should be rejected",
})
// Postfix evaluates smtpd_sender_restrictions with smtpd_delay_reject=yes
// (the default): the mismatch is detected at MAIL FROM but the reject is
// only sent back at RCPT TO. (Postfix 3.10.13, src/global/mail_params.h,
// DEF_SMTPD_DELAY_REJECT 1; src/smtpd/smtpd_check.c,
// reject_auth_sender_login_mismatch(): 553 5.7.1 "Sender address rejected:
// not owned by user".)
if err := res.refusedAt("RCPT", 553, "5.7.1"); err != nil {
t.Fatalf("a sender the application does not own: %v", err)
}
if err := replyIs(res.rcptErr, 553, "Sender address rejected: not owned by user"); err != nil {
t.Fatalf("a sender the application does not own: %v", err)
}
}
// testNoAuthRejected is plan C.4 negative check 1: no SASL session, no mail.
// The sender address used here belongs to no registered domain, so the
// rejection is unambiguously about the missing AUTH and not sender ownership.
// The reply: smtps's client restrictions end in "reject" (build/
// postfix-config.sh), Postfix 3.10.13, src/smtpd/smtpd_check.c,
// generic_checks(): var_reject_code (554) "<%s>: %s rejected: Access denied",
// delayed to RCPT by smtpd_delay_reject.
func testNoAuthRejected(t *testing.T, sc *scenario) {
res := attemptSend(sendAttempt{
from: "anyone@unregistered.e2e.test", to: recipient,
subject: "e2e " + uniqueToken("noauth"), body: "should be rejected",
})
if err := res.refusedAt("RCPT", 554, "Client host rejected: Access denied"); err != nil {
t.Fatalf("an unauthenticated send: %v", err)
}
}
// testForeignRelayRejected is plan C.4 negative check 3: a direct proof that
// the submission port is not an open relay — even the exact recipient the
// positive path just delivered to is refused without AUTH. The refusal is
// smtpd_client_restrictions (permit_sasl_authenticated, reject), which on 465
// and 587 comes before reject_unauth_destination, so the reply is the same as
// check 1's; reject_unauth_destination's own "Relay access denied" is held on
// port 25 (testInboundRelay), where clients do not authenticate.
func testForeignRelayRejected(t *testing.T, sc *scenario) {
res := attemptSend(sendAttempt{
from: "anyone@unregistered.e2e.test", to: recipient,
subject: "e2e " + uniqueToken("relay"), body: "should be rejected",
})
if err := res.refusedAt("RCPT", 554, "Client host rejected: Access denied"); err != nil {
t.Fatalf("an unauthenticated relay to an external destination: %v", err)
}
}
// checkContainerAlive confirms the container is still up after a component
// stop — the crashexit listener must not have brought it down.
func checkContainerAlive(s *stack) error {
out, err := s.execIn("selfpost", "true")
if err != nil {
return fmt.Errorf("container not responding to exec: %v (%s)", err, out)
}
return nil
}
// testSessionSurvivesRestart is plan C.4 negative/regression check 8 (moved
// here from the manual B.1 stand check, plan item C.4's closing note): the
// login session (SQLite-backed, plan B.1) must survive `docker restart`.
//
// The stop half of the restart stops Postfix as "postfix stop" does: before,
// supervisord's TERM reached the wrapper's shell alone and master went down
// with the container's SIGKILL (assumptions C-P42; supervision_test.go).
func testSessionSurvivesRestart(t *testing.T, sc *scenario) {
stops := postfixStops(t)
if err := h.restartSelfpost(); err != nil {
t.Fatalf("restart selfpost: %v", err)
}
if got := postfixStops(t); got != stops+1 {
t.Fatalf("mail.log has %d lines of postfix stop after the container's restart, want %d\n%s", got, stops+1, mailLogTail(h, "postfix"))
}
if err := waitFor("panel HTTP to answer after restart", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
_, err := sc.panel.status()
return err == nil, err
}); err != nil {
t.Fatal(err)
}
// The panel and Postfix come up independently after a restart (see
// waitForSMTPSReady) — the next subtest sends mail, so make sure smtpd is
// actually listening before this one returns.
if err := waitForSMTPSReady(30 * time.Second); err != nil {
t.Fatal(err)
}
resp, err := sc.panel.status()
if err != nil {
t.Fatalf("GET /overview after restart: %v", err)
}
if resp.Request.URL.Path != "/overview" {
t.Fatalf("session did not survive restart: landed on %s instead of /overview", resp.Request.URL.Path)
}
}
// testLevel1RateLimit is plan C.4 negative check 4: the native Postfix anvil
// backstop (smtpd_client_message_rate_limit, guide § Rate limiting), set by
// the override to RATE_LIMIT_MESSAGES_PER_IP=50, rejects once exceeded. It
// uses a dedicated application with no level-2 limit of its own, and retries
// well past that count, so the result is unambiguous regardless of how much of
// the shared per-IP budget earlier subtests already spent (they stay well
// under 50 between them).
//
// Only anvil's own reply counts, and only after at least one message of the
// loop went through: the case once passed on "limit tripped on attempt 1/60"
// when the connection was reset because Postfix was still starting - and,
// held to anvil's reply, it turned out the first message had always been
// refused for another reason: the new login was not yet in the sender map an
// smtpd from before the panel's reload held (waitForSenderMap). (An incident
// on the stand, date not recorded.) Anvil's reply: Postfix 3.10.13,
// src/smtpd/smtpd.c, mail_cmd(): "450 4.7.1 Error: too much mail from %s".
func testLevel1RateLimit(t *testing.T, sc *scenario) {
login, password, err := sc.panel.addApplication(sc.domainID, "l1app", "wildcard", "")
if err != nil {
t.Fatalf("add application: %v", err)
}
send := func() sendResult {
return attemptSend(sendAttempt{
authLogin: login, authPassword: password,
from: "l1@" + senderDomain, to: recipient,
subject: "e2e " + uniqueToken("l1"), body: "rate limit probe",
})
}
if err := waitForSenderMap(login, password, "l1@"+senderDomain); err != nil {
t.Fatal(err)
}
if res := send(); !res.ok() {
t.Fatalf("the first message of %s, before any limit could trip: %v", login, res.firstErr())
}
const maxAttempts = 60
for i := 1; i < maxAttempts; i++ {
res := send()
if res.ok() {
continue
}
if err := res.refusedAt("MAIL", 450, "4.7.1 Error: too much mail from"); err != nil {
t.Fatalf("attempt %d/%d: %v", i+1, maxAttempts, err)
}
t.Logf("level-1 limit tripped on attempt %d/%d: %v", i+1, maxAttempts, res.firstErr())
return
}
t.Fatalf("level-1 rate limit (RATE_LIMIT_MESSAGES_PER_IP=50) never tripped after %d sends", maxAttempts)
}