Security review of the DMARC report path (Fable, 2026-10-11), which ran for the first time in 2.0.0. No Critical or High finding. Fixed: - Medium: a report was bounded only as 8 MiB of XML - some 160 000 records or one name of megabytes, all of it rows and table cells. At most 5 000 records (more is ErrRejected, dropped), text fields cut to 255 bytes on a character boundary, a record's count capped. - Medium: the cap of 500 reports was for the whole server, so a stranger filing under one domain's address evicted every other domain's reports. PruneDMARCReports counts per domain. - Low: the pipe service ran up to default_process_limit (100) commands at once against the panel's database; maxproc is 1. - Low: the reason logged for a dropped message carried the sender's bytes (header line, zip entry name) as they came; cut to 200 bytes and reduced to printable ASCII, zip entry names quoted. - Low: SELFPOST_DATA_DIR / SELFPOST_DB_PATH with whitespace or "$" would reach the command through export_environment as another path; refused at start when DMARC ingest is on. Left as they are, told to the owner: the parse-failure count is a lifetime figure shown under "This week"; a report larger than the command reads is dropped as unreadable; nested multipart is not searched. The hub's Retention line follows the design commit before this one. security.md: the review, and the accepted-risk entry says what a stranger can and cannot do now. The roadmap no longer calls the review pending. go vet ./cmd/... ./internal/dmarc/ ./internal/store/: ok go test ./cmd/... ./internal/dmarc/ ./internal/store/ ./internal/web/...: ok make e2e on the test server (with the port-25 scenarios of the next commit): ok Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
252 lines
7.1 KiB
Go
252 lines
7.1 KiB
Go
package dmarc
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// ParsedReport is the structured form of a DMARC aggregate XML payload.
|
|
type ParsedReport struct {
|
|
Reporter string
|
|
ReportID string
|
|
ContactEmail string
|
|
PeriodBegin time.Time
|
|
PeriodEnd time.Time
|
|
Domain string
|
|
PolicyP string
|
|
PolicySP string
|
|
PolicyPct int
|
|
PolicyADKIM string
|
|
PolicyASPF string
|
|
Records []ParsedRecord
|
|
PassCount int
|
|
FailCount int
|
|
}
|
|
|
|
// ParsedRecord is one <record> row from the aggregate XML.
|
|
type ParsedRecord struct {
|
|
SourceIP string
|
|
Count int
|
|
Disposition string
|
|
SPFResult string
|
|
DKIMResult string
|
|
HeaderFrom string
|
|
}
|
|
|
|
type feedbackXML struct {
|
|
XMLName xml.Name `xml:"feedback"`
|
|
Metadata struct {
|
|
OrgName string `xml:"org_name"`
|
|
Email string `xml:"email"`
|
|
ReportID string `xml:"report_id"`
|
|
DateRange struct {
|
|
Begin int64 `xml:"begin"`
|
|
End int64 `xml:"end"`
|
|
} `xml:"date_range"`
|
|
} `xml:"report_metadata"`
|
|
Policy struct {
|
|
Domain string `xml:"domain"`
|
|
P string `xml:"p"`
|
|
SP string `xml:"sp"`
|
|
Pct int `xml:"pct"`
|
|
ADKIM string `xml:"adkim"`
|
|
ASPF string `xml:"aspf"`
|
|
} `xml:"policy_published"`
|
|
Records []struct {
|
|
Row struct {
|
|
SourceIP string `xml:"source_ip"`
|
|
Count int `xml:"count"`
|
|
Policy struct {
|
|
Disposition string `xml:"disposition"`
|
|
DKIM string `xml:"dkim"`
|
|
SPF string `xml:"spf"`
|
|
} `xml:"policy_evaluated"`
|
|
} `xml:"row"`
|
|
Identifiers struct {
|
|
HeaderFrom string `xml:"header_from"`
|
|
} `xml:"identifiers"`
|
|
} `xml:"record"`
|
|
}
|
|
|
|
// What a report may hold. The XML comes from anyone who knows a report address,
|
|
// and eight megabytes of it (maxAggregateXML) fit some 160 000 empty records or
|
|
// one name of eight megabytes - every one of them a row or a table cell later.
|
|
// A real report has tens of records, a few thousand from the largest
|
|
// receivers, and no field longer than a line.
|
|
const (
|
|
maxReportRecords = 5000
|
|
maxFieldLen = 255
|
|
maxRecordCount = 1_000_000_000
|
|
)
|
|
|
|
// field trims a text value of the report and cuts it to maxFieldLen bytes, on
|
|
// a character boundary.
|
|
func field(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if len(s) <= maxFieldLen {
|
|
return s
|
|
}
|
|
s = s[:maxFieldLen]
|
|
for len(s) > 0 && !utf8.ValidString(s) {
|
|
s = s[:len(s)-1]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ParseAggregate decodes gzip- or zip-compressed, or raw, DMARC aggregate XML.
|
|
func ParseAggregate(raw []byte) (ParsedReport, error) {
|
|
data, err := decompress(raw)
|
|
if err != nil {
|
|
return ParsedReport{}, err
|
|
}
|
|
var doc feedbackXML
|
|
if err := xml.Unmarshal(data, &doc); err != nil {
|
|
return ParsedReport{}, fmt.Errorf("dmarc xml: %w", err)
|
|
}
|
|
if doc.Metadata.ReportID == "" || doc.Policy.Domain == "" {
|
|
return ParsedReport{}, fmt.Errorf("dmarc xml: missing report_id or domain")
|
|
}
|
|
if len(doc.Records) > maxReportRecords {
|
|
return ParsedReport{}, fmt.Errorf("dmarc xml: %d records, more than the %d a report may hold", len(doc.Records), maxReportRecords)
|
|
}
|
|
// Cut, not refused: a domain name is at most 253 bytes, so a longer one
|
|
// names nothing this relay sends for and is turned away as such.
|
|
out := ParsedReport{
|
|
Reporter: field(doc.Metadata.OrgName),
|
|
ReportID: field(doc.Metadata.ReportID),
|
|
ContactEmail: field(doc.Metadata.Email),
|
|
PeriodBegin: time.Unix(doc.Metadata.DateRange.Begin, 0).UTC(),
|
|
PeriodEnd: time.Unix(doc.Metadata.DateRange.End, 0).UTC(),
|
|
Domain: strings.ToLower(field(doc.Policy.Domain)),
|
|
PolicyP: field(doc.Policy.P),
|
|
PolicySP: field(doc.Policy.SP),
|
|
PolicyPct: doc.Policy.Pct,
|
|
PolicyADKIM: field(doc.Policy.ADKIM),
|
|
PolicyASPF: field(doc.Policy.ASPF),
|
|
}
|
|
if out.PolicyPct == 0 {
|
|
out.PolicyPct = 100
|
|
}
|
|
for _, rec := range doc.Records {
|
|
row := ParsedRecord{
|
|
SourceIP: field(rec.Row.SourceIP),
|
|
Count: rec.Row.Count,
|
|
Disposition: field(rec.Row.Policy.Disposition),
|
|
SPFResult: field(rec.Row.Policy.SPF),
|
|
DKIMResult: field(rec.Row.Policy.DKIM),
|
|
HeaderFrom: field(rec.Identifiers.HeaderFrom),
|
|
}
|
|
if row.Count <= 0 {
|
|
row.Count = 1
|
|
}
|
|
// Bounded so that the sums of a report cannot wrap around.
|
|
if row.Count > maxRecordCount {
|
|
row.Count = maxRecordCount
|
|
}
|
|
if dmarcRecordPasses(row) {
|
|
out.PassCount += row.Count
|
|
} else {
|
|
out.FailCount += row.Count
|
|
}
|
|
out.Records = append(out.Records, row)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func dmarcRecordPasses(r ParsedRecord) bool {
|
|
return strings.EqualFold(r.SPFResult, "pass") || strings.EqualFold(r.DKIMResult, "pass")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dmarc zip open %q: %w", field(f.Name), err)
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(rc, maxAggregateXML))
|
|
rc.Close()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dmarc zip read %q: %w", field(f.Name), err)
|
|
}
|
|
return data, nil
|
|
}
|
|
return nil, fmt.Errorf("dmarc zip: no xml entry")
|
|
}
|
|
|
|
// TightenPolicyHint summarises whether raising p= looks reasonable.
|
|
func TightenPolicyHint(pass, fail int, sources []SourceHint) string {
|
|
total := pass + fail
|
|
if total == 0 {
|
|
return "No messages in the reporting window yet."
|
|
}
|
|
failPct := float64(fail) * 100 / float64(total)
|
|
if fail > 0 {
|
|
for _, s := range sources {
|
|
if s.FailCount > 0 && !s.ThisRelay {
|
|
return "A third-party source is not aligned. Do not tighten p= until that sender is fixed or removed."
|
|
}
|
|
}
|
|
}
|
|
if failPct <= 2 && pass > 0 {
|
|
return "Alignment looks strong. Tightening p= may be reasonable."
|
|
}
|
|
if fail > 0 {
|
|
return "Some failures remain. Review sources before tightening p=."
|
|
}
|
|
return "Alignment looks clean for this window."
|
|
}
|
|
|
|
// SourceHint is a panel-facing rollup row with relay detection.
|
|
type SourceHint struct {
|
|
SourceIP string
|
|
PassCount int
|
|
FailCount int
|
|
ThisRelay bool
|
|
}
|