Files
selfpost/internal/web/view/dmarc.go
T
mixandClaude Opus 5.5 b2fbe91de9 panel-redesign: security review of 2.0 - sessions belong to a user by id
Stage 3, step 2. Reviewed by Fable (read-only), fixed by Sonnet with a
failing test before each fix, checked by Opus. No Critical or High
finding: route authorization, tenant isolation by id, the /server/
subtree guard, output encoding and the origin check were found sound.

Fixed
  - M1: a session was keyed by user name and had no link to the user. A
    deleted or renamed user's cookie stayed alive (renewed on every hit,
    before the user was looked up) and became a session of whoever was
    later created under that name, with that account's role. The
    baseline now has sessions.user_id REFERENCES users(id) ON DELETE
    CASCADE; the principal is resolved by id and a session is renewed
    only after its user was found. RenameSession is gone.
  - M2: any user's password change deleted every other user's sessions.
    It ends that user's other sessions only.
  - L1: a password set on the user form ends that user's sessions (an
    administrator editing their own account keeps the current one).
  - L2: store, saslpasswd2 and Postfix reload errors were shown verbatim
    on the application form, the inbound upstream / recipients forms and
    the limit recalculation - pages a domain administrator reaches.
    Validation errors (app.ValidationError, inbound.ValidationError,
    store.ErrNotAutoRateLimit) keep their text; anything else is logged
    and shown as "Could not save ... Check the logs and try again."
  - L3: store.ListDMARCReports treated an empty domain list as "all". It
    takes a scope now (AllDomains or a list; an empty list is no rows),
    and the DMARC hub is open to the domain role as the plan says: the
    reports of the assigned domains, without the instance-wide ingest
    box. The menu entry was already shown to them and led to a 404.
  - I1: /static/ answered with a listing of the embedded assets. A path
    that is not a known asset is a 404.

Consequences
  - The global DMARC hub lists reports of domains that were deleted
    since, too.
  - dev/migrate1x (not in the tree) no longer carries sessions over:
    everyone signs in again after the transfer.
  - docs/schema-migrations.md: the sessions row.

Recorded, not changed - for the owner:
  - L4: the report-address select names the followed user and their
    address to every administrator of the domain, by default the global
    administrator's login name. The plan asks for it.
  - L5: a delegated inbound upstream may be any host and port, loopback
    and private ranges included - within the trusted-operator model; to
    be listed among the accepted risks in security.md.
  - L6: Account's profile is saved without the password; becomes a
    requirement once password-reset mails to users.email.
  - Still echoing an error's text: backup and domain import (global
    only), the domain form's validation messages.

Not checked by the review: anything needing the container (e2e, real
Postfix / DNS), CSP behaviour in a live browser.

  go test -count=1 ./internal/web/... ./internal/store/... ./internal/app/...
    ./internal/inbound/... ./internal/dmarc/... ./cmd/panel
      ok  (all eleven packages; dev/migrate1x also ok)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:04:02 +03:00

