release: 1.9.2: Review pass: port-25 milters, DMARC validation, rate-limit fix

Review pass over `1.4.0`…`1.9.1`: port 25 no longer shares the submission milter chain, DMARC reports are validated before they are stored, and the auto rate limit is correct for a non-default level-1 window. Schema migration `0010` (index only). Upgrading is a tag bump.
Added:
  - SQLite migration `0010_send_log_app_login_index.sql` —
  - DMARC aggregate reports are accepted as **zip** as well as gzip or plain XML
  - e2e: `checkPort25ConfigCombinations` regenerates the Postfix configuration
Changed:
  - README hero line and a **Positioning** block in `docs/product.md` record the
Fixed:
  - The setup account's password is hashed at bcrypt cost `12` like every other
  - Login/setup rate limiter: protecting blocked buckets from eviction is now
  - `postqueue -p` output is bounded to 4 MiB (the timeout alone left the size of
  - [schema-migrations.md](docs/schema-migrations.md): the schema section was
  - postqueue reader is now time-bounded: `postqueue -p` runs under a 5-second
  … (11 more in CHANGELOG)
Changed:
  - [roadmap.md](docs/roadmap.md) now carries everything the 2026-08-19 code
  - Extracted `internal/configsafe` — the empty-value and forbidden-character
  - Build, deploy and migration comments no longer cite the archived
  - Dropped the `parseDomainRateLimitForm` / `parseAppRateLimitForm` pass-through
  - Extracted shared helpers: `internal/atomicfile` (atomic temp-write+rename) and
  … (7 more in CHANGELOG)
Removed:
  - Unused legacy asset `docs/assets/icon.png` (superseded by the four brand SVGs
  - Plan documents for already-shipped features: `domain-stats-auto-ratelimit`,

Full detail: CHANGELOG.md (1.9.2).
This commit is contained in:
mix
2026-09-22 15:24:53 +03:00
parent 967d3d95de
commit 7ef7f75246
179 changed files with 11558 additions and 1738 deletions
+18
View File
@@ -29,3 +29,21 @@ func IsHostedOnHostname(addr, hostname string) bool {
at := strings.LastIndex(addr, "@")
return addr[at+1:] == hostname
}
// TaggedDomain returns the domain a hosted report address is tagged for
// (dmarc-reports+<domain>@<hostname> → <domain>), or "" when addr carries no
// such tag. Used to check a report's claimed domain against the address it
// actually arrived at.
func TaggedDomain(addr string) string {
addr = strings.ToLower(strings.TrimSpace(addr))
at := strings.LastIndex(addr, "@")
if at <= 0 {
return ""
}
local := addr[:at]
plus := strings.Index(local, "+")
if plus < 0 || local[:plus] != DefaultLocalPart {
return ""
}
return local[plus+1:]
}
+34 -1
View File
@@ -18,6 +18,15 @@ import (
// IngestMessage parses a raw RFC 5322 message from r and stores any DMARC
// aggregate attachment it finds.
//
// The report XML is unauthenticated input: anyone who can reach an allow-listed
// rua= address can post one, and the address form is predictable. So the domain
// the report claims is checked twice before anything is stored — it must be a
// domain this relay actually sends for, and, when the report arrived at a
// per-domain hosted address (dmarc-reports+<domain>@<hostname>), it must be the
// domain that address is tagged for. Without this a stranger could file reports
// under someone else's domain, skew the panel's alignment figures, and push
// genuine reports out through the retention cap.
func IngestMessage(st *store.Store, r io.Reader, recipient string, receivedAt time.Time) error {
raw, err := io.ReadAll(io.LimitReader(r, 12<<20))
if err != nil {
@@ -31,6 +40,10 @@ func IngestMessage(st *store.Store, r io.Reader, recipient string, receivedAt ti
if err != nil {
return err
}
recipient = strings.ToLower(strings.TrimSpace(recipient))
if err := checkReportDomain(st, parsed.Domain, recipient); err != nil {
return err
}
rep := store.DMARCReport{
Domain: parsed.Domain,
Reporter: parsed.Reporter,
@@ -46,7 +59,7 @@ func IngestMessage(st *store.Store, r io.Reader, recipient string, receivedAt ti
PolicyASPF: parsed.PolicyASPF,
PassCount: parsed.PassCount,
FailCount: parsed.FailCount,
Recipient: strings.ToLower(strings.TrimSpace(recipient)),
Recipient: recipient,
}
for _, rec := range parsed.Records {
rep.Records = append(rep.Records, store.DMARCReportRecord{
@@ -64,6 +77,26 @@ func IngestMessage(st *store.Store, r io.Reader, recipient string, receivedAt ti
return st.PruneDMARCReports()
}
// checkReportDomain refuses a report whose policy domain is not one of this
// relay's sending domains, or that contradicts the +tag of the address it was
// delivered to.
func checkReportDomain(st *store.Store, domain, recipient string) error {
if domain == "" {
return fmt.Errorf("dmarc report: no policy domain")
}
if tagged := TaggedDomain(recipient); tagged != "" && tagged != domain {
return fmt.Errorf("dmarc report: domain %q does not match the address it was sent to (%s)", domain, recipient)
}
ok, err := st.DomainExists(domain)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("dmarc report: %q is not a domain configured on this relay", domain)
}
return nil
}
func extractAggregatePayload(raw []byte) ([]byte, error) {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
+51 -11
View File
@@ -1,6 +1,7 @@
package dmarc
import (
"archive/zip"
"bytes"
"compress/gzip"
"encoding/xml"
@@ -73,9 +74,9 @@ type feedbackXML struct {
} `xml:"record"`
}
// ParseAggregate decodes gzip-compressed or raw DMARC aggregate XML.
// ParseAggregate decodes gzip- or zip-compressed, or raw, DMARC aggregate XML.
func ParseAggregate(raw []byte) (ParsedReport, error) {
data, err := maybeGunzip(raw)
data, err := decompress(raw)
if err != nil {
return ParsedReport{}, err
}
@@ -128,20 +129,59 @@ func dmarcRecordPasses(r ParsedRecord) bool {
return strings.EqualFold(r.SPFResult, "pass") || strings.EqualFold(r.DKIMResult, "pass")
}
func maybeGunzip(raw []byte) ([]byte, error) {
if len(raw) >= 2 && raw[0] == 0x1f && raw[1] == 0x8b {
zr, err := gzip.NewReader(bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("dmarc gzip: %w", err)
// maxAggregateXML bounds the decompressed payload, so a small archive cannot
// expand into an unbounded allocation.
const maxAggregateXML = 8 << 20
// decompress unwraps the container the reporter used. Both are seen in the
// wild: most senders gzip the XML, some (notably several Microsoft and Yahoo
// endpoints) ship it as a single-entry zip.
func decompress(raw []byte) ([]byte, error) {
switch {
case len(raw) >= 2 && raw[0] == 0x1f && raw[1] == 0x8b:
return gunzip(raw)
case len(raw) >= 4 && raw[0] == 'P' && raw[1] == 'K' && raw[2] == 0x03 && raw[3] == 0x04:
return unzip(raw)
}
return raw, nil
}
func gunzip(raw []byte) ([]byte, error) {
zr, err := gzip.NewReader(bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("dmarc gzip: %w", err)
}
defer zr.Close()
data, err := io.ReadAll(io.LimitReader(zr, maxAggregateXML))
if err != nil {
return nil, fmt.Errorf("dmarc gzip read: %w", err)
}
return data, nil
}
// unzip returns the first .xml entry of a zip archive. Directories and any
// other members are skipped rather than trusted.
func unzip(raw []byte) ([]byte, error) {
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
return nil, fmt.Errorf("dmarc zip: %w", err)
}
for _, f := range zr.File {
if f.FileInfo().IsDir() || !strings.HasSuffix(strings.ToLower(f.Name), ".xml") {
continue
}
defer zr.Close()
data, err := io.ReadAll(io.LimitReader(zr, 8<<20))
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("dmarc gzip read: %w", err)
return nil, fmt.Errorf("dmarc zip open %s: %w", f.Name, err)
}
data, err := io.ReadAll(io.LimitReader(rc, maxAggregateXML))
rc.Close()
if err != nil {
return nil, fmt.Errorf("dmarc zip read %s: %w", f.Name, err)
}
return data, nil
}
return raw, nil
return nil, fmt.Errorf("dmarc zip: no xml entry")
}
// TightenPolicyHint summarises whether raising p= looks reasonable.
+100
View File
@@ -1,6 +1,7 @@
package dmarc
import (
"archive/zip"
"bytes"
"compress/gzip"
"testing"
@@ -76,6 +77,9 @@ func TestIngestMessageStoresReport(t *testing.T) {
t.Fatal(err)
}
defer st.Close()
if _, err := st.AddDomain("example.com", "selfpost"); err != nil {
t.Fatal(err)
}
var gz bytes.Buffer
zw := gzip.NewWriter(&gz)
@@ -114,3 +118,99 @@ func TestHostedReportAddress(t *testing.T) {
t.Fatalf("got %q want %q", got, want)
}
}
func TestIngestMessageRejectsUnknownDomain(t *testing.T) {
st, err := store.Open(t.TempDir() + "/test.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := IngestMessage(st, bytes.NewBufferString(gzipMessage(t, sampleXML)), "dmarc-reports@mail.example.com", time.Now().UTC()); err == nil {
t.Fatal("report for an unconfigured domain was accepted")
}
list, err := st.ListDMARCReports(nil, 10)
if err != nil {
t.Fatal(err)
}
if len(list) != 0 {
t.Fatalf("stored %d report(s) for an unconfigured domain", len(list))
}
}
// A report claiming example.com must not be accepted at the address tagged for
// another domain, even when both domains are configured here.
func TestIngestMessageRejectsTagMismatch(t *testing.T) {
st, err := store.Open(t.TempDir() + "/test.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
if _, err := st.AddDomain("example.com", "selfpost"); err != nil {
t.Fatal(err)
}
if _, err := st.AddDomain("other.example", "selfpost"); err != nil {
t.Fatal(err)
}
err = IngestMessage(st, bytes.NewBufferString(gzipMessage(t, sampleXML)),
"dmarc-reports+other.example@mail.example.com", time.Now().UTC())
if err == nil {
t.Fatal("report was accepted at an address tagged for another domain")
}
}
func TestTaggedDomain(t *testing.T) {
cases := map[string]string{
"dmarc-reports+example.com@mail.example.com": "example.com",
"dmarc-reports@mail.example.com": "",
"postmaster+example.com@mail.example.com": "",
"not-an-address": "",
}
for addr, want := range cases {
if got := TaggedDomain(addr); got != want {
t.Errorf("TaggedDomain(%q) = %q, want %q", addr, got, want)
}
}
}
func TestParseAggregateFromZip(t *testing.T) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, err := zw.Create("report.xml")
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte(sampleXML)); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
got, err := ParseAggregate(buf.Bytes())
if err != nil {
t.Fatalf("ParseAggregate(zip): %v", err)
}
if got.Domain != "example.com" || got.PassCount != 10 {
t.Fatalf("zip report = %+v", got)
}
}
// gzipMessage wraps xml in the single-part gzip message the ingest pipe sees.
func gzipMessage(t *testing.T, xml string) string {
t.Helper()
var gz bytes.Buffer
zw := gzip.NewWriter(&gz)
if _, err := zw.Write([]byte(xml)); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return "From: noreply@google.com\r\n" +
"To: dmarc-reports@mail.example.com\r\n" +
"Subject: Report\r\n" +
"Content-Type: application/gzip; name=\"report.xml.gz\"\r\n" +
"\r\n" + gz.String()
}