dmarc: no custom report address on the server's own name (audit W1)

Security audit 2026-10-17, finding W1, stage 1. HandleDomainDMARC took any
address validate.Email takes; AllowedRecipients opened every stored address
on the hostname, not only the domain's hosted one; checkReportDomain
cross-checked the claimed domain only against a dmarc-reports+<tag> local
part. So a domain user of tenant A could store ops@<hostname> and from then
on anyone could mail a report claiming tenant B's domain to it, stored and
shown under B and counted toward B's 500; or store B's hosted address and
open B's mailbox without B choosing hosted. TestAllowedRecipients asserted
ops@<host> was allowed - a choice, now reversed.

Now: a custom address on the hostname is refused on the form ("choose
SelfPost hosted"); AllowedRecipients adds a domain's address only when it
is that domain's own hosted address; checkReportDomain requires a tag and
the claimed domain to equal it. The live registry (checked 2026-10-17)
holds two hosted addresses and no custom one on the hostname, so nothing
there changes.

Tests: TestCustomReportAddressOnTheHostnameIsRefused (typed, another
domain's hosted, the domain's own hosted typed as custom; an address
elsewhere still saves); TestAllowedRecipientsFollowTheDomainAddresses
inverted plus another domain's hosted address stored; untagged addresses
rejected in TestIngestMessageRejectsTagMismatch; the ingest tests deliver
to tagged addresses.

Docs: security.md § Accepted risks (DMARC), guide § DMARC reports;
CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-17 21:47:34 +03:00
co-authored by Claude Opus 5.5
parent c50314fb4c
commit 626bd337be
10 changed files with 91 additions and 21 deletions
+5
View File
@@ -23,6 +23,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
each, and the journal milter's rows did not count toward the 10 000-row each, and the journal milter's rows did not count toward the 10 000-row
cut between the six-hourly prunes. Repeats are one row, a message's rows cut between the six-hourly prunes. Repeats are one row, a message's rows
are written in one transaction, and both writers count toward the cut. are written in one transaction, and both writers count toward the cut.
- **A domain's custom DMARC report address may not be on the server's own
name** (audit W1): it opened a mailbox the whole internet could file
reports into, under any domain, or another domain's hosted one. Only the
hosted address of a domain that chose it is a mailbox now, and a report
must arrive at the address tagged with its domain.
- **A stranger's recipient no longer writes an Inbound › Log row** (audit - **A stranger's recipient no longer writes an Inbound › Log row** (audit
M2): Postfix quotes a refused recipient in its reply with the quotes M2): Postfix quotes a refused recipient in its reply with the quotes
removed, so `RCPT TO:<"; from=<x> to=<y> …"@domain>` spelled a second removed, so `RCPT TO:<"; from=<x> to=<y> …"@domain>` spelled a second
+4 -3
View File
@@ -1343,7 +1343,8 @@ settings) and nowhere else:
server whose reports are parsed and shown under *DMARC reports*. Offered only server whose reports are parsed and shown under *DMARC reports*. Offered only
while `DMARC_REPORTS_ENABLE=true`. while `DMARC_REPORTS_ENABLE=true`.
- **A specific address** — any mailbox, typed for this domain; it receives the - **A specific address** — any mailbox, typed for this domain; it receives the
raw XML. Beside the field, *Use my e-mail* fills it with the e-mail of your raw XML. Not one on `SELFPOST_HOSTNAME`: to have this server receive the
reports, choose *SelfPost hosted*. Beside the field, *Use my e-mail* fills it with the e-mail of your
profile (Account); it only fills the field, and saving is the form's own profile (Account); it only fills the field, and saving is the form's own
button. The button is absent when your profile has no e-mail. button. The button is absent when your profile has no e-mail.
@@ -1351,8 +1352,8 @@ Changing the choice changes the DMARC record to publish; the domain page shows
the new value. There is no panel-wide or per-user default. Only addresses on the new value. There is no panel-wide or per-user default. Only addresses on
`SELFPOST_HOSTNAME` that a domain has chosen are accepted on port 25 - the `SELFPOST_HOSTNAME` that a domain has chosen are accepted on port 25 - the
hosted address `dmarc-reports+<domain>@<hostname>` of a domain set to hosted address `dmarc-reports+<domain>@<hostname>` of a domain set to
*SelfPost hosted*, or a typed address that happens to be on this host. There *SelfPost hosted* - and a report is kept only when it is for the domain the
is no mailbox for the server as a whole. address is tagged with. There is no mailbox for the server as a whole.
**DNS.** Publish the usual `_dmarc` TXT on each sending domain with **DNS.** Publish the usual `_dmarc` TXT on each sending domain with
`rua=mailto:…` pointing at the address the domain shows. Receivers deliver to the `rua=mailto:…` pointing at the address the domain shows. Receivers deliver to the
+5 -2
View File
@@ -737,11 +737,14 @@ the argument.
into the store (`countInboundWrites`, both writers), into the store (`countInboundWrites`, both writers),
`InsertInboundSpamMessage`; TestInboundRecipientRepeatedIsOneRow, `InsertInboundSpamMessage`; TestInboundRecipientRepeatedIsOneRow,
TestInboundJournalIsCutWhicheverWriterWrites. TestInboundJournalIsCutWhicheverWriterWrites.
- [ ] **W1.** Refuse a custom report address on the hostname; exact hosted - [x] **W1.** Refuse a custom report address on the hostname; exact hosted
address only in `AllowedRecipients`; a tag required for every recipient on address only in `AllowedRecipients`; a tag required for every recipient on
the hostname in `checkReportDomain`; the existing test inverted; the the hostname in `checkReportDomain`; the existing test inverted; the
accepted-risk text corrected. Check the live registry for a custom address accepted-risk text corrected. Check the live registry for a custom address
on the hostname first (none expected). — **Opus**, 1.5 h. on the hostname first (none expected). — **Opus**, 1.5 h. *Done
2026-10-17:* the live registry holds two hosted addresses and no custom
one on the hostname; TestCustomReportAddressOnTheHostnameIsRefused, the
untagged cases in TestIngestMessageRejectsTagMismatch.
- [ ] **A5/W2.** Delete a domain's reports in `domain.Service.Delete` (the - [ ] **A5/W2.** Delete a domain's reports in `domain.Service.Delete` (the
cheap form); security.md § Roles says reports go with the domain. — cheap form); security.md § Roles says reports go with the domain. —
**Sonnet**, 0.5 h. (The `domain_id` form is better and 2 h; take it if **Sonnet**, 0.5 h. (The `domain_id` form is better and 2 h; take it if
+10 -4
View File
@@ -636,10 +636,16 @@ deferred item from the roadmap.
the alignment statistics the panel shows. Inherent to how `rua` works: the alignment statistics the panel shows. Inherent to how `rua` works:
reporters are arbitrary third parties and the mailbox address is published in reporters are arbitrary third parties and the mailbox address is published in
DNS. The ingest already constrains the meaningful part — a report cannot be DNS. The ingest already constrains the meaningful part — a report cannot be
filed under a domain this instance does not host, and the claimed domain is filed under a domain this instance does not host, and the claimed domain
cross-checked against the `+tag` of the delivery address (effective since must be the `+tag` of the delivery address (checked since 2.0.0: before it
2.0.0: before it the recipient never reached the command) — and the data is the recipient never reached the command; required since `2.14.4`) — and
advisory statistics only; nothing enforces off it. What a stranger can do is the data is advisory statistics only; nothing enforces off it. The only
mailboxes are the hosted addresses, each open while its own domain has
chosen it: a typed report address on the server's own name is refused, and
one stored before is no mailbox (until `2.14.4` a domain user could type
`ops@<hostname>` and so open an untagged mailbox to the internet whose
reports were filed under any domain, or another domain's hosted address -
security audit 2026-10-17, W1). What a stranger can do is
limited to a domain that chose a SelfPost-hosted address, through that limited to a domain that chose a SelfPost-hosted address, through that
address: file reports under it, replace one whose reporter and id they know, address: file reports under it, replace one whose reporter and id they know,
and fill that domain's 500 — not another domain's. A message that is not a and fill that domain's 500 — not another domain's. A message that is not a
+9 -3
View File
@@ -113,13 +113,19 @@ func IngestMessage(st *store.Store, r io.Reader, recipient string, receivedAt ti
} }
// checkReportDomain refuses a report whose policy domain is not one of this // 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 // relay's sending domains, or is not the +tag of the address it was delivered
// delivered to. // to. Every mailbox here is a hosted one, tagged with its domain
// (AllowedRecipients), so an address without a tag is no mailbox of a domain
// and its report belongs to none (security audit 2026-10-17, W1).
func checkReportDomain(st *store.Store, domain, recipient string) error { func checkReportDomain(st *store.Store, domain, recipient string) error {
if domain == "" { if domain == "" {
return rejected(fmt.Errorf("no policy domain")) return rejected(fmt.Errorf("no policy domain"))
} }
if tagged := TaggedDomain(recipient); tagged != "" && tagged != domain { tagged := TaggedDomain(recipient)
if tagged == "" {
return rejected(fmt.Errorf("the address it was sent to (%s) is tagged with no domain", recipient))
}
if tagged != domain {
return rejected(fmt.Errorf("domain %q does not match the address it was sent to (%s)", domain, recipient)) return rejected(fmt.Errorf("domain %q does not match the address it was sent to (%s)", domain, recipient))
} }
ok, err := st.DomainExists(domain) ok, err := st.DomainExists(domain)
+10 -2
View File
@@ -101,7 +101,7 @@ func TestIngestMessageStoresReport(t *testing.T) {
// append raw gzip for simple single-part test // append raw gzip for simple single-part test
msg.Write(gz.Bytes()) msg.Write(gz.Bytes())
if err := IngestMessage(st, msg, "dmarc-reports@mail.example.com", time.Now().UTC()); err != nil { if err := IngestMessage(st, msg, "dmarc-reports+example.com@mail.example.com", time.Now().UTC()); err != nil {
t.Fatalf("IngestMessage: %v", err) t.Fatalf("IngestMessage: %v", err)
} }
list, err := st.ListDMARCReports(store.DMARCReportScope{AllDomains: true}, 10) list, err := st.ListDMARCReports(store.DMARCReportScope{AllDomains: true}, 10)
@@ -131,7 +131,7 @@ func TestIngestMessageRejectsUnknownDomain(t *testing.T) {
} }
defer st.Close() defer st.Close()
err = IngestMessage(st, bytes.NewBufferString(gzipMessage(t, sampleXML)), "dmarc-reports@mail.example.com", time.Now().UTC()) err = IngestMessage(st, bytes.NewBufferString(gzipMessage(t, sampleXML)), "dmarc-reports+example.com@mail.example.com", time.Now().UTC())
if !errors.Is(err, ErrRejected) { if !errors.Is(err, ErrRejected) {
t.Fatalf("report for an unconfigured domain: err = %v, want ErrRejected", err) t.Fatalf("report for an unconfigured domain: err = %v, want ErrRejected", err)
} }
@@ -164,6 +164,14 @@ func TestIngestMessageRejectsTagMismatch(t *testing.T) {
if !errors.Is(err, ErrRejected) { if !errors.Is(err, ErrRejected) {
t.Fatalf("report at an address tagged for another domain: err = %v, want ErrRejected", err) t.Fatalf("report at an address tagged for another domain: err = %v, want ErrRejected", err)
} }
// An address with no tag is no domain's mailbox, whatever the report
// claims (security audit 2026-10-17, W1).
for _, untagged := range []string{"ops@mail.example.com", "dmarc-reports@mail.example.com"} {
err = IngestMessage(st, bytes.NewBufferString(gzipMessage(t, sampleXML)), untagged, time.Now().UTC())
if !errors.Is(err, ErrRejected) {
t.Errorf("report at the untagged %s: err = %v, want ErrRejected", untagged, err)
}
}
} }
// What cannot be read as a report is rejected, not put off: Postfix would // What cannot be read as a report is rejected, not put off: Postfix would
+9 -6
View File
@@ -63,17 +63,20 @@ func (s *Service) AllowedRecipients() ([]string, error) {
seen[addr] = true seen[addr] = true
out = append(out, addr) out = append(out, addr)
} }
// Only what a domain has chosen is open: its report address opens the // Only what a domain has chosen is open, and only the one mailbox that is
// mailbox it names, if that is on this host. There is no mailbox for the // its own: the hosted address tagged with its name. A stored address on
// server as a whole. // this host that is anything else - typed before 2.14.4, when the form
// still took one - opens nothing: it was an untagged mailbox, or another
// domain's, open to the internet (security audit 2026-10-17, W1). There
// is no mailbox for the server as a whole.
domains, err := s.store.ListDomains() domains, err := s.store.ListDomains()
if err != nil { if err != nil {
return nil, err return nil, err
} }
for _, d := range domains { for _, d := range domains {
// add keeps only addresses on this host, so a domain reporting elsewhere if hosted := HostedReportAddress(s.hostname, d.Name); strings.EqualFold(strings.TrimSpace(d.DMARCRua), hosted) {
// (or nowhere) opens nothing here. add(hosted)
add(d.DMARCRua) }
} }
sort.Strings(out) sort.Strings(out)
return out, nil return out, nil
+11 -1
View File
@@ -32,11 +32,21 @@ func TestAllowedRecipientsFollowTheDomainAddresses(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
// Another domain's hosted address, stored for this one: no mailbox
// either (W1's second shape).
stolen, _ := st.AddDomain("stolen.example", "sel")
if err := st.SetDomainDMARCAddress(stolen.ID, HostedReportAddress("mail.example.com", "victim.example")); err != nil {
t.Fatal(err)
}
got, err := NewService(st, nil, "mail.example.com", true).AllowedRecipients() got, err := NewService(st, nil, "mail.example.com", true).AllowedRecipients()
if err != nil { if err != nil {
t.Fatalf("AllowedRecipients: %v", err) t.Fatalf("AllowedRecipients: %v", err)
} }
want := []string{"dmarc-reports+hosted.example@mail.example.com", "ops@mail.example.com"} // Only the hosted address of the domain that chose it: an address typed
// on the server's name (ops@) opens nothing (security audit 2026-10-17,
// W1; this test asserted the opposite until then).
want := []string{"dmarc-reports+hosted.example@mail.example.com"}
if !reflect.DeepEqual(got, want) { if !reflect.DeepEqual(got, want) {
t.Errorf("allowed recipients = %v, want %v", got, want) t.Errorf("allowed recipients = %v, want %v", got, want)
} }
+8
View File
@@ -44,6 +44,14 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
refuse(http.StatusBadRequest, "Enter the report address or choose another option.") refuse(http.StatusBadRequest, "Enter the report address or choose another option.")
return return
} }
// An address on the server's own name is a mailbox of this server:
// the one way to one is the hosted choice, which is tagged with this
// domain. A typed one opened an untagged mailbox - or another
// domain's - to the whole internet (security audit 2026-10-17, W1).
if dmarc.IsHostedOnHostname(email, h.cfg.Hostname) {
refuse(http.StatusBadRequest, "That address is on this server's own name. To have SelfPost receive the reports, choose SelfPost hosted.")
return
}
rua = email rua = email
case ruaHosted: case ruaHosted:
if h.dmarc == nil || !h.dmarc.Enabled() { if h.dmarc == nil || !h.dmarc.Enabled() {
@@ -356,3 +356,23 @@ func TestDeleteDomainSaysItIsGoneWhenARebuildFails(t *testing.T) {
t.Errorf("the domain is still there: %v", err) t.Errorf("the domain is still there: %v", err)
} }
} }
// A typed report address on the server's own name is refused: the hosted
// choice is the one way to a mailbox here, tagged with the domain - a typed
// one opened an untagged mailbox, or another domain's, to the internet
// (security audit 2026-10-17, W1).
func TestCustomReportAddressOnTheHostnameIsRefused(t *testing.T) {
s, _ := hostedStand(t)
for _, addr := range []string{"ops@mail.example.org", "dmarc-reports+victim.example@MAIL.example.org", hostedForExample} {
rec := s.saveReports(url.Values{"dmarc_rua_mode": {"custom"}, "dmarc_rua_email": {addr}})
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "choose SelfPost hosted") {
t.Errorf("custom %s = %d:\n%.600s", addr, rec.Code, rec.Body.String())
}
if got := s.reportAddress(t); got != "" {
t.Errorf("custom %s stored %q", addr, got)
}
}
if rec := s.saveReports(url.Values{"dmarc_rua_mode": {"custom"}, "dmarc_rua_email": {"reports@hub.example"}}); rec.Code != http.StatusSeeOther {
t.Errorf("an address elsewhere = %d", rec.Code)
}
}