Security audit 2026-10-17, finding A5/W2 (low), stage 1; the mockup is
7effc98. dmarc_reports belong to a domain by name, outlive the domain (no
FK), and a domain deleted and added again handed its old reports to
whoever it was assigned to then - the logs were moved to ids in 2.5.0 for
exactly this case, reports were not.
The cheap form of the plan: store.DeleteDomain deletes the domain's
reports (their records by the table's cascade) in the same transaction as
the domain. The confirmation page says so when the domain has reports
("delete the 12 DMARC reports received for it;"), counted by the new
store.CountDMARCReports; NewOutDomainDelete takes the count.
Tests: TestDeletingADomainDeletesItsReports (the domain added again has
none, another domain keeps its report and record, a second delete is
ErrDomainNotFound); TestOutDomainDeleteNamesWhatGoesWithIt with 12, 1 and
0 reports.
Docs: security.md § Roles; CHANGELOG; plan.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
238 lines
7.9 KiB
Go
238 lines
7.9 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"modernc.org/sqlite"
|
|
sqlite3 "modernc.org/sqlite/lib"
|
|
)
|
|
|
|
// ErrDomainExists is returned by AddDomain when the domain is already managed.
|
|
var ErrDomainExists = errors.New("domain already exists")
|
|
|
|
// ErrDomainNotFound is returned when a domain id/name does not exist.
|
|
var ErrDomainNotFound = errors.New("domain not found")
|
|
|
|
// Domain is a sending domain managed through the panel (product.md §
|
|
// Multi-domain model). The DKIM key material itself lives on disk under /data;
|
|
// this row records the selector and metadata. AppCount is populated by the
|
|
// listing queries, not stored.
|
|
type Domain struct {
|
|
ID int64
|
|
Name string
|
|
DKIMSelector string
|
|
// Where DMARC aggregate reports go: the address itself, "" meaning no
|
|
// reports (which is what a new domain has). Whether it is the hosted
|
|
// address or one typed for the domain is told by its value; the store does
|
|
// not know the server's hostname.
|
|
DMARCRua string
|
|
CreatedAt time.Time
|
|
AppCount int
|
|
}
|
|
|
|
// AddDomain inserts a new sending domain. The caller is responsible for having
|
|
// validated name (security.md) before it reaches SQL; the query is parameterised
|
|
// regardless. A duplicate name maps to ErrDomainExists.
|
|
func (s *Store) AddDomain(name, selector string) (Domain, error) {
|
|
now := time.Now().UTC()
|
|
res, err := s.db.Exec(
|
|
"INSERT INTO domains (name, dkim_selector, created_at) VALUES (?, ?, ?)",
|
|
name, selector, now.Format(time.RFC3339),
|
|
)
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
return Domain{}, ErrDomainExists
|
|
}
|
|
return Domain{}, fmt.Errorf("insert domain: %w", err)
|
|
}
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
return Domain{}, fmt.Errorf("domain id: %w", err)
|
|
}
|
|
return Domain{ID: id, Name: name, DKIMSelector: selector, CreatedAt: now}, nil
|
|
}
|
|
|
|
// ListDomains returns every domain with its bound-application count (product.md),
|
|
// ordered by name.
|
|
func (s *Store) ListDomains() ([]Domain, error) {
|
|
rows, err := s.db.Query(`
|
|
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
|
|
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
|
FROM domains d
|
|
ORDER BY d.name`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list domains: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []Domain
|
|
for rows.Next() {
|
|
d, err := scanDomain(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ListDomainsForUser returns the sending domains a domain user reaches, with
|
|
// application counts, ordered by name: the assigned ones, or every domain when
|
|
// the user's All flag is set — which is how a domain added later is included.
|
|
// The filter is in the query, so a caller cannot forget it.
|
|
func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) {
|
|
rows, err := s.db.Query(`
|
|
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
|
|
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
|
FROM domains d
|
|
WHERE EXISTS (SELECT 1 FROM users u WHERE u.id = ? AND u.all_domains = 1)
|
|
OR EXISTS (SELECT 1 FROM user_domains ud WHERE ud.user_id = ? AND ud.domain_id = d.id)
|
|
ORDER BY d.name`, userID, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list domains for user: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []Domain
|
|
for rows.Next() {
|
|
d, err := scanDomain(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// GetDomain returns a single domain (with its application count) by id, or
|
|
// ErrDomainNotFound.
|
|
func (s *Store) GetDomain(id int64) (Domain, error) {
|
|
row := s.db.QueryRow(`
|
|
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
|
|
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
|
FROM domains d
|
|
WHERE d.id = ?`, id)
|
|
d, err := scanDomain(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Domain{}, ErrDomainNotFound
|
|
}
|
|
if err != nil {
|
|
return Domain{}, err
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// DeleteDomain removes a domain. Its applications and their address/binding rows
|
|
// go with it via ON DELETE CASCADE (product.md). Returns ErrDomainNotFound if no
|
|
// such row existed. SQLite 3.53.4 acts on ON DELETE only on a connection with
|
|
// foreign_keys ON, which Open's DSN sets on every one
|
|
// (https://sqlite.org/foreignkeys.html § 2, § 4.3); the rows the cascade
|
|
// removes are not in RowsAffected (https://sqlite.org/c3ref/changes.html).
|
|
//
|
|
// The domain's DMARC reports go with it, in the same transaction (their
|
|
// records by the table's cascade): reports belong to a domain by name, and a
|
|
// domain deleted and added again - by anyone, for anyone - must not hand its
|
|
// old reports to whoever it is assigned to then (security audit 2026-10-17,
|
|
// A5/W2). The logs keep their rows, by id, for the global role.
|
|
func (s *Store) DeleteDomain(id int64) error {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("delete domain: begin: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck // a no-op after Commit
|
|
if _, err := tx.Exec(`DELETE FROM dmarc_reports WHERE domain = (SELECT name FROM domains WHERE id = ?)`, id); err != nil {
|
|
return fmt.Errorf("delete domain: its DMARC reports: %w", err)
|
|
}
|
|
res, err := tx.Exec("DELETE FROM domains WHERE id = ?", id)
|
|
if err != nil {
|
|
return fmt.Errorf("delete domain: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("delete domain rows: %w", err)
|
|
}
|
|
if n == 0 {
|
|
return ErrDomainNotFound
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("delete domain: commit: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// scanRow is the minimal surface shared by *sql.Row and *sql.Rows.
|
|
type scanRow interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanDomain(r scanRow) (Domain, error) {
|
|
var (
|
|
d Domain
|
|
createdAt string
|
|
)
|
|
if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &d.DMARCRua, &createdAt, &d.AppCount); err != nil {
|
|
return Domain{}, err
|
|
}
|
|
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
|
return d, nil
|
|
}
|
|
|
|
// isForeignKeyViolation reports whether err is a SQLite FOREIGN KEY conflict.
|
|
// modernc.org/sqlite v1.60.1, conn.go newConn(): extended result codes are on
|
|
// for every connection, so Error.Code() (error.go) is the extended code
|
|
// (https://sqlite.org/rescode.html#constraint_foreignkey, _unique,
|
|
// _primarykey); a unique index - the NOCASE one on logins too - fails as
|
|
// SQLITE_CONSTRAINT_UNIQUE.
|
|
func isForeignKeyViolation(err error) bool {
|
|
var se *sqlite.Error
|
|
return errors.As(err, &se) && se.Code() == sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY
|
|
}
|
|
|
|
// isUniqueViolation reports whether err is a SQLite UNIQUE/PRIMARY-KEY conflict,
|
|
// so callers can turn a duplicate insert into a friendly domain-level error.
|
|
func isUniqueViolation(err error) bool {
|
|
var se *sqlite.Error
|
|
if errors.As(err, &se) {
|
|
code := se.Code()
|
|
return code == sqlite3.SQLITE_CONSTRAINT_UNIQUE || code == sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SetDomainDMARCAddress sets where the domain's aggregate reports go; "" means
|
|
// no reports.
|
|
func (s *Store) SetDomainDMARCAddress(id int64, rua string) error {
|
|
res, err := s.db.Exec("UPDATE domains SET dmarc_rua = ? WHERE id = ?", rua, id)
|
|
if err != nil {
|
|
return fmt.Errorf("update domain dmarc rua: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("update domain dmarc rua rows: %w", err)
|
|
}
|
|
if n == 0 {
|
|
return ErrDomainNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DomainExists reports whether a sending domain with this name is configured.
|
|
// Used by the DMARC ingest path to refuse reports for domains this relay does
|
|
// not send for (security.md: report XML is attacker-supplied input). NOCASE
|
|
// folds ASCII letters only (https://sqlite.org/datatype3.html § 7), which is
|
|
// all a domain name the panel accepts holds.
|
|
func (s *Store) DomainExists(name string) (bool, error) {
|
|
var n int
|
|
err := s.db.QueryRow(
|
|
"SELECT COUNT(*) FROM domains WHERE name = ? COLLATE NOCASE", strings.TrimSpace(name),
|
|
).Scan(&n)
|
|
if err != nil {
|
|
return false, fmt.Errorf("domain exists %q: %w", name, err)
|
|
}
|
|
return n > 0, nil
|
|
}
|