Files
selfpost/internal/dmarc/ingest.go
T
mixandClaude Opus 5.5 4dd46ea355 dmarc: a report is bounded, kept per domain, and ingested one at a time
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>
2026-10-11 17:40:31 +03:00

177 lines
5.4 KiB
Go

package dmarc
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"net/textproto"
"strings"
"time"
"github.com/mixeme/selfpost/internal/store"
)
// ErrRejected marks a message that is not a report this relay keeps and never
// will be: it cannot be read as an aggregate report, or it reports on a domain
// that is not sent from here, or it came to another domain's address. Sending
// it again changes nothing, so the caller drops it. Any other error is the
// store's and worth another try.
var ErrRejected = errors.New("dmarc report rejected")
func rejected(err error) error { return fmt.Errorf("%w: %w", ErrRejected, err) }
// 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 {
return fmt.Errorf("read message: %w", err)
}
payload, err := extractAggregatePayload(raw)
if err != nil {
return rejected(err)
}
parsed, err := ParseAggregate(payload)
if err != nil {
return rejected(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,
ReportID: parsed.ReportID,
PeriodBegin: parsed.PeriodBegin,
PeriodEnd: parsed.PeriodEnd,
ReceivedAt: receivedAt.UTC(),
ContactEmail: parsed.ContactEmail,
PolicyP: parsed.PolicyP,
PolicySP: parsed.PolicySP,
PolicyPct: parsed.PolicyPct,
PolicyADKIM: parsed.PolicyADKIM,
PolicyASPF: parsed.PolicyASPF,
PassCount: parsed.PassCount,
FailCount: parsed.FailCount,
Recipient: recipient,
}
for _, rec := range parsed.Records {
rep.Records = append(rep.Records, store.DMARCReportRecord{
SourceIP: rec.SourceIP,
Count: rec.Count,
Disposition: rec.Disposition,
SPFResult: rec.SPFResult,
DKIMResult: rec.DKIMResult,
HeaderFrom: rec.HeaderFrom,
})
}
if _, err := st.InsertDMARCReport(rep); err != nil {
return err
}
return st.PruneDMARCReports(rep.Domain)
}
// 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 rejected(fmt.Errorf("no policy domain"))
}
if tagged := TaggedDomain(recipient); tagged != "" && tagged != domain {
return rejected(fmt.Errorf("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 rejected(fmt.Errorf("%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 {
return nil, fmt.Errorf("parse message: %w", err)
}
ct := msg.Header.Get("Content-Type")
if ct == "" {
return io.ReadAll(io.LimitReader(msg.Body, 8<<20))
}
mediaType, params, err := mime.ParseMediaType(ct)
if err != nil {
return nil, fmt.Errorf("content-type: %w", err)
}
if strings.HasPrefix(mediaType, "multipart/") {
boundary := params["boundary"]
if boundary == "" {
return nil, fmt.Errorf("multipart without boundary")
}
mr := multipart.NewReader(msg.Body, boundary)
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("multipart: %w", err)
}
name := strings.ToLower(part.FileName())
if name == "" {
name = strings.ToLower(part.Header.Get("Content-Type"))
}
if !looksLikeAggregate(name) {
continue
}
data, err := readEncodedBody(textproto.MIMEHeader(part.Header), part)
if err != nil {
return nil, err
}
if len(data) > 0 {
return data, nil
}
}
return nil, fmt.Errorf("no aggregate attachment found")
}
return readEncodedBody(textproto.MIMEHeader(msg.Header), msg.Body)
}
func looksLikeAggregate(name string) bool {
name = strings.ToLower(name)
return strings.Contains(name, "xml") || strings.Contains(name, "gzip") || strings.Contains(name, "zip")
}
func readEncodedBody(hdr textproto.MIMEHeader, r io.Reader) ([]byte, error) {
encoding := strings.ToLower(hdr.Get("Content-Transfer-Encoding"))
var body io.Reader = r
switch encoding {
case "base64":
body = base64.NewDecoder(base64.StdEncoding, r)
case "quoted-printable":
body = quotedprintable.NewReader(r)
}
data, err := io.ReadAll(io.LimitReader(body, 8<<20))
if err != nil {
return nil, fmt.Errorf("read part body: %w", err)
}
return data, nil
}