fix: port-25 milter chain, DMARC report validation, auto rate-limit window
test / test (push) Has been cancelled
test / test (push) Has been cancelled
Review pass over the 1.4.0-1.9.1 work. Port 25 no longer inherits main.cf's submission milter chain: smtpd_milters is always overridden on smtp/inet, to the antispam milter or to nothing. Without that, OpenDKIM (strict, tempfail) gated relayed mail during a signing outage, and the journal-milter filed inbound mail into the outbound send log under the sender's domain -- so Deliveries showed other people's mail and a forged From: consumed that domain's level-2 budget. architecture.md already described the intended behaviour; the code now matches. DMARC reports are checked before they are stored: the policy domain must be a configured sending domain, and must match the +tag of a per-domain hosted address. The rua address form is predictable, so anyone able to reach it could file reports under any domain and evict genuine ones through the retention cap. Zip attachments are now decompressed as well as gzip. The auto level-2 ceiling is scaled to the level-1 window before the multiplier is applied; any window other than 3600s shifted the allowance by window/3600. Migration 0010 indexes send_log(app_login, created_at) -- both the per-MAIL FROM rate-limit count and the per-application statistics were scanning the created_at range. e2e now regenerates the Postfix config in-container for all four port-25 flag combinations and requires postfix check to pass; the 1.9.1 crash-loop shipped in 1.4.0 and survived three releases because only the flags-off path was exercised. Migration 0009 stays as shipped -- its conversion branch only fires for a database carrying application-scope allowed_ips from before 1.9.0. The schema reference notes that the old list was permissive and the new column is restrictive; the guide documents the allow-list as a sending control on a fail-open path, not an authentication boundary. Also: gofmt across cmd/ and internal/, unused duplicate validators dropped from internal/web/validate, export no longer swallows store errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,9 +14,9 @@ import (
|
||||
|
||||
type dmarcListRow struct {
|
||||
store.DMARCReportSummary
|
||||
DomainID int64
|
||||
ReceivedLabel string
|
||||
PeriodLabel string
|
||||
DomainID int64
|
||||
ReceivedLabel string
|
||||
PeriodLabel string
|
||||
}
|
||||
|
||||
func (h *Handlers) requireDMARC(w http.ResponseWriter, r *http.Request) (auth.Principal, bool) {
|
||||
|
||||
@@ -108,12 +108,12 @@ func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) {
|
||||
row.Subject = mailhdr.DecodeSubject(row.Subject)
|
||||
logRows, logNote := h.deliveryLog(row)
|
||||
h.view.Render(w, http.StatusOK, "delivery", map[string]any{
|
||||
"Title": "SelfPost — delivery",
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "deliveries",
|
||||
"IsGlobal": p.IsGlobal(),
|
||||
"SendLogRetentionDays": h.sendLogRetentionDays(),
|
||||
"Row": row,
|
||||
"Title": "SelfPost — delivery",
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "deliveries",
|
||||
"IsGlobal": p.IsGlobal(),
|
||||
"SendLogRetentionDays": h.sendLogRetentionDays(),
|
||||
"Row": row,
|
||||
// The status in the panel's own badge vocabulary, so the headline reads
|
||||
// the same way as every other health signal in the panel.
|
||||
"Level": deliveryLevel(row.Status),
|
||||
|
||||
@@ -82,7 +82,7 @@ func TestParseAppAuthIPsForm(t *testing.T) {
|
||||
|
||||
restrict, ips, err := parseAppAuthIPsForm(form(url.Values{
|
||||
"auth_ip_restrict": {"1"},
|
||||
"auth_allowed_ips": {"203.0.113.10"},
|
||||
"auth_allowed_ips": {"203.0.113.10"},
|
||||
}))
|
||||
if err != nil || !restrict || len(ips) != 1 {
|
||||
t.Fatalf("enabled with IP = restrict=%v ips=%v err=%v", restrict, ips, err)
|
||||
@@ -107,15 +107,15 @@ func TestParseDomainRateLimitFormAuto(t *testing.T) {
|
||||
return r
|
||||
}
|
||||
in, err := parseDomainRateLimitForm(form(url.Values{
|
||||
"mode": {"auto"},
|
||||
"auto_multiplier": {"2.5"},
|
||||
"mode": {"auto"},
|
||||
"auto_multiplier": {"2.5"},
|
||||
}), 100)
|
||||
if err != nil || in.mode != "auto" || in.autoMultiplier != 2.5 {
|
||||
t.Fatalf("auto domain = %+v err=%v", in, err)
|
||||
}
|
||||
_, err = parseDomainRateLimitForm(form(url.Values{
|
||||
"mode": {"auto"},
|
||||
"auto_multiplier": {"10"},
|
||||
"mode": {"auto"},
|
||||
"auto_multiplier": {"10"},
|
||||
}), 100)
|
||||
if err == nil {
|
||||
t.Fatal("multiplier out of range should fail")
|
||||
|
||||
@@ -3,8 +3,6 @@ package validate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
@@ -148,104 +146,3 @@ func Email(addr string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const maxHostLen = 253
|
||||
|
||||
// NormalizeHost trims, lower-cases, and strips wrapping IPv6 brackets so the
|
||||
// stored value is a bare hostname or IP, safe to wrap again when writing maps.
|
||||
func NormalizeHost(host string) string {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||
host = host[1 : len(host)-1]
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// Host enforces a whitelist for an upstream hostname or IP (security.md): a
|
||||
// dotted domain, a single DNS label (LAN names), or an IPv4/IPv6 address.
|
||||
func Host(host string) error {
|
||||
if host == "" {
|
||||
return fmt.Errorf("host is required")
|
||||
}
|
||||
if len(host) > maxHostLen {
|
||||
return fmt.Errorf("host must be at most %d characters", maxHostLen)
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return nil
|
||||
}
|
||||
labels := strings.Split(host, ".")
|
||||
for _, label := range labels {
|
||||
if err := domainLabel(label); err != nil {
|
||||
return fmt.Errorf("host is invalid: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Port checks a TCP port number parsed from form input.
|
||||
func Port(raw string) (int, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("port is required")
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 1 || n > 65535 {
|
||||
return 0, fmt.Errorf("port must be between 1 and 65535")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// TLSMode checks a Postfix smtp_tls_policy_maps level.
|
||||
func TLSMode(mode string) error {
|
||||
switch mode {
|
||||
case "may", "encrypt", "none":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid TLS mode")
|
||||
}
|
||||
}
|
||||
|
||||
// RecipientMode checks an inbound-domain recipient policy.
|
||||
func RecipientMode(mode string) error {
|
||||
switch mode {
|
||||
case "list", "any":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid recipient mode")
|
||||
}
|
||||
}
|
||||
|
||||
// MailboxInDomain checks that addr is a conservative mailbox on domain
|
||||
// (security.md). domain must already be normalised.
|
||||
func MailboxInDomain(addr, domain string) error {
|
||||
at := strings.LastIndexByte(addr, '@')
|
||||
if at <= 0 || at >= len(addr)-1 {
|
||||
return fmt.Errorf("%q is not a valid email address", addr)
|
||||
}
|
||||
local, host := addr[:at], addr[at+1:]
|
||||
if host != domain {
|
||||
return fmt.Errorf("%q does not belong to domain %s", addr, domain)
|
||||
}
|
||||
if err := mailboxLocalPart(local); err != nil {
|
||||
return fmt.Errorf("%q: %w", addr, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mailboxLocalPart(local string) error {
|
||||
if local == "" {
|
||||
return fmt.Errorf("missing the part before '@'")
|
||||
}
|
||||
if local[0] == '.' || local[len(local)-1] == '.' {
|
||||
return fmt.Errorf("local part must not start or end with '.'")
|
||||
}
|
||||
for i := 0; i < len(local); i++ {
|
||||
c := local[i]
|
||||
lower := c >= 'a' && c <= 'z'
|
||||
digit := c >= '0' && c <= '9'
|
||||
if !lower && !digit && c != '.' && c != '-' && c != '_' && c != '+' {
|
||||
return fmt.Errorf("local part may contain only lower-case letters, digits, '.', '-', '_' and '+'")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -65,69 +65,3 @@ func TestValidateDomainLongLabelRejected(t *testing.T) {
|
||||
t.Error("expected error for over-long label")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostValid(t *testing.T) {
|
||||
valid := []string{
|
||||
"10.0.0.8",
|
||||
"192.0.2.20",
|
||||
"2001:db8::1",
|
||||
"mail.internal.example",
|
||||
"mx1",
|
||||
"mail-1.lan",
|
||||
}
|
||||
for _, h := range valid {
|
||||
if err := Host(h); err != nil {
|
||||
t.Errorf("Host(%q) unexpected error: %v", h, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostInvalid(t *testing.T) {
|
||||
invalid := []string{
|
||||
"",
|
||||
"exa mple",
|
||||
"host/name",
|
||||
"host;rm",
|
||||
"-bad",
|
||||
"bad-",
|
||||
"host\nname",
|
||||
}
|
||||
for _, h := range invalid {
|
||||
if err := Host(h); err == nil {
|
||||
t.Errorf("Host(%q) = nil, want error", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHostStripsIPv6Brackets(t *testing.T) {
|
||||
if got := NormalizeHost(" [2001:DB8::1] "); got != "2001:db8::1" {
|
||||
t.Errorf("NormalizeHost IPv6 = %q", got)
|
||||
}
|
||||
if got := NormalizeHost("Mail.Example.COM"); got != "mail.example.com" {
|
||||
t.Errorf("NormalizeHost hostname = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPort(t *testing.T) {
|
||||
n, err := Port("25")
|
||||
if err != nil || n != 25 {
|
||||
t.Fatalf("Port(25) = %d, %v", n, err)
|
||||
}
|
||||
for _, raw := range []string{"", "0", "65536", "abc", "-1"} {
|
||||
if _, err := Port(raw); err == nil {
|
||||
t.Errorf("Port(%q) = nil, want error", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailboxInDomain(t *testing.T) {
|
||||
if err := MailboxInDomain("staff@lists.example.com", "lists.example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := MailboxInDomain("staff@other.com", "lists.example.com"); err == nil {
|
||||
t.Fatal("expected domain mismatch error")
|
||||
}
|
||||
if err := MailboxInDomain("bad addr@lists.example.com", "lists.example.com"); err == nil {
|
||||
t.Fatal("expected local-part error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,8 +330,11 @@
|
||||
<div class="check-col">
|
||||
<p class="check-col-title">Client IP allow-list
|
||||
{{if .AuthIPRestrict}}<span class="st st-ok">active</span>{{else}}<span class="st st-unknown">off</span>{{end}}</p>
|
||||
<p class="muted">When enabled, only these addresses may authenticate
|
||||
and submit mail as this application. When off, any client IP is allowed.</p>
|
||||
<p class="muted">When enabled, only these addresses may submit mail as
|
||||
this application; a login from anywhere else is refused with a 4xx at
|
||||
<code>MAIL FROM</code>. When off, any client IP is allowed. Like the
|
||||
level-2 rate limit this runs in the journal-milter and is fail-open —
|
||||
it is a sending control, not an authentication boundary.</p>
|
||||
<form method="post" action="/applications/{{.ID}}/authips">
|
||||
<label class="check-row">
|
||||
<input type="checkbox" name="auth_ip_restrict" value="1"
|
||||
|
||||
@@ -131,8 +131,11 @@
|
||||
shown once.</p>
|
||||
<p>Address mode is which From addresses this application may use: any
|
||||
address of the domain, or a fixed list. Client IP allow-list, when enabled,
|
||||
restricts which addresses may authenticate as this application; when off,
|
||||
any client IP is allowed. The level-2 rate limit <em>overrides</em> the
|
||||
restricts which client addresses may <em>submit</em> as this application;
|
||||
when off, any client IP is allowed. It is checked in the journal-milter at
|
||||
<code>MAIL FROM</code>, alongside the level-2 limit and fail-open like it,
|
||||
so it narrows sending rather than blocking the SASL login itself. The
|
||||
level-2 rate limit <em>overrides</em> the
|
||||
domain limit for that application — you can set a ceiling above or below
|
||||
the domain (still ≤ level 1). Independent of the client IP allow-list.</p>
|
||||
{{template "help_more" true}}
|
||||
|
||||
+16
-16
@@ -9,9 +9,9 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/app"
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/inbound"
|
||||
"github.com/mixeme/selfpost/internal/legal"
|
||||
@@ -118,21 +118,21 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc
|
||||
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
|
||||
}, v, setupTokenPath)
|
||||
h := handlers.New(st, domains, apps, inboundSvc, dmarcSvc, handlers.Config{
|
||||
Hostname: cfg.Hostname,
|
||||
SubmissionEnabled: cfg.SubmissionEnabled,
|
||||
MailLogPath: cfg.MailLogPath,
|
||||
DataDir: cfg.DataDir,
|
||||
DBPath: cfg.DBPath,
|
||||
DeployRoot: cfg.DeployRoot,
|
||||
Version: cfg.Version,
|
||||
TLSCertFile: cfg.TLSCertFile,
|
||||
OpenDKIMSocket: cfg.OpenDKIMSocket,
|
||||
JournalSocket: cfg.JournalSocket,
|
||||
RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP,
|
||||
RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
|
||||
RetryPolicy: cfg.RetryPolicy,
|
||||
InboundEnabled: cfg.InboundEnabled,
|
||||
DMARCEnabled: cfg.DMARCEnabled,
|
||||
Hostname: cfg.Hostname,
|
||||
SubmissionEnabled: cfg.SubmissionEnabled,
|
||||
MailLogPath: cfg.MailLogPath,
|
||||
DataDir: cfg.DataDir,
|
||||
DBPath: cfg.DBPath,
|
||||
DeployRoot: cfg.DeployRoot,
|
||||
Version: cfg.Version,
|
||||
TLSCertFile: cfg.TLSCertFile,
|
||||
OpenDKIMSocket: cfg.OpenDKIMSocket,
|
||||
JournalSocket: cfg.JournalSocket,
|
||||
RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP,
|
||||
RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
|
||||
RetryPolicy: cfg.RetryPolicy,
|
||||
InboundEnabled: cfg.InboundEnabled,
|
||||
DMARCEnabled: cfg.DMARCEnabled,
|
||||
SendLogRetentionEnvDefault: cfg.SendLogRetentionEnvDefault,
|
||||
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
|
||||
return &Server{cfg: cfg, auth: a, handlers: h}, nil
|
||||
|
||||
Reference in New Issue
Block a user