Files
selfpost/internal/store/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

359 lines
11 KiB
Go

package store
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
const (
// DMARCReportsMaxKeep is how many parsed reports are kept before pruning.
DMARCReportsMaxKeep = 500
// DMARCReportsMaxAgeDays drops reports older than this window.
DMARCReportsMaxAgeDays = 90
// DMARCParseFailuresKey counts ingest failures (settings).
DMARCParseFailuresKey = "dmarc_parse_failures_total"
)
// ErrDMARCReportNotFound is returned when a report id does not exist.
var ErrDMARCReportNotFound = errors.New("dmarc report not found")
// DMARCReport is one parsed aggregate summary.
type DMARCReport struct {
ID int64
Domain string
Reporter string
ReportID string
PeriodBegin time.Time
PeriodEnd time.Time
ReceivedAt time.Time
ContactEmail string
PolicyP string
PolicySP string
PolicyPct int
PolicyADKIM string
PolicyASPF string
PassCount int
FailCount int
Recipient string
Records []DMARCReportRecord
}
// DMARCReportRecord is one source row inside a report.
type DMARCReportRecord struct {
SourceIP string
Count int
Disposition string
SPFResult string
DKIMResult string
HeaderFrom string
}
// DMARCReportSummary is a list-row without per-record detail.
type DMARCReportSummary struct {
ID int64
Domain string
Reporter string
PeriodBegin time.Time
PeriodEnd time.Time
ReceivedAt time.Time
PassCount int
FailCount int
}
// DMARCSourceRollup aggregates pass/fail per source over a window.
type DMARCSourceRollup struct {
SourceIP string
PassCount int
FailCount int
Disposition string
}
// DMARCIngestStats is panel-facing ingest health.
type DMARCIngestStats struct {
LastReceivedAt *time.Time
KeptThisWeek int
ParseFailures int
IngestOK bool
}
// InsertDMARCReport stores a parsed report and its records, replacing any prior
// row with the same reporter/report_id/domain triple.
func (s *Store) InsertDMARCReport(rep DMARCReport) (int64, error) {
tx, err := s.db.Begin()
if err != nil {
return 0, fmt.Errorf("begin insert dmarc report: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(
`DELETE FROM dmarc_reports WHERE reporter = ? AND report_id = ? AND domain = ?`,
rep.Reporter, rep.ReportID, rep.Domain,
); err != nil {
return 0, fmt.Errorf("delete prior dmarc report: %w", err)
}
res, err := tx.Exec(`
INSERT INTO dmarc_reports (
domain, reporter, report_id, period_begin, period_end, received_at,
contact_email, policy_p, policy_sp, policy_pct, policy_adkim, policy_aspf,
pass_count, fail_count, recipient
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
rep.Domain, rep.Reporter, rep.ReportID,
rep.PeriodBegin.UTC().Format(time.RFC3339),
rep.PeriodEnd.UTC().Format(time.RFC3339),
rep.ReceivedAt.UTC().Format(time.RFC3339),
rep.ContactEmail, rep.PolicyP, rep.PolicySP, rep.PolicyPct,
rep.PolicyADKIM, rep.PolicyASPF, rep.PassCount, rep.FailCount, rep.Recipient,
)
if err != nil {
return 0, fmt.Errorf("insert dmarc report: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("dmarc report id: %w", err)
}
for _, rec := range rep.Records {
if _, err := tx.Exec(`
INSERT INTO dmarc_report_records (
report_row_id, source_ip, count, disposition, spf_result, dkim_result, header_from
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
id, rec.SourceIP, rec.Count, rec.Disposition, rec.SPFResult, rec.DKIMResult, rec.HeaderFrom,
); err != nil {
return 0, fmt.Errorf("insert dmarc record: %w", err)
}
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit dmarc report: %w", err)
}
return id, nil
}
// PruneDMARCReports enforces count and age caps.
func (s *Store) PruneDMARCReports() error {
cutoff := time.Now().UTC().AddDate(0, 0, -DMARCReportsMaxAgeDays).Format(time.RFC3339)
if _, err := s.db.Exec(`DELETE FROM dmarc_reports WHERE received_at < ?`, cutoff); err != nil {
return fmt.Errorf("prune dmarc by age: %w", err)
}
var count int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM dmarc_reports`).Scan(&count); err != nil {
return fmt.Errorf("count dmarc reports: %w", err)
}
if count <= DMARCReportsMaxKeep {
return nil
}
excess := count - DMARCReportsMaxKeep
_, err := s.db.Exec(`
DELETE FROM dmarc_reports WHERE id IN (
SELECT id FROM dmarc_reports ORDER BY received_at ASC LIMIT ?
)`, excess)
if err != nil {
return fmt.Errorf("prune dmarc by count: %w", err)
}
return nil
}
// IncrDMARCParseFailures bumps the failure counter in settings.
func (s *Store) IncrDMARCParseFailures() error {
raw, err := s.GetSetting(DMARCParseFailuresKey)
if err != nil {
return err
}
n := 0
if raw != "" {
fmt.Sscanf(raw, "%d", &n)
}
return s.SetSetting(DMARCParseFailuresKey, fmt.Sprintf("%d", n+1))
}
// DMARCReportScope is whose reports a listing may return: every domain's
// (AllDomains) or those of the named domains. A scope with neither is a viewer
// who reaches nothing and gets no rows — an empty list is never "all"; see
// SendLogFilter, which has the same shape.
type DMARCReportScope struct {
AllDomains bool
Domains []string
}
// ListDMARCReports returns recent summaries within the scope.
func (s *Store) ListDMARCReports(scope DMARCReportScope, limit int) ([]DMARCReportSummary, error) {
if limit <= 0 {
limit = 50
}
q := `SELECT id, domain, reporter, period_begin, period_end, received_at, pass_count, fail_count
FROM dmarc_reports`
var args []any
if !scope.AllDomains {
if len(scope.Domains) == 0 {
return nil, nil
}
marks := make([]string, len(scope.Domains))
for i, d := range scope.Domains {
marks[i] = "?"
args = append(args, d)
}
q += " WHERE domain IN (" + strings.Join(marks, ",") + ")"
}
q += " ORDER BY received_at DESC LIMIT ?"
args = append(args, limit)
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, fmt.Errorf("list dmarc reports: %w", err)
}
defer rows.Close()
var out []DMARCReportSummary
for rows.Next() {
var (
summary DMARCReportSummary
begin string
end string
recv string
)
if err := rows.Scan(&summary.ID, &summary.Domain, &summary.Reporter, &begin, &end, &recv, &summary.PassCount, &summary.FailCount); err != nil {
return nil, err
}
summary.PeriodBegin, _ = time.Parse(time.RFC3339, begin)
summary.PeriodEnd, _ = time.Parse(time.RFC3339, end)
summary.ReceivedAt, _ = time.Parse(time.RFC3339, recv)
out = append(out, summary)
}
return out, rows.Err()
}
// GetDMARCReport loads one report with records.
func (s *Store) GetDMARCReport(id int64) (DMARCReport, error) {
row := s.db.QueryRow(`
SELECT id, domain, reporter, report_id, period_begin, period_end, received_at,
contact_email, policy_p, policy_sp, policy_pct, policy_adkim, policy_aspf,
pass_count, fail_count, recipient
FROM dmarc_reports WHERE id = ?`, id)
rep, err := scanDMARCReport(row)
if errors.Is(err, sql.ErrNoRows) {
return DMARCReport{}, ErrDMARCReportNotFound
}
if err != nil {
return DMARCReport{}, err
}
recs, err := s.listDMARCReportRecords(id)
if err != nil {
return DMARCReport{}, err
}
rep.Records = recs
return rep, nil
}
// ListDMARCReportsForDomain returns summaries for one sending domain.
func (s *Store) ListDMARCReportsForDomain(domain string, limit int) ([]DMARCReportSummary, error) {
return s.ListDMARCReports(DMARCReportScope{Domains: []string{domain}}, limit)
}
// DMARCDomainRollup summarises pass/fail for a domain over the last windowDays.
func (s *Store) DMARCDomainRollup(domain string, windowDays int) (pass, fail int, err error) {
cutoff := time.Now().UTC().AddDate(0, 0, -windowDays).Format(time.RFC3339)
err = s.db.QueryRow(`
SELECT COALESCE(SUM(pass_count), 0), COALESCE(SUM(fail_count), 0)
FROM dmarc_reports WHERE domain = ? AND received_at >= ?`,
domain, cutoff,
).Scan(&pass, &fail)
if err != nil {
return 0, 0, fmt.Errorf("dmarc domain rollup: %w", err)
}
return pass, fail, nil
}
// DMARCSourceRollups aggregates per-source rows for a domain over windowDays.
func (s *Store) DMARCSourceRollups(domain string, windowDays int) ([]DMARCSourceRollup, error) {
cutoff := time.Now().UTC().AddDate(0, 0, -windowDays).Format(time.RFC3339)
rows, err := s.db.Query(`
SELECT r.source_ip,
SUM(CASE WHEN r.spf_result = 'pass' OR r.dkim_result = 'pass' THEN r.count ELSE 0 END),
SUM(CASE WHEN r.spf_result != 'pass' AND r.dkim_result != 'pass' THEN r.count ELSE 0 END),
MAX(r.disposition)
FROM dmarc_report_records r
INNER JOIN dmarc_reports d ON d.id = r.report_row_id
WHERE d.domain = ? AND d.received_at >= ?
GROUP BY r.source_ip
ORDER BY 2 DESC, 3 DESC`, domain, cutoff)
if err != nil {
return nil, fmt.Errorf("dmarc source rollups: %w", err)
}
defer rows.Close()
var out []DMARCSourceRollup
for rows.Next() {
var rollup DMARCSourceRollup
if err := rows.Scan(&rollup.SourceIP, &rollup.PassCount, &rollup.FailCount, &rollup.Disposition); err != nil {
return nil, err
}
out = append(out, rollup)
}
return out, rows.Err()
}
// DMARCIngestStats returns ingest health for the panel.
func (s *Store) DMARCIngestStats() (DMARCIngestStats, error) {
var stats DMARCIngestStats
var last sql.NullString
err := s.db.QueryRow(`SELECT MAX(received_at) FROM dmarc_reports`).Scan(&last)
if err != nil {
return stats, fmt.Errorf("dmarc last received: %w", err)
}
if last.Valid && last.String != "" {
t, _ := time.Parse(time.RFC3339, last.String)
stats.LastReceivedAt = &t
stats.IngestOK = time.Since(t) < 8*24*time.Hour
}
weekCutoff := time.Now().UTC().AddDate(0, 0, -7).Format(time.RFC3339)
if err := s.db.QueryRow(`SELECT COUNT(*) FROM dmarc_reports WHERE received_at >= ?`, weekCutoff).Scan(&stats.KeptThisWeek); err != nil {
return stats, fmt.Errorf("dmarc week count: %w", err)
}
raw, err := s.GetSetting(DMARCParseFailuresKey)
if err != nil {
return stats, err
}
if raw != "" {
fmt.Sscanf(raw, "%d", &stats.ParseFailures)
}
return stats, nil
}
func (s *Store) listDMARCReportRecords(reportID int64) ([]DMARCReportRecord, error) {
rows, err := s.db.Query(`
SELECT source_ip, count, disposition, spf_result, dkim_result, header_from
FROM dmarc_report_records WHERE report_row_id = ? ORDER BY count DESC`, reportID)
if err != nil {
return nil, fmt.Errorf("list dmarc records: %w", err)
}
defer rows.Close()
var out []DMARCReportRecord
for rows.Next() {
var rec DMARCReportRecord
if err := rows.Scan(&rec.SourceIP, &rec.Count, &rec.Disposition, &rec.SPFResult, &rec.DKIMResult, &rec.HeaderFrom); err != nil {
return nil, err
}
out = append(out, rec)
}
return out, rows.Err()
}
func scanDMARCReport(r scanRow) (DMARCReport, error) {
var (
rep DMARCReport
begin string
end string
recv string
)
if err := r.Scan(
&rep.ID, &rep.Domain, &rep.Reporter, &rep.ReportID, &begin, &end, &recv,
&rep.ContactEmail, &rep.PolicyP, &rep.PolicySP, &rep.PolicyPct,
&rep.PolicyADKIM, &rep.PolicyASPF, &rep.PassCount, &rep.FailCount, &rep.Recipient,
); err != nil {
return DMARCReport{}, err
}
rep.PeriodBegin, _ = time.Parse(time.RFC3339, begin)
rep.PeriodEnd, _ = time.Parse(time.RFC3339, end)
rep.ReceivedAt, _ = time.Parse(time.RFC3339, recv)
return rep, nil
}