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

234 lines
7.5 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 handlers
import (
"errors"
"net/http"
"strconv"
"time"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/view"
)
// dmarcRow is a report in a table of the pages.
func dmarcRow(rep store.DMARCReportSummary) view.DMARCReportRow {
return view.DMARCReportRow{
Received: view.FormatReceived(rep.ReceivedAt), Domain: rep.Domain, Reporter: rep.Reporter,
Window: formatDMARCWindow(rep.PeriodBegin, rep.PeriodEnd), Pass: rep.PassCount, Fail: rep.FailCount,
Href: view.DMARCReportHref(rep.ID),
}
}
func (h *Handlers) requireDMARC(w http.ResponseWriter, r *http.Request) (auth.Principal, bool) {
if !h.cfg.DMARCEnabled || h.dmarc == nil {
http.NotFound(w, r)
return auth.Principal{}, false
}
p, ok := h.principal(r)
if !ok {
http.NotFound(w, r)
return auth.Principal{}, false
}
return p, true
}
func (h *Handlers) canViewDMARCDomain(p auth.Principal, d store.Domain) bool {
return p.CanAccessDomain(d.ID)
}
// HandleDMARCList is the DMARC reports hub. The global role sees every report
// and the ingest statistics of the instance; a domain administrator sees the
// reports of the sending domains assigned to them, and no ingest statistics
// (the box describes the whole instance: its mailbox, its failures).
func (h *Handlers) HandleDMARCList(w http.ResponseWriter, r *http.Request) {
p, ok := h.requireDMARC(w, r)
if !ok {
return
}
assigned, err := h.assignedDomains(p)
if err != nil {
logf("panel: dmarc list domains: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
scope := store.DMARCReportScope{AllDomains: p.IsGlobal()}
domainIDs := make(map[string]int64, len(assigned))
for _, d := range assigned {
scope.Domains = append(scope.Domains, d.Name)
domainIDs[d.Name] = d.ID
}
reports, err := h.store.ListDMARCReports(scope, 100)
if err != nil {
logf("panel: dmarc list: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
rows := make([]view.DMARCReportRow, len(reports))
for i, rep := range reports {
rows[i] = dmarcRow(rep)
if id := domainIDs[rep.Domain]; id != 0 {
rows[i].DomainHref = view.DMARCDomainHref(id)
}
}
if !p.IsGlobal() {
page := view.NewDMARCHub(h.shellMeta(r), view.IngestInput{}).WithoutIngest().WithReports(rows)
h.view.Render(w, http.StatusOK, "dmarc", page)
return
}
stats, err := h.store.DMARCIngestStats()
if err != nil {
logf("panel: dmarc ingest stats: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
in := view.IngestInput{
OK: stats.IngestOK, KeptThisWeek: stats.KeptThisWeek, ParseFailures: stats.ParseFailures,
Hosted: h.dmarc.DefaultHostedSuggestion(), Host: h.cfg.Hostname,
RetentionMax: store.DMARCReportsMaxKeep, RetentionDays: store.DMARCReportsMaxAgeDays,
}
if stats.LastReceivedAt != nil {
in.Last = *stats.LastReceivedAt
}
h.view.Render(w, http.StatusOK, "dmarc", view.NewDMARCHub(h.shellMeta(r), in).WithReports(rows))
}
// HandleDMARCDomain shows roll-ups for one sending domain.
func (h *Handlers) HandleDMARCDomain(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireDMARC(w, r); !ok {
return
}
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
p, _ := h.principal(r)
if !h.canViewDMARCDomain(p, d) {
http.NotFound(w, r)
return
}
const windowDays = 7
pass, fail, err := h.store.DMARCDomainRollup(d.Name, windowDays)
if err != nil {
logf("panel: dmarc domain rollup %s: %v", d.Name, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
sources, err := h.store.DMARCSourceRollups(d.Name, windowDays)
if err != nil {
logf("panel: dmarc source rollups %s: %v", d.Name, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
reports, err := h.store.ListDMARCReportsForDomain(d.Name, 50)
if err != nil {
logf("panel: dmarc domain reports %s: %v", d.Name, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
reportRows := make([]view.DMARCReportRow, len(reports))
for i, rep := range reports {
reportRows[i] = dmarcRow(rep)
}
relay := h.relayIPs()
hints := make([]dmarc.SourceHint, len(sources))
sourceRows := make([]view.DMARCSourceRow, len(sources))
for i, s := range sources {
hints[i] = dmarc.SourceHint{SourceIP: s.SourceIP, PassCount: s.PassCount, FailCount: s.FailCount, ThisRelay: relay[s.SourceIP]}
sourceRows[i] = view.DMARCSourceRow{
Source: s.SourceIP, ThisRelay: relay[s.SourceIP], Pass: s.PassCount, Fail: s.FailCount, Disposition: s.Disposition,
}
}
page := view.NewDMARCDomain(h.shellMeta(r), d.ID, d.Name, true, pass, fail, windowDays,
dmarc.TightenPolicyHint(pass, fail, hints)).
WithSources(sourceRows).WithReports(reportRows)
h.view.Render(w, http.StatusOK, "dmarc-domain", page)
}
// HandleDMARCReport shows one parsed aggregate report.
func (h *Handlers) HandleDMARCReport(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireDMARC(w, r); !ok {
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return
}
rep, err := h.store.GetDMARCReport(id)
if errors.Is(err, store.ErrDMARCReportNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
logf("panel: dmarc report %d: %v", id, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
p, _ := h.principal(r)
domains, err := h.assignedDomains(p)
if err != nil {
logf("panel: dmarc report authz: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
var d store.Domain
found := false
for _, cand := range domains {
if cand.Name == rep.Domain {
d = cand
found = true
break
}
}
if !found {
http.NotFound(w, r)
return
}
relay := h.relayIPs()
records := make([]view.DMARCRecordRow, len(rep.Records))
for i, rec := range rep.Records {
records[i] = view.DMARCRecordRow{
Source: rec.SourceIP, ThisRelay: relay[rec.SourceIP], Count: rec.Count, Disposition: rec.Disposition,
SPF: rec.SPFResult, DKIM: rec.DKIMResult, HeaderFrom: rec.HeaderFrom,
}
}
const stamp = "2 Jan 15:04"
page := view.NewDMARCReport(h.shellMeta(r), view.ReportInput{
ID: rep.ID, Reporter: rep.Reporter, ReportID: rep.ReportID, Domain: rep.Domain, DomainID: d.ID,
Contact: rep.ContactEmail, Pass: rep.PassCount, Fail: rep.FailCount,
Window: formatDMARCWindow(rep.PeriodBegin, rep.PeriodEnd),
Period: rep.PeriodBegin.UTC().Format(stamp) + " – " + rep.PeriodEnd.UTC().Format(stamp) + " UTC",
Received: rep.ReceivedAt.UTC().Format("2006-01-02 15:04") + " UTC",
PolicyP: rep.PolicyP, PolicySP: rep.PolicySP, PolicyPct: rep.PolicyPct,
PolicyADKIM: rep.PolicyADKIM, PolicyASPF: rep.PolicyASPF, Recipient: rep.Recipient,
Hub: true,
}).WithRecords(records)
h.view.Render(w, http.StatusOK, "dmarc-report", page)
}
// relayIPs is the set of addresses this server sends from, so that a source in
// a report can be told to be this relay. Empty while the server's name or its
// addresses are unknown.
func (h *Handlers) relayIPs() map[string]bool {
if h.dns == nil || h.cfg.Hostname == "" {
return nil
}
ips := map[string]bool{}
for _, ip := range h.dns.Server(h.cfg.Hostname, false).IPs {
ips[ip] = true
}
return ips
}
func formatDMARCWindow(begin, end time.Time) string {
if begin.IsZero() {
return ""
}
if begin.Year() == end.Year() && begin.YearDay() == end.YearDay() {
return begin.UTC().Format("2 Jan")
}
return begin.UTC().Format("2 Jan") + " – " + end.UTC().Format("2 Jan")
}