test / test (push) Canceled after 0s
A message's From: line is held against the application that sends it, under the rule Postfix holds the envelope sender to. OpenDKIM chooses the key by that line, so until now an application could have mail signed as another domain of the server or as the server itself. The check (internal/milter/sender.go) - A role of the panel on a socket of its own (SENDER_CHECK_SOCKET), first in smtpd_milters and strict: default_action=tempfail. The journal milter keeps its socket and stays fail-open. postfix-wrapper waits for the new socket. - It reads no database: what an application may send as comes from Postfix's own sender_login_maps, what the server signs for from OpenDKIM's SigningTable. Both are read again when they change; one that cannot be read defers mail. - One From: field, one address, allowed by the envelope's lookup; then no other name of the SigningTable may appear anywhere in the raw line. Refused: 550 5.7.1 naming the line, reduced to printable ASCII. - A refusal is a send-log row with reason "from" and the header address (migration 0006), written by another goroutine and not waited for. The journal's refusals record their reason too (limit, client), and the notifier takes only a limit's. What it costs (docs/plans/from-header-binding.md, Statistics) - The role marks itself alive every 2 s; at start the gap since the mark is downtime, counted from Postfix's start if later and not at all when supervisord says Postfix is not running. - The log tailer counts the connections Postfix turned away: a warning that names the socket, then milter-reject "Service unavailable" from the same smtpd. Seen on the stand: the client is refused from CONNECT on and leaves at AUTH (454), so no MAIL line is ever written. - sender_check_days, a row per UTC day, kept a year. Pages - Health: the card "Sender check", the socket's row, and the cards layout - boxes numbered by importance in two stacks read in rows; the left stack polls, the three readings of the right one are swapped in by id. - Outbound log: a refused row says why in its tag (wrong From, wrong client, rate-limited); the message's page shows the From: line and what the application may send as. - Help: topic "Sender check", word for word the mockup's. Found by the stand, fixed here: the names for the second look came from the sender bindings, and a domain with a key and no application yet was not among them. e2e: negative_journal_milter_fail_open is gone - it proved that mail passes with the panel down, which is no longer so. In its place sender_check_holds_the_from_line (another domain's From:, the hostname's, a stranger's, an address off the list, another sender in the display name - refused; own addresses - delivered) and panel_down_defers_mail (4xx with the panel stopped, the container alive, mail again after, downtime and one deferred message on Health, the card warning). Test server, this tree before the last cosmetic edits (a fixture row, a fact's size, docs): go vet ok, go test 23 packages ok, make e2e ok (337 s). Locally after them: internal/web/view ok. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
195 lines
7.6 KiB
Go
195 lines
7.6 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)
|
|
}
|
|
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",
|
|
})
|
|
if second.ok() {
|
|
t.Fatal("second message exceeding the level-2 limit was accepted, want rejected")
|
|
}
|
|
if second.mailErr == nil {
|
|
t.Fatalf("expected the level-2 rejection at MAIL FROM, got: dial=%v auth=%v rcpt=%v data=%v",
|
|
second.dialErr, second.authErr, second.rcptErr, second.dataErr)
|
|
}
|
|
|
|
// 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).
|
|
if errs := mailTwiceOnOneConnection(login, password, from); errs[0] == nil || errs[1] == nil {
|
|
t.Fatalf("over the level-2 limit, two MAIL FROM on one connection were answered %v and %v, want both refused", errs[0], errs[1])
|
|
}
|
|
|
|
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",
|
|
})
|
|
if res.ok() {
|
|
t.Fatal("send with a sender the application does not own was accepted, want 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, so the error can land on either call here.
|
|
if res.mailErr == nil && res.rcptErr == nil {
|
|
t.Fatalf("expected the mismatch rejected at MAIL or RCPT, got: dial=%v auth=%v data=%v",
|
|
res.dialErr, res.authErr, res.dataErr)
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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 res.ok() {
|
|
t.Fatal("unauthenticated send was accepted, want rejected")
|
|
}
|
|
}
|
|
|
|
// testForeignRelayRejected is plan C.4 negative check 3: a direct proof that
|
|
// this is not an open relay — even the exact recipient the positive path just
|
|
// delivered to is refused without AUTH (reject_unauth_destination).
|
|
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 res.ok() {
|
|
t.Fatal("unauthenticated relay to an external destination was accepted, want reject_unauth_destination")
|
|
}
|
|
if res.rcptErr == nil && res.mailErr == nil {
|
|
t.Fatalf("expected rejection at MAIL or RCPT, got: dial=%v auth=%v data=%v", res.dialErr, res.authErr, res.dataErr)
|
|
}
|
|
}
|
|
|
|
// 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`.
|
|
func testSessionSurvivesRestart(t *testing.T, sc *scenario) {
|
|
if err := h.restartSelfpost(); err != nil {
|
|
t.Fatalf("restart selfpost: %v", err)
|
|
}
|
|
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).
|
|
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)
|
|
}
|
|
|
|
const maxAttempts = 60
|
|
for i := 0; i < maxAttempts; i++ {
|
|
res := attemptSend(sendAttempt{
|
|
authLogin: login, authPassword: password,
|
|
from: "l1@" + senderDomain, to: recipient,
|
|
subject: "e2e " + uniqueToken("l1"), body: "rate limit probe",
|
|
})
|
|
if !res.ok() {
|
|
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)
|
|
}
|