Files
selfpost/internal/web/handlers/handlers_dmarc_pages_test.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

244 lines
12 KiB
Go

package handlers
import (
"net/http"
"testing"
"time"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/store"
)
// The DMARC pages, rendered from the real stores and the handlers. Their markup
// is held to the mockups by the guards in internal/web/view; what is checked
// here is that the handlers fill the typed pages from what is stored and that
// each page reaches only what its viewer may see.
// dmarcStand is a panel with report ingest on and one report stored for the
// stand's domain: a relay that passed and a source that failed both checks.
type dmarcStand struct {
appStand
reportID int64
}
func newDMARCStand(t *testing.T) dmarcStand {
t.Helper()
s := dmarcStand{appStand: newAppStand(t)}
s.h.cfg.DMARCEnabled, s.h.cfg.Hostname = true, "mail.example.org"
s.h.dmarc = dmarc.NewService(s.h.store, nil, "mail.example.org", true)
received := time.Now().UTC().Add(-2 * time.Hour)
id, err := s.h.store.InsertDMARCReport(store.DMARCReport{
Domain: "example.org", Reporter: "Outlook.com", ReportID: "5f1c9a0e7b2d4e61a3",
PeriodBegin: received.Add(-30 * time.Hour), PeriodEnd: received.Add(-6 * time.Hour), ReceivedAt: received,
ContactEmail: "noreply-dmarc@outlook.example", PolicyP: "none", PolicySP: "none", PolicyPct: 100,
PolicyADKIM: "r", PolicyASPF: "r", PassCount: 1903, FailCount: 7, Recipient: "dmarc-reports@mail.example.org",
Records: []store.DMARCReportRecord{
{SourceIP: "203.0.113.25", Count: 1903, Disposition: "none", SPFResult: "pass", DKIMResult: "pass", HeaderFrom: "example.org"},
{SourceIP: "198.51.100.44", Count: 7, Disposition: "none", SPFResult: "fail", DKIMResult: "fail", HeaderFrom: "example.org"},
},
})
if err != nil {
t.Fatalf("InsertDMARCReport: %v", err)
}
s.reportID = id
return s
}
func TestDMARCHubShowsIngestAndRecentReports(t *testing.T) {
s := newDMARCStand(t)
body := getBody(t, s.h.HandleDMARCList, "/outbound/dmarc")
has(t, "hub", body,
`<h1 class="title is-3">DMARC reports</h1>`, `<h2>Ingest</h2>`, `<span class="tag is-success is-light">ok</span>`,
`1 kept · 0 parse failures`, `Hosted addresses`, `dmarc-reports&#43;&lt;domain&gt;@mail.example.org`, `500 reports a domain or 90 days`,
`<h2>Recent reports</h2>`, `<a href="/outbound/dmarc/domains/1">example.org</a>`, `Outlook.com`,
"<td>1\u00a0903</td>", `<span class="tag is-warning is-light">7</span>`, `href="/outbound/dmarc/reports/`+idStr(s.reportID)+`">View</a>`)
lacks(t, "hub", body, `None yet`)
}
// Before the first report the page says how to get one rather than showing an
// empty table next to a healthy-looking stamp.
func TestDMARCHubBeforeTheFirstReport(t *testing.T) {
s := newAppStand(t)
s.h.cfg.DMARCEnabled, s.h.cfg.Hostname = true, "mail.example.org"
s.h.dmarc = dmarc.NewService(s.h.store, nil, "mail.example.org", true)
body := getBody(t, s.h.HandleDMARCList, "/outbound/dmarc")
has(t, "hub", body, `<span class="tag is-warning is-light">quiet</span>`, `None yet`, `<code>rua=</code>`,
`publish MX for <span class="sp-mono">mail.example.org</span>`, `No reports yet.`)
lacks(t, "hub", body, `<table`)
}
// A report names its reporter and its domain in its own words, and what a
// remote sender chose to write is text on the page, never markup.
func TestDMARCPagesEscapeWhatReportersWrote(t *testing.T) {
s := newDMARCStand(t)
id, err := s.h.store.InsertDMARCReport(store.DMARCReport{
Domain: "example.org", Reporter: `<script>alert(1)</script>`, ReportID: `"><img src=x>`,
PeriodBegin: time.Now().Add(-48 * time.Hour), PeriodEnd: time.Now().Add(-24 * time.Hour), ReceivedAt: time.Now(),
ContactEmail: `"><b>x</b>`, PolicyP: `<i>none</i>`, PassCount: 1, FailCount: 1,
Records: []store.DMARCReportRecord{{SourceIP: `<u>1.2.3.4</u>`, Count: 2, Disposition: `<s>none</s>`, SPFResult: `<em>x`, DKIMResult: "fail", HeaderFrom: `<p>`}},
})
if err != nil {
t.Fatal(err)
}
for name, body := range map[string]string{
"hub": getBody(t, s.h.HandleDMARCList, "/outbound/dmarc"),
"domain": pageOf(t, s.h.HandleDMARCDomain, "/outbound/dmarc/domains/1"),
"report": reportPage(t, s, id),
} {
lacks(t, name, body, `<script>alert`, `<img src=x>`, `<b>x</b>`, `<i>none</i>`, `<u>1.2.3.4`, `<s>none`, `<em>x`)
}
has(t, "report", reportPage(t, s, id), `&lt;script&gt;alert(1)&lt;/script&gt;`, `&lt;u&gt;1.2.3.4&lt;/u&gt;`)
}
func reportPage(t *testing.T, s dmarcStand, id int64) string {
t.Helper()
rec := send(s.h.HandleDMARCReport, &globalPrincipal, "GET", "/outbound/dmarc/reports/"+idStr(id), map[string]string{"id": idStr(id)}, nil)
if rec.Code != http.StatusOK {
t.Fatalf("report %d = %d:\n%s", id, rec.Code, rec.Body.String())
}
return rec.Body.String()
}
// The page of a domain with sources: the table of where its mail came from, the
// stamp that says how much of it passed and the advice that follows. A source
// that is this server is told apart from one that is not.
func TestDMARCDomainShowsSourcesAndWhatToDoAboutThem(t *testing.T) {
s := newDMARCStand(t)
s.h.dns = offlineDNS
body := pageOf(t, s.h.HandleDMARCDomain, "/outbound/dmarc/domains/1")
has(t, "domain", body,
`<h1 class="title is-3">example.org</h1>`, `<a href="/outbound/dmarc">DMARC reports</a> /`,
`class="sp-postmark sp-warn"`, `<b>99.6%</b>`, `PASS · 7 DAYS`,
"1\u00a0903 pass · 7 fail in the last 7 days. A third-party source is not aligned. Do not tighten <code>p=</code>",
`href="/outbound/domains/1"`, `<h2>Sources · 7 days</h2>`, `198.51.100.44`, `203.0.113.25`, `<h2>Reports</h2>`,
`href="/outbound/dmarc/reports/`+idStr(s.reportID)+`">View</a>`)
// 198.51.100.44 passed nothing, so its disposition is amber.
has(t, "domain", body, `<td>0</td><td>7</td><td><span class="tag is-warning is-light">none</span></td>`)
lacks(t, "domain", body, `this relay`)
}
func TestDMARCDomainWithoutReportsSaysSo(t *testing.T) {
s := newAppStand(t)
s.h.cfg.DMARCEnabled, s.h.cfg.Hostname = true, "mail.example.org"
s.h.dmarc = dmarc.NewService(s.h.store, nil, "mail.example.org", true)
body := pageOf(t, s.h.HandleDMARCDomain, "/outbound/dmarc/domains/1")
has(t, "domain", body, `No reports for this domain yet.`, `No mail from any source in this window.`,
`0 pass · 0 fail in the last 7 days. No messages in the reporting window yet.`)
lacks(t, "domain", body, `sp-postmark`, `<table`)
}
func TestDMARCReportShowsPolicyAndRecords(t *testing.T) {
s := newDMARCStand(t)
body := reportPage(t, s, s.reportID)
has(t, "report", body,
`<h1 class="title is-4">Outlook.com · `, `<a href="/outbound/dmarc">DMARC reports</a> /`, `<a href="/outbound/dmarc/domains/1">example.org</a> /`,
"<span class=\"tag is-success is-light\">1\u00a0903 pass</span>", `<span class="tag is-warning is-light">7 fail</span>`,
`5f1c9a0e7b2d4e61a3`, `noreply-dmarc@outlook.example`, ` UTC</dd>`,
`none / none / 100`, `<dd class="sp-mono">r / r</dd>`, `dmarc-reports@mail.example.org`,
`<h2>Records</h2>`, `Parsed from the aggregate XML`, `203.0.113.25`, `198.51.100.44`,
`<span class="tag is-danger is-light">fail</span>`, `<span class="tag is-success is-light">pass</span>`)
}
// DMARC is a feature of the deployment: with ingest off none of its pages
// exists, whoever asks.
func TestDMARCPagesAreAbsentWithIngestOff(t *testing.T) {
s := newDMARCStand(t)
s.h.cfg.DMARCEnabled = false
for name, h := range map[string]http.HandlerFunc{
"hub": s.h.HandleDMARCList, "domain": s.h.HandleDMARCDomain, "report": s.h.HandleDMARCReport,
} {
rec := send(h, &globalPrincipal, "GET", "/outbound/dmarc", map[string]string{"id": "1"}, nil)
if rec.Code != http.StatusNotFound {
t.Errorf("%s with ingest off = %d, want 404", name, rec.Code)
}
}
}
// A domain administrator reaches the reports of the domains assigned to them
// and nothing else: the hub lists only theirs and does not carry the ingest
// statistics of the whole instance, and another tenant's domain page and report
// are not there. A report that does not exist is the same 404 as one that is
// not theirs.
func TestDMARCPagesStayWithinTheViewersDomains(t *testing.T) {
s := newDMARCStand(t)
s.h.dns = offlineDNS
other, err := s.h.store.AddDomain("other.example.net", "mail")
if err != nil {
t.Fatal(err)
}
otherReport, err := s.h.store.InsertDMARCReport(store.DMARCReport{
Domain: other.Name, Reporter: "google.com", ReportID: "other", ReceivedAt: time.Now(),
PeriodBegin: time.Now().Add(-24 * time.Hour), PeriodEnd: time.Now(), PassCount: 9,
})
if err != nil {
t.Fatal(err)
}
ops := domainAdmin(t, s.h.store, "ops", s.d.ID)
// The hub is open to them: their own domain's reports, no one else's, and
// not the statistics of the instance's ingest box.
hub := send(s.h.HandleDMARCList, &ops, "GET", "/outbound/dmarc", nil, nil)
if hub.Code != http.StatusOK {
t.Fatalf("hub for its administrator = %d, want 200: %s", hub.Code, hub.Body.String())
}
has(t, "hub for its administrator", hub.Body.String(), `<h1 class="title is-3">DMARC reports</h1>`, `<h2>Recent reports</h2>`,
`<a href="/outbound/dmarc/domains/1">example.org</a>`, `Outlook.com`, `href="/outbound/dmarc/reports/`+idStr(s.reportID)+`"`)
lacks(t, "hub for its administrator", hub.Body.String(), "other.example.net", "google.com",
`/outbound/dmarc/reports/`+idStr(otherReport)+`"`, `<h2>Ingest</h2>`, `Hosted addresses`, `dmarc-reports&#43;&lt;domain&gt;`,
`parse failure`, `Last report`, `Retention`)
// Their own domain and report are theirs, and so is the way back to the hub.
own := send(s.h.HandleDMARCDomain, &ops, "GET", "/outbound/dmarc/domains/1", map[string]string{"id": "1"}, nil)
if own.Code != http.StatusOK {
t.Fatalf("own domain = %d", own.Code)
}
has(t, "domain for its administrator", own.Body.String(), `<h1 class="title is-3">example.org</h1>`, `<a href="/outbound/dmarc">DMARC reports</a>`)
rep := send(s.h.HandleDMARCReport, &ops, "GET", "/outbound/dmarc/reports/"+idStr(s.reportID), map[string]string{"id": idStr(s.reportID)}, nil)
if rep.Code != http.StatusOK {
t.Fatalf("own report = %d", rep.Code)
}
has(t, "report for its administrator", rep.Body.String(), `<a href="/outbound/dmarc">DMARC reports</a>`)
for name, code := range map[string]int{
"another tenant's domain": send(s.h.HandleDMARCDomain, &ops, "GET", "/outbound/dmarc/domains/"+idStr(other.ID),
map[string]string{"id": idStr(other.ID)}, nil).Code,
"another tenant's report": send(s.h.HandleDMARCReport, &ops, "GET", "/outbound/dmarc/reports/"+idStr(otherReport),
map[string]string{"id": idStr(otherReport)}, nil).Code,
"a report that does not exist": send(s.h.HandleDMARCReport, &ops, "GET", "/outbound/dmarc/reports/999",
map[string]string{"id": "999"}, nil).Code,
} {
if code != http.StatusNotFound {
t.Errorf("%s for a domain administrator = %d, want 404", name, code)
}
}
}
// A domain administrator with no outbound domain is not shown every tenant's
// reports because their own list is empty: the hub has no rows, and no error.
func TestDMARCHubForAnAdministratorWithoutOutboundDomains(t *testing.T) {
s := newDMARCStand(t)
nobody := domainAdmin(t, s.h.store, "nobody")
rec := send(s.h.HandleDMARCList, &nobody, "GET", "/outbound/dmarc", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("hub = %d, want 200: %s", rec.Code, rec.Body.String())
}
has(t, "hub", rec.Body.String(), `No reports yet.`)
lacks(t, "hub", rec.Body.String(), "example.org", "Outlook.com", `<table`, `<h2>Ingest</h2>`)
}
// The global role sees the ingest box and every report, including those of a
// domain that has since been removed.
func TestDMARCHubForTheGlobalRoleSeesEverything(t *testing.T) {
s := newDMARCStand(t)
if _, err := s.h.store.InsertDMARCReport(store.DMARCReport{
Domain: "gone.example.net", Reporter: "yahoo.com", ReportID: "gone", ReceivedAt: time.Now(),
PeriodBegin: time.Now().Add(-24 * time.Hour), PeriodEnd: time.Now(), PassCount: 3,
}); err != nil {
t.Fatal(err)
}
body := getBody(t, s.h.HandleDMARCList, "/outbound/dmarc")
has(t, "hub", body, `<h2>Ingest</h2>`, `<h2>Recent reports</h2>`, `example.org`, `Outlook.com`, `gone.example.net`, `yahoo.com`)
}