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