396 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package view
// The data of the DMARC report pages: the hub (templates/dmarc.html, mockup
// dmarc.html), one sending domain (dmarc_domain.html, mockup dmarc-domain.html)
// and one aggregate report (dmarc_report.html, mockup dmarc-report.html).
//
// A page is a typed struct that embeds Meta, built by a constructor and filled
// by With… methods; the handler reads the report store, the view decides what is
// said and how it is worded.
import (
"fmt"
"math"
"strconv"
"strings"
"time"
)
const outboundDMARC = "/outbound/dmarc"
// DMARCDomainHref is the address of a sending domain's reports.
func DMARCDomainHref(id int64) string { return outboundDMARC + "/domains/" + strconv.FormatInt(id, 10) }
// DMARCReportHref is the address of one report.
func DMARCReportHref(id int64) string { return outboundDMARC + "/reports/" + strconv.FormatInt(id, 10) }
// FormatReceived is the time a report was received, as the tables show it.
func FormatReceived(t time.Time) string { return t.UTC().Format("01-02 15:04") }
// ---- The hub
// DMARCHub is the data of the DMARC reports hub: whether reports are arriving
// and the latest of them. The ingest box describes the whole instance (the
// hosted mailbox, the reports kept across every domain), so it is drawn only
// for the viewer it belongs to — see WithoutIngest.
type DMARCHub struct {
Meta
Head Head
IngestOn bool
Ingest Box
IngestFacts []Fact
Recent Box
Rows []DMARCReportRow
Empty EmptyState
}
// DMARCReportRow is one report in a table: when it was received, for which
// domain (DomainHref is empty when the domain is gone or not the viewer's), who
// sent it, the window it covers and its pass and fail counts. Domain is left
// out on the page of one domain.
type DMARCReportRow struct {
Received string
Domain string
DomainHref string
Reporter string
Window string
Pass int
Fail int
Href string
}
// PassText is the pass count as the table writes it.
func (r DMARCReportRow) PassText() string { return FormatCount(int64(r.Pass)) }
// FailTag is the fail count as a tag: failures are the thing to look at, so a
// non-zero count is amber and a zero is plain text.
func (r DMARCReportRow) FailTag() Tag {
return Tag{Status: "warn", Label: FormatCount(int64(r.Fail))}
}
// IngestInput is the state of report ingest: whether it is healthy, when the
// last report arrived (zero when none has), what the week kept, the address
// reports can be sent to and how long they are kept.
type IngestInput struct {
OK bool
Last time.Time
KeptThisWeek int
ParseFailures int
Hosted string
// Host is the name that must have an MX record on this server for the hosted
// address to receive anything.
Host string
RetentionMax int
RetentionDays int
}
// NewDMARCHub builds the hub.
func NewDMARCHub(m Meta, in IngestInput) *DMARCHub {
m.Title, m.Section, m.Page = "DMARC reports", "outbound", "dmarc"
status, label := "ok", "ok"
if !in.OK {
status, label = "quiet", "quiet"
}
last := Fact{Label: "Last report", Value: Plain("None yet")}
if in.Last.IsZero() {
last.Note = Rich("Point ", Code("rua="), " at the hosted address below and publish MX for ", Mono(in.Host), " on this server.")
} else {
last.Value = Plain(in.Last.UTC().Format("2006-01-02 15:04") + " UTC")
}
return &DMARCHub{
Meta: m,
Head: Head{
Kicker: "Outbound",
Title: "DMARC reports",
Lead: Rich("How receivers saw mail claiming to be from your domains. Aggregate (", Code("rua="),
") only; open a report for the parsed XML."),
},
IngestOn: true,
Ingest: Box{No: "01", Title: "Ingest", End: Rich(TagOf(status, label))},
IngestFacts: []Fact{
last,
{Label: "This week", Value: Plain(FormatCount(int64(in.KeptThisWeek)) + " kept · " + plural(int64(in.ParseFailures), "parse failure", "parse failures"))},
{Label: "Hosted address", Value: Plain(in.Hosted), Mono: true},
{Label: "Retention", Value: Plain(FormatCount(int64(in.RetentionMax)) + " reports or " + plural(int64(in.RetentionDays), "day", "days"))},
},
Recent: Box{No: "02", Title: "Recent reports"},
Empty: EmptyState{Icon: "ti-report-analytics", Text: Plain("No reports yet.")},
}
}
// WithoutIngest leaves the ingest box out, for a viewer who reaches some of the
// domains and not the instance: the box is about all of them, so what it says
// is not theirs to read. The reports box is then the page's first and only one.
func (p *DMARCHub) WithoutIngest() *DMARCHub {
p.IngestOn = false
p.Recent.No = "01"
return p
}
// WithReports sets the table of the latest reports.
func (p *DMARCHub) WithReports(rows []DMARCReportRow) *DMARCHub {
p.Rows = rows
return p
}
// ---- One sending domain
// DMARCDomain is the data of a domain's reports: how its mail fared over the
// window, which sources it came from and the reports that said so.
type DMARCDomain struct {
Meta
Head Head
Sources Box
SourceRows []DMARCSourceRow
SourcesEmpty EmptyState
Reports Box
Rows []DMARCReportRow
ReportsEmpty EmptyState
}
// DMARCSourceRow is one sending address over the window: its pass and fail
// counts and what receivers did with its mail. ThisRelay marks the server the
// panel runs on.
type DMARCSourceRow struct {
Source string
ThisRelay bool
Pass int
Fail int
Disposition string
}
// PassText is the pass count as the table writes it.
func (r DMARCSourceRow) PassText() string { return FormatCount(int64(r.Pass)) }
// FailText is the fail count as the table writes it.
func (r DMARCSourceRow) FailText() string { return FormatCount(int64(r.Fail)) }
// Relay is the tag of the row that is this server.
func (r DMARCSourceRow) Relay() Tag { return Tag{Label: "this relay"} }
// Verdict is the disposition as a tag: green while the source passes, amber
// once it fails.
func (r DMARCSourceRow) Verdict() Tag {
status := "ok"
if r.Fail > 0 {
status = "warn"
}
return Tag{Status: status, Label: r.Disposition}
}
// passRate writes the share of messages that passed as the postmark prints it.
// It rounds down: a domain that has failures is never stamped 100%.
func passRate(pass, fail int) string {
if fail == 0 {
return "100%"
}
total := float64(pass + fail)
return fmt.Sprintf("%.1f%%", math.Floor(float64(pass)*1000/total)/10)
}
// withCode turns every p= in a sentence into a code span.
func withCode(s string) Text {
var t Text
for i, part := range strings.Split(s, "p=") {
if i > 0 {
t = append(t, Code("p="))
}
if part != "" {
t = append(t, Inline{Text: part})
}
}
return t
}
// NewDMARCDomain builds the page of a domain's reports. pass and fail are the
// messages of the last days days, hint the advice on tightening p= that follows
// from them and the sources. hub is whether the viewer reaches the hub: the
// trail leads back to it only then.
func NewDMARCDomain(m Meta, id int64, name string, hub bool, pass, fail, days int, hint string) *DMARCDomain {
m.Title, m.Section, m.Page = name+" · DMARC reports", "outbound", "dmarc"
crumb := Crumb{Label: "DMARC reports"}
if hub {
crumb.Href = outboundDMARC
}
window := plural(int64(days), "day", "days")
p := &DMARCDomain{
Meta: m,
Head: Head{
Crumbs: []Crumb{crumb},
Title: name,
Lead: append(Rich(FormatCount(int64(pass))+" pass · "+FormatCount(int64(fail))+" fail in the last "+window+". "),
withCode(hint)...),
Actions: []Action{{Label: "Domain page", Icon: "ti-world-upload", Href: DomainHref(id)}},
},
Sources: Box{No: "01", Title: fmt.Sprintf("Sources · %d days", days)},
SourcesEmpty: EmptyState{Icon: "ti-world-upload", Text: Plain("No mail from any source in this window.")},
Reports: Box{No: "02", Title: "Reports"},
ReportsEmpty: EmptyState{Icon: "ti-report-analytics", Text: Plain("No reports for this domain yet.")},
}
if pass+fail > 0 {
level := LevelOK
switch {
case pass == 0:
level = LevelFail
case fail > 0:
level = LevelWarn
}
p.Head.Postmark = &Postmark{Top: "DMARC", Word: passRate(pass, fail), Bottom: fmt.Sprintf("PASS · %d DAYS", days), Level: level}
}
return p
}
// WithSources sets the table of the sources.
func (p *DMARCDomain) WithSources(rows []DMARCSourceRow) *DMARCDomain {
p.SourceRows = rows
return p
}
// WithReports sets the table of the domain's reports.
func (p *DMARCDomain) WithReports(rows []DMARCReportRow) *DMARCDomain {
p.Rows = rows
return p
}
// ---- One report
// DMARCReport is the data of one aggregate report: who sent it and when, the
// policy it was written against and the records it holds.
type DMARCReport struct {
Meta
Head Head
Report Box
ReportFacts []Fact
Policy Box
PolicyFacts []Fact
Records Box
RecordRows []DMARCRecordRow
RecordsEmpty EmptyState
}
// DMARCRecordRow is one source in a report: how many messages, what receivers
// did with them and what SPF and DKIM said.
type DMARCRecordRow struct {
Source string
ThisRelay bool
Count int
Disposition string
SPF string
DKIM string
HeaderFrom string
}
// CountText is the message count as the table writes it.
func (r DMARCRecordRow) CountText() string { return FormatCount(int64(r.Count)) }
// Relay is the tag of the row that is this server.
func (r DMARCRecordRow) Relay() Tag { return Tag{Label: "this relay"} }
// Verdict is the disposition as a tag: green when either check passed, amber
// when neither did.
func (r DMARCRecordRow) Verdict() Tag {
status := "warn"
if r.SPF == "pass" || r.DKIM == "pass" {
status = "ok"
}
return Tag{Status: status, Label: r.Disposition}
}
// SPFTag is the SPF result as a tag.
func (r DMARCRecordRow) SPFTag() Tag { return resultTag(r.SPF) }
// DKIMTag is the DKIM result as a tag.
func (r DMARCRecordRow) DKIMTag() Tag { return resultTag(r.DKIM) }
// resultTag is a check's result as a tag: green for pass, red for anything else.
func resultTag(result string) Tag {
if result == "pass" {
return Tag{Status: "ok", Label: result}
}
return Tag{Status: "fail", Label: result}
}
// ReportInput is what a report holds, with the times already written the way
// the page shows them.
type ReportInput struct {
ID int64
Reporter string
ReportID string
Domain string
DomainID int64
Contact string
Pass, Fail int
Window string // the window in the title: "20 Sep"
Period string // the window with its times: "20 Sep 00:00 – 21 Sep 00:00 UTC"
Received string // "2026-09-21 04:40 UTC"
PolicyP string
PolicySP string
PolicyPct int
PolicyADKIM string
PolicyASPF string
Recipient string
// Hub is whether the viewer reaches the hub: the trail leads back to it only then.
Hub bool
}
// NewDMARCReport builds the page of one report.
func NewDMARCReport(m Meta, in ReportInput) *DMARCReport {
title := in.Reporter
if in.Window != "" {
title += " · " + in.Window
}
m.Title, m.Section, m.Page = title+" · DMARC", "outbound", "dmarc"
hub := Crumb{Label: "DMARC reports"}
if in.Hub {
hub.Href = outboundDMARC
}
route := Route{{Text: in.Domain}, {Tag: &Tag{Status: "ok", Label: FormatCount(int64(in.Pass)) + " pass"}}}
if in.Fail > 0 {
route = append(route, RoutePart{Tag: &Tag{Status: "warn", Label: FormatCount(int64(in.Fail)) + " fail"}})
}
report := []Fact{
{Label: "Reporter", Value: Plain(in.Reporter)},
{Label: "Report id", Value: Plain(in.ReportID), Mono: true, Small: true},
{Label: "Window", Value: Plain(in.Period)},
{Label: "Received", Value: Plain(in.Received)},
}
if in.Contact != "" {
report = append(report, Fact{Label: "Contact", Value: Plain(in.Contact)})
}
policy := []Fact{
{Label: "p / sp / pct", Value: Plain(fmt.Sprintf("%s / %s / %d", in.PolicyP, in.PolicySP, in.PolicyPct)), Mono: true},
{Label: "adkim / aspf", Value: Plain(in.PolicyADKIM + " / " + in.PolicyASPF), Mono: true},
}
if in.Recipient != "" {
policy = append(policy, Fact{Label: "rua", Value: Plain(in.Recipient), Mono: true, Small: true})
}
return &DMARCReport{
Meta: m,
Head: Head{
Crumbs: []Crumb{hub, {Href: DMARCDomainHref(in.DomainID), Label: in.Domain}},
Title: title,
Compact: true,
Route: route,
},
Report: Box{No: "01", Title: "Report"},
ReportFacts: report,
Policy: Box{No: "02", Title: "Published policy"},
PolicyFacts: policy,
Records: Box{No: "03", Title: "Records", End: Plain("Parsed from the aggregate XML")},
RecordsEmpty: EmptyState{Icon: "ti-report-analytics", Text: Plain("This report holds no records.")},
}
}
// WithRecords sets the table of the report's records.
func (p *DMARCReport) WithRecords(rows []DMARCRecordRow) *DMARCReport {
p.RecordRows = rows
return p
}