From ad3d834392e96ddff1bb5186eba0dec8455b9c5d Mon Sep 17 00:00:00 2001 From: Mix Date: Wed, 14 Oct 2026 19:36:26 +0300 Subject: [PATCH] store, web: cite the source of what they rely on (assumptions part D) Comments only, no behaviour changed. modernc.org/sqlite v1.60.1 (the _pragma list on every connection, busy_timeout first; extended result codes; a deferred BEGIN; multi-statement Exec) and SQLite 3.53.4's documentation (transactions, WAL, foreign keys, NOCASE, upsert, julianday, partial indexes); htmx 2.0.4 src/htmx.js; the Fetch and HTML standards for Sec-Fetch-Site, Origin, SameSite, __Host-, CSP and defer order; Go's html/template. Comments the source contradicts are corrected - the driver does not serialise writes (SetMaxOpenConns(1) does), htmx:load fires per swapped element, no-store does not keep a page out of history. Left for their own commits: D-S3 a migration two processes can both run, D-S20 recipient case, D-B3 Origin: null under Referrer-Policy: no-referrer, D-H5/H6/H7/H9 (audit M11, stage 4). Co-Authored-By: Claude Opus 5.5 --- internal/store/applications.go | 13 ++++-- internal/store/dmarc.go | 4 +- internal/store/domains.go | 14 +++++- internal/store/inbound.go | 2 + internal/store/inboundspam.go | 5 ++- internal/store/notify.go | 7 ++- internal/store/sendercheck.go | 5 ++- internal/store/sendlog.go | 27 ++++++++++-- internal/store/sendloglines.go | 7 ++- internal/store/sessions.go | 7 ++- internal/store/settings.go | 3 +- internal/store/stats.go | 7 +++ internal/store/store.go | 28 +++++++++++- internal/store/users.go | 21 +++++++-- internal/web/auth/handlers.go | 9 ++++ internal/web/auth/middleware.go | 4 ++ internal/web/auth/session.go | 5 ++- internal/web/handlers/handlers_account.go | 2 + internal/web/handlers/handlers_apps.go | 3 ++ internal/web/handlers/handlers_settings.go | 2 + internal/web/security.go | 51 ++++++++++++++++++---- internal/web/view/components.go | 14 +++++- internal/web/view/static.go | 7 ++- internal/web/view/static/panel.js | 28 +++++++++--- internal/web/view/templates/layout.html | 6 ++- 25 files changed, 243 insertions(+), 38 deletions(-) diff --git a/internal/store/applications.go b/internal/store/applications.go index 8346ce2..cb335ad 100644 --- a/internal/store/applications.go +++ b/internal/store/applications.go @@ -12,6 +12,9 @@ import ( // ErrLoginExists is returned when an application login is already taken. The // login is globally unique because it is the SASL identity Postfix // authenticates (one sasldb2 across all domains, architecture.md § Mail path). +// It is unique without regard to case too (migration 0008's NOCASE index): +// Postfix 3.7.11, src/smtpd/smtpd_check.c reject_auth_sender_login_mismatch() +// compares the SASL login with the map's owners by strcasecmp_utf8. var ErrLoginExists = errors.New("application login already exists") // ErrApplicationNotFound is returned when an application id does not exist. @@ -74,6 +77,7 @@ type Binding struct { // login maps to ErrLoginExists. func (s *Store) AddApplication(domainID int64, login, mode string, addresses []string) (Application, error) { now := time.Now().UTC() + // The first statement is a write (see UpdateStatus). tx, err := s.db.Begin() if err != nil { return Application{}, fmt.Errorf("begin add application: %w", err) @@ -90,6 +94,8 @@ func (s *Store) AddApplication(domainID int64, login, mode string, addresses []s } return Application{}, fmt.Errorf("insert application: %w", err) } + // sqlite3_last_insert_rowid() of this connection, the transaction's own + // (https://sqlite.org/c3ref/last_insert_rowid.html). id, err := res.LastInsertId() if err != nil { return Application{}, fmt.Errorf("application id: %w", err) @@ -110,6 +116,7 @@ func (s *Store) AddApplication(domainID int64, login, mode string, addresses []s // address list atomically (product.md). The login and password are untouched. // Returns ErrApplicationNotFound if the id does not exist. func (s *Store) UpdateApplicationMode(id int64, mode string, addresses []string) error { + // The first statement is a write (see UpdateStatus). tx, err := s.db.Begin() if err != nil { return fmt.Errorf("begin update application: %w", err) @@ -353,9 +360,9 @@ func (s *Store) UpdateApplicationAuthIPs(id int64, restrict bool, ips []string) return nil } -// DeleteApplication removes an application and its addresses (via cascade), -// returning the deleted application so the caller can drop its sasldb2 entry -// (product.md). Returns ErrApplicationNotFound if no such row existed. +// DeleteApplication removes an application and its addresses (via cascade, see +// DeleteDomain), returning the deleted application so the caller can drop its +// sasldb2 entry (product.md). Returns ErrApplicationNotFound if no such row existed. func (s *Store) DeleteApplication(id int64) (Application, error) { a, err := s.GetApplication(id) if err != nil { diff --git a/internal/store/dmarc.go b/internal/store/dmarc.go index e57f120..ea5df87 100644 --- a/internal/store/dmarc.go +++ b/internal/store/dmarc.go @@ -80,8 +80,10 @@ type DMARCIngestStats struct { } // InsertDMARCReport stores a parsed report and its records, replacing any prior -// row with the same reporter/report_id/domain triple. +// row with the same reporter/report_id/domain triple; the prior report's +// records go with it by ON DELETE CASCADE (see DeleteDomain). func (s *Store) InsertDMARCReport(rep DMARCReport) (int64, error) { + // The first statement is a write (see UpdateStatus). tx, err := s.db.Begin() if err != nil { return 0, fmt.Errorf("begin insert dmarc report: %w", err) diff --git a/internal/store/domains.go b/internal/store/domains.go index 339d7d0..aa32268 100644 --- a/internal/store/domains.go +++ b/internal/store/domains.go @@ -128,7 +128,10 @@ func (s *Store) GetDomain(id int64) (Domain, error) { // 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. +// 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). func (s *Store) DeleteDomain(id int64) error { res, err := s.db.Exec("DELETE FROM domains WHERE id = ?", id) if err != nil { @@ -162,6 +165,11 @@ func scanDomain(r scanRow) (Domain, error) { } // 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 @@ -197,7 +205,9 @@ func (s *Store) SetDomainDMARCAddress(id int64, rua string) error { // 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). +// 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( diff --git a/internal/store/inbound.go b/internal/store/inbound.go index f8ab44f..08b6036 100644 --- a/internal/store/inbound.go +++ b/internal/store/inbound.go @@ -48,6 +48,7 @@ type InboundDomain struct { // caller must have validated name (security.md). func (s *Store) AddInboundDomain(name string) (InboundDomain, error) { now := time.Now().UTC() + // The first statement is a write (see UpdateStatus). tx, err := s.db.Begin() if err != nil { return InboundDomain{}, fmt.Errorf("begin add inbound domain: %w", err) @@ -189,6 +190,7 @@ func (s *Store) UpdateInboundTransport(id int64, host string, port int, tlsMode // UpdateInboundRecipients replaces the recipient mode and, in list mode, the // address list. In any mode the stored list is cleared. func (s *Store) UpdateInboundRecipients(id int64, mode string, addresses []string) error { + // The first statement is a write (see UpdateStatus). tx, err := s.db.Begin() if err != nil { return fmt.Errorf("begin update inbound recipients: %w", err) diff --git a/internal/store/inboundspam.go b/internal/store/inboundspam.go index 23388f6..45722c2 100644 --- a/internal/store/inboundspam.go +++ b/internal/store/inboundspam.go @@ -72,7 +72,8 @@ func (s *Store) InsertInboundSpam(e InboundSpamEntry) error { // detail - and says whether it wrote. The log tailer uses it for the lines it // reads when it starts: after an unclean stop those are lines it may have read // before, and a refusal read twice is otherwise two rows. e.At must be set; a -// row dated "now" is never the same as another. +// row dated "now" is never the same as another. The check and the insert are +// one statement, and one of no row reports 0 (see CreateGlobalUser). func (s *Store) InsertInboundSpamOnce(e InboundSpamEntry) (bool, error) { at := e.At.UTC().Format(time.RFC3339) res, err := s.db.Exec( @@ -103,6 +104,8 @@ func (s *Store) PruneInboundSpam(cutoff time.Time, keep int) (int64, error) { n, _ := res.RowsAffected() // Oldest by when it happened, not by id: the tailer dates a refusal by its // log line, so after the panel was down the newest ids are old rows. + // SQLite 3.53.4, https://sqlite.org/lang_select.html § 5: a negative LIMIT + // is no upper bound, so this is every row after the newest keep. res, err = s.db.Exec( `DELETE FROM inbound_spam_log WHERE id IN (SELECT id FROM inbound_spam_log ORDER BY created_at DESC, id DESC LIMIT -1 OFFSET ?)`, keep) diff --git a/internal/store/notify.go b/internal/store/notify.go index 43af752..a44383b 100644 --- a/internal/store/notify.go +++ b/internal/store/notify.go @@ -137,6 +137,9 @@ func (s *Store) DeleteNotifyState(key string) error { // ListNotifyStateKeys returns the keys that begin with prefix, so that the // states of subjects that are gone - a deleted domain - can be found. +// SQLite's substr counts characters of a TEXT value, len(prefix) its bytes +// (https://sqlite.org/lang_corefunc.html#substr): the two agree for the ASCII +// prefixes the callers pass ("dns:", "limit:"). func (s *Store) ListNotifyStateKeys(prefix string) ([]string, error) { rows, err := s.db.Query( "SELECT key FROM notify_state WHERE substr(key, 1, ?) = ? ORDER BY key", len(prefix), prefix) @@ -181,7 +184,9 @@ type LimitRefusal struct { // reason and is still judged the old way. func (s *Store) LimitRefusalsSince(since time.Time) ([]LimitRefusal, error) { // The status is a literal so that the partial index of migration 0005 - // answers the question. + // answers the question: the query's term matches the index's WHERE + // exactly (https://sqlite.org/partialindex.html § 3) when the statement is + // first prepared. rows, err := s.db.Query( `SELECT d.name, a.login, COUNT(*), MIN(l.created_at) FROM send_log l diff --git a/internal/store/sendercheck.go b/internal/store/sendercheck.go index e767004..4655199 100644 --- a/internal/store/sendercheck.go +++ b/internal/store/sendercheck.go @@ -100,6 +100,7 @@ func (s *Store) AddSenderCheckDeferred(at time.Time, n int64, place string, mess if n <= 0 { return nil } + // The first statement is a write (see UpdateStatus). tx, err := s.db.Begin() if err != nil { return fmt.Errorf("sender check deferred: %w", err) @@ -118,7 +119,9 @@ func (s *Store) AddSenderCheckDeferred(at time.Time, n int64, place string, mess } // To the fraction of a second the log gives, and of a fixed width so that // the strings compare as the times do; a re-read log must not move the - // mark back. + // mark back. SQLite 3.53.4, https://sqlite.org/lang_upsert.html § 2: a + // DO UPDATE whose WHERE is false is a no-op, not an error, and excluded. + // names the value the INSERT brought. stamp := at.UTC().Format("2006-01-02T15:04:05.000000Z") setting := func(key, value, cond string) error { _, err := tx.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) diff --git a/internal/store/sendlog.go b/internal/store/sendlog.go index 98332a7..1144feb 100644 --- a/internal/store/sendlog.go +++ b/internal/store/sendlog.go @@ -47,6 +47,11 @@ type SendLogEntry struct { // application whose login it is, NULL for a login that is no application's // (audit 2026-10-12, L12). The domain name of the row stays what the // envelope said. +// +// The login is matched exactly (BINARY): it reaches the milter and the log in +// the case it is stored in, because Cyrus SASL 2.1.28 looks it up as given - +// lib/canonusr.c _canonuser_internal() only trims spaces and appends a realm, +// sasldb/allockey.c _sasl_alloc_key() copies it byte for byte into the key. const loginDomainID = `(SELECT a.domain_id FROM applications a WHERE a.login = ?)` // InsertQueued records an accepted message in the send log with status @@ -108,7 +113,12 @@ func (s *Store) InsertRejected(e SendLogEntry) error { // first and then writes fails at once with "database is locked" when another // connection - the dmarc-ingest process opens the same file - commits in // between, and the line would be lost; a write first takes the write lock, -// and the busy timeout waits for it. +// and the busy timeout waits for it. SQLite 3.53.4, btree.c btreeBeginTrans(): +// the busy handler is retried only while the connection has no transaction +// open, so the upgrade of a read transaction fails with SQLITE_BUSY at once - +// also while the other connection merely holds the write lock - and with +// SQLITE_BUSY_SNAPSHOT after it committed (https://sqlite.org/rescode.html#busy_snapshot). +// Every other transaction of this package opens with a write for this reason. func (s *Store) UpdateStatus(queueID, recipient, status string) (int64, error) { now := time.Now().UTC() tx, err := s.db.Begin() @@ -184,7 +194,9 @@ type QueuedDelivery struct { // about them. // // created_at is stored as RFC3339 UTC, so a lexical comparison against the same -// format is chronologically correct. +// format is chronologically correct. SQLite 3.53.4 compares TEXT with the +// BINARY collation, memcmp() (https://sqlite.org/datatype3.html § 7), and +// time.RFC3339 of a UTC time is fixed-width, YYYY-MM-DDTHH:MM:SSZ. func (s *Store) ListQueuedOlderThan(cutoff time.Time) ([]QueuedDelivery, error) { rows, err := s.db.Query( `SELECT id, queue_id, to_addr FROM send_log @@ -247,6 +259,12 @@ type SendLogRow struct { // order the rows were written, so two refusals of one second take one line // each in turn (review of stage 2b). A line already on a row - read again // after a restart - goes on no other. It reports whether a row took the line. +// +// SQLite 3.53.4: the choice and the write are one UPDATE, a write transaction +// from its start (https://sqlite.org/lang_transaction.html § 2.1); julianday() +// reads YYYY-MM-DDTHH:MM:SSZ, the T and the Z included (lang_datefunc.html +// § 2); NOCASE folds ASCII only (datatype3.html § 7.1); ?1 is bound once and +// used twice (lang_expr.html § 4). func (s *Store) AttachRefusalLine(clientIP, from string, at time.Time, line string) (bool, error) { res, err := s.db.Exec( `UPDATE send_log SET refusal_line = ?1 WHERE id = ( @@ -277,7 +295,8 @@ func (s *Store) AttachRefusalLine(clientIP, from string, at time.Time, line stri // and nothing is. The domain is the login's, or - when the tailer did not see the login, its // client= line read before a restart - the envelope's, so the row is still // its domain's administrator's (review of stage 2b). It reports whether it -// wrote one. +// wrote one: the check and the insert are one statement (see +// CreateGlobalUser), and an INSERT ... SELECT of no row reports 0. func (s *Store) RecoverRefusal(e SendLogEntry, at time.Time) (bool, error) { stamp := at.UTC().Format(time.RFC3339) res, err := s.db.Exec( @@ -433,7 +452,7 @@ func sendLogWhere(f SendLogFilter) (string, []any) { // implementing the configurable retention window (architecture.md § // Persistence, SEND_LOG_RETENTION_DAYS). It returns the number of rows pruned. // created_at is stored as RFC3339 UTC, so a lexical comparison against the -// same format is chronologically correct. +// same format is chronologically correct (see ListQueuedOlderThan). func (s *Store) DeleteSendLogBefore(cutoff time.Time) (int64, error) { res, err := s.db.Exec( `DELETE FROM send_log WHERE created_at < ?`, diff --git a/internal/store/sendloglines.go b/internal/store/sendloglines.go index 5977476..35687cb 100644 --- a/internal/store/sendloglines.go +++ b/internal/store/sendloglines.go @@ -43,7 +43,12 @@ func cutSendLogLine(line string) string { // wrote. The tailer calls it with what it read in one pass over the log, // whether or not send-log rows exist for the queue ids yet: a commit per line // would be a disk sync per line, on the one connection the journal milter -// writes through. +// writes through. SQLite 3.53.4 as modernc builds it keeps synchronous at FULL +// (SQLITE_DEFAULT_WAL_SYNCHRONOUS 2), and in WAL mode FULL syncs the WAL at +// every commit (https://sqlite.org/wal.html § 2.3, pragma.html#pragma_synchronous). +// The first statement stepped is an INSERT, a write (see UpdateStatus): +// tx.Prepare leaves no transaction open (prepare.c sqlite3InitOne() commits +// the read it opens to load the schema). // // With once set, a line is written only if the same line is not there already // under its queue id. That is for the lines the tailer reads again after an diff --git a/internal/store/sessions.go b/internal/store/sessions.go index 6032b57..9ca96c1 100644 --- a/internal/store/sessions.go +++ b/internal/store/sessions.go @@ -30,7 +30,10 @@ func (s *Store) CreateSession(tokenHash string, userID int64, expiresAt time.Tim // LookupSession returns the session for tokenHash, if any. It does not check // expiry itself — callers compare ExpiresAt against time.Now() and call -// DeleteSession on an expired row, keeping the read side lock-free. +// DeleteSession on an expired row, keeping the read side lock-free: in WAL +// mode a reader takes no write lock and is not blocked by a writer +// (https://sqlite.org/wal.html § 2.2). Within this process it still waits for +// the one pooled connection (Open). func (s *Store) LookupSession(tokenHash string) (SessionRow, bool, error) { var ( row SessionRow @@ -99,6 +102,8 @@ func (s *Store) DeleteUserSessions(userID int64) error { // abandoned session (cookie never presented again) does not sit in the table // forever. It piggybacks on session creation rather than running as its own // background loop. +// expires_at is RFC3339 UTC text, so < compares moments (see +// ListQueuedOlderThan). func (s *Store) DeleteExpiredSessions(now time.Time) (int64, error) { res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, now.UTC().Format(time.RFC3339)) if err != nil { diff --git a/internal/store/settings.go b/internal/store/settings.go index ed4c6a9..1723054 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -156,7 +156,8 @@ func (s *Store) GetSetting(key string) (string, error) { return value, nil } -// SetSetting upserts a settings key. +// SetSetting upserts a settings key (https://sqlite.org/lang_upsert.html: on +// a conflict of the key, excluded.value is the value the INSERT brought). func (s *Store) SetSetting(key, value string) error { _, err := s.db.Exec( "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", diff --git a/internal/store/stats.go b/internal/store/stats.go index f1d5191..e9f01b7 100644 --- a/internal/store/stats.go +++ b/internal/store/stats.go @@ -85,6 +85,10 @@ const domainByName = "domain_id = (SELECT d.id FROM domains d WHERE d.name = ?)" // login can be taken again in another domain, and its numbers - statistics, // the level-2 count - must not start with the old one's (review of audit // stage 2). ?1 twice; a plain ? after it takes the next number. +// SQLite 3.53.4, https://sqlite.org/lang_expr.html § 4: a plain ? is numbered +// one more than the largest number already assigned; modernc.org/sqlite +// v1.60.1, conn.go bind() and argFinder.find(): ?NNN takes argument NNN, a +// plain ? the argument of its number. const appByLogin = "app_login = ?1 AND domain_id = (SELECT a.domain_id FROM applications a WHERE a.login = ?1)" // sendStats counts the rows that match, a condition with one placeholder @@ -102,6 +106,9 @@ func (s *Store) sendStats(match, ref string, since time.Time, hours float64) (Se return SendStats{}, fmt.Errorf("send stats total %q: %w", ref, err) } + // The busiest UTC hour: substr counts characters from 1 + // (https://sqlite.org/lang_corefunc.html#substr), so 1-13 of an RFC3339 + // UTC stamp is YYYY-MM-DDTHH. var peak int64 err = s.db.QueryRow( `SELECT COALESCE(MAX(bucket_count), 0) FROM ( diff --git a/internal/store/store.go b/internal/store/store.go index 85af0d1..13abd76 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -29,13 +29,23 @@ type Store struct { func Open(path string) (*Store, error) { // _pragma parameters are applied on every pooled connection by the driver, // so foreign-key enforcement and WAL survive connection churn. + // modernc.org/sqlite v1.60.1, conn.go newConn() -> sqlite.go + // applyQueryParams(): the _pragma list runs on every new connection, + // busy_timeout first. SQLite 3.53.4: foreign_keys is per connection + // (https://sqlite.org/foreignkeys.html § 2), journal_mode=WAL stays in the + // file (https://sqlite.org/wal.html § 3.3). dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)", path) db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open database: %w", err) } - // modernc's driver serializes writes anyway; a small pool avoids - // "database is locked" surprises under WAL. + // One connection: database/sql then runs this process's statements and + // transactions one after another, so none of them meets another's lock. + // The driver does not serialise across connections (modernc.org/sqlite + // v1.60.1, conn.go: the conn's mutex is its own); between processes - the + // dmarc-ingest command opens this file too - SQLite allows one write + // transaction at a time (https://sqlite.org/lang_transaction.html § 2.1) + // and the other waits out busy_timeout. db.SetMaxOpenConns(1) s := &Store{db: db} @@ -83,6 +93,10 @@ func (s *Store) migrate() error { // against today's files would skip the baseline and leave the panel running // on a schema it does not know, so such a file is refused outright; nothing // is migrated from 1.x (docs/schema-migrations.md). + // SQLite 3.53.4: the application id is the header's integer at offset 68 + // (https://sqlite.org/fileformat2.html § 1.3.15); btree.c newDatabase() + // zeroes bytes 24-99 of a new file's header, so it reads 0 until set - and + // 1.x never set it. if version > 0 { var appID int64 if err := s.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil { @@ -109,12 +123,22 @@ func (s *Store) migrate() error { if err != nil { return fmt.Errorf("begin migration %s: %w", name, err) } + // One Exec runs the whole file: modernc.org/sqlite v1.60.1, stmt.go + // stmt.exec(), "FALLBACK PATH: Multi-statement script" prepares and + // steps each statement of the tail in turn. It runs with foreign_keys + // ON, which cannot be switched off inside a transaction + // (https://sqlite.org/pragma.html#pragma_foreign_keys): an ADD COLUMN + // with REFERENCES needs a NULL default (lang_altertable.html § 4), and + // a changed collation means a rebuilt table (§ 8). if _, err := tx.Exec(string(sqlBytes)); err != nil { tx.Rollback() return fmt.Errorf("apply migration %s: %w", name, err) } // PRAGMA does not accept a bound parameter, and target is a trusted // loop index, so formatting it in is safe. + // SQLite 3.53.4, pragma.c sqlite3Pragma(), PragTyp_HEADER_VALUE: the + // value is the literal's text (sqlite3Atoi), written by OP_SetCookie + // under a write transaction, so a rollback takes it back with the rest. if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", target)); err != nil { tx.Rollback() return fmt.Errorf("bump schema version for %s: %w", name, err) diff --git a/internal/store/users.go b/internal/store/users.go index 11c3057..dc0af17 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -83,6 +83,10 @@ func (s *Store) UserExists() (bool, error) { // CreateGlobalUser inserts the first global user during setup. The check that // no user exists yet and the insert are one statement, so two setup requests // at once cannot both create one (audit 2026-10-12, M7). +// SQLite 3.53.4, https://sqlite.org/lang_transaction.html § 2.1: an INSERT +// starts a write transaction, one at a time, so its SELECT reads what is +// committed; an insert of no row leaves sqlite3_changes() at 0 +// (https://sqlite.org/c3ref/changes.html). func (s *Store) CreateGlobalUser(username, passwordHash string) error { now := time.Now().UTC().Format(time.RFC3339) res, err := s.db.Exec( @@ -249,6 +253,7 @@ func (s *Store) AddUser(c UserChange) (int64, error) { if c.Role == RoleDomain && c.Reach.Empty() { return 0, ErrEmptyReach } + // The first statement is a write (see UpdateStatus). tx, err := s.db.Begin() if err != nil { return 0, fmt.Errorf("create user: %w", err) @@ -302,7 +307,12 @@ func (s *Store) SaveUser(c UserChange) error { } defer tx.Rollback() // A write first: it takes the write lock, so what is counted below is - // what is committed. + // what is committed. SQLite 3.53.4, https://sqlite.org/lang_transaction.html + // § 2.2: after a deferred BEGIN, a first statement that writes starts a + // write transaction, and § 2.1: there is one at a time. RowsAffected counts + // the row its WHERE found even when no value changes: update.c + // sqlite3Update() writes every such row through sqlite3CompleteInsertion(), + // which marks it counted (OPFLAG_NCHANGE). // An empty hash keeps the stored one: the form read it when it was // opened, and the user may have changed it on Account since. res, err := tx.Exec( @@ -403,7 +413,9 @@ func (s *Store) SetUserReach(userID int64, reach Reach) error { // DeleteUser removes a panel user. ErrLastGlobal when deleting the only global // user: the delete and the count are one transaction, so two administrators -// deleting each other at once cannot both succeed (audit 2026-10-12, M7). +// deleting each other at once cannot both succeed (audit 2026-10-12, M7). The +// DELETE comes first and so takes the write lock (see SaveUser); the rows its +// cascade removes are not in RowsAffected (https://sqlite.org/c3ref/changes.html). func (s *Store) DeleteUser(id int64) error { tx, err := s.db.Begin() if err != nil { @@ -517,7 +529,10 @@ func (s *Store) setUserReach(userID int64, reach Reach) error { // writeUserReach writes a user's reach inside tx. With an All flag set the // rows of that direction are dropped rather than stored: the flag already // covers them, and stale rows would come back to life the day the flag is -// cleared. +// cleared. Its first statement is a write (see UpdateStatus). A domain deleted +// meanwhile fails its INSERT at once: SQLite 3.53.4's foreign keys are +// immediate unless declared deferred (https://sqlite.org/foreignkeys.html § 4.2), +// and the code is SQLITE_CONSTRAINT_FOREIGNKEY (isForeignKeyViolation). func writeUserReach(tx *sql.Tx, userID int64, reach Reach) error { if _, err := tx.Exec("UPDATE users SET all_domains = ?, all_inbound_domains = ? WHERE id = ?", reach.AllDomains, reach.AllInbound, userID); err != nil { diff --git a/internal/web/auth/handlers.go b/internal/web/auth/handlers.go index 37fdb4e..c25eb74 100644 --- a/internal/web/auth/handlers.go +++ b/internal/web/auth/handlers.go @@ -11,6 +11,9 @@ import ( "golang.org/x/crypto/bcrypt" ) +// draft-ietf-httpbis-rfc6265bis § 4.1.3.2: a cookie named __Host- is accepted +// only with Secure, Path=/ and no Domain, from a secure origin - so no other +// host can set or overwrite it, and clearing it needs Secure as well. const ( sessionCookieBase = "selfpost_session" sessionCookiePrefixed = "__Host-" + sessionCookieBase @@ -46,6 +49,9 @@ func (m *Module) sessionToken(r *http.Request) (string, bool) { case 1: return token, true default: + // RFC 6265 § 5.4: the Cookie header carries every cookie that + // matches, a parent domain's of the same name included. The prefixed + // name cannot be planted that way (see sessionCookiePrefixed). logf("panel: %s %s carries %d cookies named %q — treating the request as signed out; "+ "another host on this domain is overwriting the session cookie, clear the cookies for the parent domain", r.Method, r.URL.Path, n, name) @@ -130,6 +136,9 @@ func (m *Module) submitLogin(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusSeeOther) } +// setSessionCookie sets the session cookie. SameSite=Lax withholds it from a +// cross-site request unless that is a top-level navigation with a safe method +// (rfc6265bis § 5.8.3, § 5.6.7.1), so a cross-site POST arrives without it. func (m *Module) setSessionCookie(w http.ResponseWriter, token string) { http.SetCookie(w, &http.Cookie{ Name: m.sessionCookie(), diff --git a/internal/web/auth/middleware.go b/internal/web/auth/middleware.go index b68464d..b3a53af 100644 --- a/internal/web/auth/middleware.go +++ b/internal/web/auth/middleware.go @@ -34,6 +34,10 @@ func (m *Module) RequireAuth(next http.Handler) http.Handler { }) } +// isSessionActivity: a GET from htmx is a background poll - every panel hx-get +// is a load or a poll - and does not renew the session. htmx 2.0.4, src/htmx.js +// getHeaders(): every request it issues carries HX-Request: true (and +// loadHistoryFromServer() sets it too). func isSessionActivity(r *http.Request) bool { return !(r.Method == http.MethodGet && r.Header.Get("HX-Request") != "") } diff --git a/internal/web/auth/session.go b/internal/web/auth/session.go index d2d0146..625441f 100644 --- a/internal/web/auth/session.go +++ b/internal/web/auth/session.go @@ -31,7 +31,10 @@ func newSessionStore(st *store.Store, idle time.Duration) *sessionStore { // MaxAge is the session cookie's Max-Age in seconds, kept equal to the // sliding idle window so the browser drops the cookie no later than the -// server would have expired it anyway. +// server would have expired it anyway. RFC 6265 § 5.2.2: Max-Age sets the +// expiry to now plus that many seconds, and a cookie set again under the same +// name, domain and path replaces the old one (§ 5.3 step 11), so each renewal +// slides it. func (s *sessionStore) MaxAge() int { return int(s.idle.Seconds()) } diff --git a/internal/web/handlers/handlers_account.go b/internal/web/handlers/handlers_account.go index f958e50..e6d698d 100644 --- a/internal/web/handlers/handlers_account.go +++ b/internal/web/handlers/handlers_account.go @@ -92,6 +92,8 @@ func (h *Handlers) HandleAccountProfile(w http.ResponseWriter, r *http.Request) f.Username = strings.TrimSpace(r.PostFormValue("username")) f.Email = strings.TrimSpace(r.PostFormValue("email")) // An unticked checkbox is not sent at all: absent is off. + // HTML Living Standard, form-control-infrastructure.html#constructing-entry-list: + // a checkbox whose checkedness is false is skipped. f.Notify = r.PostFormValue("notify") != "" if f.Username == "" { f.Username = u.Username diff --git a/internal/web/handlers/handlers_apps.go b/internal/web/handlers/handlers_apps.go index c6efebd..e8c4448 100644 --- a/internal/web/handlers/handlers_apps.go +++ b/internal/web/handlers/handlers_apps.go @@ -395,6 +395,9 @@ func (h *Handlers) recalcApp(appID int64) error { // noStore keeps a page that shows a secret out of every cache and out of the // browser's history copy: the password on it is shown once. +// RFC 9111 § 5.2.2.5: no-store keeps it out of every HTTP cache, private and +// shared. The history part is not what it governs (§ 6), and Pragma has no +// defined meaning in a response (§ 5.4): assumptions-2026-10-14, D-B13. func noStore(w http.ResponseWriter) { w.Header().Set("Cache-Control", "no-store") w.Header().Set("Pragma", "no-cache") diff --git a/internal/web/handlers/handlers_settings.go b/internal/web/handlers/handlers_settings.go index 102cc65..0f19eaa 100644 --- a/internal/web/handlers/handlers_settings.go +++ b/internal/web/handlers/handlers_settings.go @@ -102,6 +102,8 @@ func (h *Handlers) submitServerSettings(w http.ResponseWriter, r *http.Request) } } // An unticked checkbox is not sent at all: absent is off. + // HTML Living Standard, form-control-infrastructure.html#constructing-entry-list: + // a checkbox whose checkedness is false is skipped. notifications := r.PostFormValue("notify") != "" if err := h.store.SetSendLogRetentionDays(days); err != nil { logf("panel: settings: set send-log retention failed: %v", err) diff --git a/internal/web/security.go b/internal/web/security.go index fc55ccf..553bfb7 100644 --- a/internal/web/security.go +++ b/internal/web/security.go @@ -19,8 +19,17 @@ import ( // relative URL on the page, including the form actions; // - form-action 'self' keeps a form from being retargeted at another host. // +// CSP Level 3, https://www.w3.org/TR/CSP3/ § 6.8.3 "Get fetch directive +// fallback list": script, style, img, font, connect and object fall back to +// default-src; base-uri (§ 6.3.1), form-action (§ 6.4.1) and frame-ancestors +// (§ 6.4.2) are not in any list, so each is set here. +// // This is a second line of defence: XSS is already prevented by -// html/template's contextual auto-escaping (security.md). +// html/template's contextual auto-escaping (security.md). Go 1.26, +// html/template doc.go "Security Model": template authors are trusted, the +// data is not. An attribute it does not know (hx-get, hx-swap, popovertarget) +// is escaped as plain text (attr.go attrType()), so the hx-get paths are built +// by the server, never taken from input. const contentSecurityPolicy = "default-src 'self'; " + "object-src 'none'; " + "base-uri 'none'; " + @@ -31,8 +40,10 @@ const contentSecurityPolicy = "default-src 'self'; " + // (CookieSecure, the same condition that gates the cookie's Secure attribute): // on the plain-HTTP development setup it would be meaningless at best and // would pin the browser to a scheme the dev instance does not speak at worst. +// RFC 6797 § 8.1: a header received over insecure transport is ignored. // -// Deliberately without includeSubDomains. The panel commonly lives on a +// Deliberately without includeSubDomains (RFC 6797 § 6.1.2: only with it does +// the policy reach the host's subdomains). The panel commonly lives on a // subdomain of a domain used for other things, but it may also sit at the // apex — and there the directive would force HTTPS on every unrelated // subdomain of that domain for a year, which is not SelfPost's call to make. @@ -51,6 +62,8 @@ func (s *Server) secure(next http.Handler) http.Handler { h.Set("X-Content-Type-Options", "nosniff") // Equivalent to the CSP's frame-ancestors directive, kept for browsers // that predate it. The two never disagree: both mean "never framed". + // CSP3 § 6.4.2.2: 'none' is DENY, and where frame-ancestors is + // enforced X-Frame-Options is ignored. h.Set("X-Frame-Options", "DENY") // The panel's behaviour never depends on the referrer, so the strictest // value is free: panel URLs carry domain names and record identifiers @@ -65,7 +78,12 @@ func (s *Server) secure(next http.Handler) http.Handler { // being the name the browser actually used, which every reverse // proxy fragment this project ships preserves (Apache // ProxyPreserveHost On, nginx proxy_set_header Host $host, Caddy - // and Traefik by default) — but a proxy that rewrites Host turns + // and Traefik by default: httpd.apache.org/docs/2.4/mod/mod_proxy.html + // #proxypreservehost; nginx.org ngx_http_proxy_module + // #proxy_set_header, whose default is $proxy_host; Caddy 2 + // reverse_proxy § Headers > Defaults, "passes through incoming + // headers - including Host"; Traefik service load balancer, + // passHostHeader true by default) — but a proxy that rewrites Host turns // every form submission into a 403, and without these two values // side by side the symptom just looks like "the panel stopped // saving anything". @@ -87,6 +105,9 @@ func (s *Server) secure(next http.Handler) http.Handler { // domain, a forgotten staging host — counts as same-site and its forged POST // would arrive with the session cookie attached. Sec-Fetch-Site and Origin are // judged per *origin*, so they tell that neighbour apart from the panel. +// draft-ietf-httpbis-rfc6265bis § 5.2 (same-site is the [SAMESITE] "same +// site" of the two origins); W3C Fetch Metadata § 2.3 (Sec-Fetch-Site: +// same-origin only if every URL of the request is same origin). // // Read-only methods are exempt: every GET route in the panel is a read (the // delete confirmation at GET /outbound/domains/{id}/delete only renders a form), so @@ -100,26 +121,40 @@ func originAllowed(r *http.Request) bool { // The browser's own account of where the request came from. It cannot be // set by page script (a forbidden header name), so when it is present it // is the answer: anything but same-origin — including "none", a - // navigation with no initiator — is refused. + // navigation with no initiator — is refused. Fetch Standard § 2.2.2: a + // header name starting with `Sec-` is a forbidden request-header; Fetch + // Metadata § 2.3: "none" is a navigation the user started directly. if site := r.Header.Get("Sec-Fetch-Site"); site != "" { return site == "same-origin" } - // Older browsers send only Origin. Compare hosts, not full origins: the - // panel speaks plain HTTP behind the proxy and does not know its own - // external scheme, while Origin will say https. + // Older browsers send only Origin, and so does a current one over plain + // HTTP at a name other than localhost: Fetch Metadata § 3 appends its + // headers only for a potentially trustworthy URL. Fetch Standard § 3.2: a + // request that is neither GET nor HEAD carries Origin. (Which value it + // carries under this panel's Referrer-Policy: assumptions-2026-10-14, D-B3.) + // Compare hosts, not full origins: the panel speaks plain HTTP behind the + // proxy and does not know its own external scheme, while Origin will say + // https. origin := r.Header.Get("Origin") if origin == "" { // Neither header. A client this old cannot be checked at all; it is // let through as the risk consciously accepted (single-admin panel, // the administrator picks the browser). Turning this return into - // false is the whole of the stricter policy. + // false is the whole of the stricter policy. By Fetch § 3.2 a browser + // that follows it never sends a POST without Origin; Firefox did for a + // same-origin form before 70 (MDN browser-compat-data 6.0.0, + // http.headers.Origin). return true } u, err := url.Parse(origin) if err != nil || u.Host == "" { // Unparseable, or the opaque "null" origin (sandboxed iframe, // redirected cross-origin POST) — not this panel either way. + // Fetch Standard § 2.2.5 "serializing a request origin": "null" for a + // redirect-tainted request; HTML § 7.1.1: an opaque origin serialises + // to "null"; url.Parse("null") has no Host. (Fetch § 3.2 also sends + // "null" under Referrer-Policy no-referrer: D-B3 of the same register.) return false } return u.Host == r.Host diff --git a/internal/web/view/components.go b/internal/web/view/components.go index a792267..03b6e0c 100644 --- a/internal/web/view/components.go +++ b/internal/web/view/components.go @@ -110,6 +110,12 @@ type Tag struct { // why a message was decided the way it was). It is the browser's own popover: // the tag is a button that names the sheet by ID, so ID is unique on the page. // Its content is a table of name and value. +// +// HTML Living Standard, popover.html: popovertarget on a button shows or hides +// (popovertargetaction="hide") the element with that id, with no script; a +// browser without the popover attribute (before Chrome 114, Firefox 125, +// Safari 17 - MDN browser-compat-data 6.0.0, html.global_attributes.popover) +// does not open it. type Sheet struct { ID string Tag Tag @@ -163,7 +169,9 @@ func StatusTag(status string) string { // wbrAt escapes s and puts a after every @ (template function wbr_at): // an address in a table cell breaks where it reads naturally, so the column can -// shrink without splitting a name mid-word. +// shrink without splitting a name mid-word. Go 1.26, text/template funcs.go +// HTMLEscape(): HTMLEscapeString escapes < > & ' " and NUL, so the only markup +// in the result is the added here. func wbrAt(s string) template.HTML { return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(s), "@", "@")) } @@ -283,6 +291,10 @@ type Box struct { Poll string // OOB marks the box to be swapped into the page by its ID when it comes // in a polled fragment beside the element that asked for the fragment. + // htmx 2.0.4, src/htmx.js swap(): the response replaces only the target; + // findAndSwapOobElements() takes every hx-swap-oob element out of it, + // nested ones too (allowNestedOobSwaps defaults true), and oobSwap() + // with "true" swaps it outerHTML over the element of its id. OOB bool // DomainPick: see Box. DomainPick bool diff --git a/internal/web/view/static.go b/internal/web/view/static.go index f919983..28c2dc2 100644 --- a/internal/web/view/static.go +++ b/internal/web/view/static.go @@ -16,7 +16,10 @@ import ( // a response carries no validator at all and the browser is free to guess how // long to keep it — which is how a replaced favicon keeps showing the old mark // long after a deploy. Hashing the content gives every asset a validator that -// changes exactly when the asset does. +// changes exactly when the asset does. RFC 9111 § 4.2.2: without explicit +// freshness a cache may assign a heuristic lifetime; Go 1.26, net/http fs.go +// ServeContent(): no Last-Modified for a zero modtime, and an ETag the caller +// set answers If-None-Match. var staticETags = buildStaticETags() func buildStaticETags() map[string]string { @@ -50,6 +53,8 @@ func buildStaticETags() map[string]string { // handful of small files on a single-operator panel that trade is the right way // round — correctness after a deploy matters more than saving the round trip. // http.ServeContent answers the conditional request from the ETag we set here. +// RFC 9111 § 5.2.2.4: no-cache lets a cache store the response but not reuse +// it without validating it first. func StaticHandler() http.Handler { files := http.FileServer(http.FS(assetsFS)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/view/static/panel.js b/internal/web/view/static/panel.js index 680a0c8..8039623 100644 --- a/internal/web/view/static/panel.js +++ b/internal/web/view/static/panel.js @@ -10,7 +10,8 @@ // read from the field itself, so it can never drift from what is shown. Where // the clipboard API is absent (plain HTTP outside localhost) the value is // selected instead, ready for the operator's own Ctrl+C. The button's icon - // stays; only its word changes for a moment. + // stays; only its word changes for a moment. W3C Clipboard API, § Navigator + // interface: navigator.clipboard is [SecureContext]. document.addEventListener("click", function (ev) { var button = ev.target.closest("button[data-copy]"); if (!button) { @@ -69,6 +70,9 @@ // inline script. The listener is delegated from the document, // so it also covers markup swapped in by HTMX. With JavaScript disabled the // form submits without asking — exactly as the inline handler behaved. + // HTML Living Standard, form-control-infrastructure.html#form-submission-algorithm: + // submit is fired with bubbles and cancelable true, and a cancelled one + // ends the submission. document.addEventListener("submit", function (ev) { var form = ev.target.closest("form[data-confirm]"); if (form && !window.confirm(form.dataset.confirm)) { @@ -140,6 +144,10 @@ form.querySelectorAll(rule.target).forEach(function (target) { // hidden alone does not hide an element whose own stylesheet gives it a // display (Bulma's .box), so Bulma's is-hidden helper goes with it. + // HTML Living Standard, rendering.html#hidden-elements: [hidden] is + // display: none in the user agent's sheet, which an author rule such + // as Bulma 1.0.4's .box { display: block } overrides; .is-hidden is + // display: none !important. target.hidden = !show; target.classList.toggle("is-hidden", !show); if (!show && rule.clearWhenHidden) { @@ -169,19 +177,26 @@ initShowWhen(document); }); - // htmx:load fires for every element htmx puts into the page, so swapped-in - // markup starts with its blocks shown or hidden the way a full page does. + // htmx:load fires for each top-level element htmx puts into the page, so + // swapped-in markup starts with its blocks shown or hidden the way a full + // page does. htmx 2.0.4, src/htmx.js insertNodesBefore() queues + // makeAjaxLoadTask(child) for each element of the swapped content, which + // triggers htmx:load on it (detail.elt); initShowWhen looks inside it. document.addEventListener("htmx:load", function (ev) { initShowWhen(ev.detail.elt); }); // --- Adaptive monitoring polling --------------------------------------- // The four monitoring fragments carry data-poll and hx-trigger="load" for - // the first fetch only. panel.js schedules the rest: 5 s while the operator + // the first fetch only (htmx 2.0.4, src/htmx.js addTriggerHandler(): "load" + // fires once, when the element is first processed). panel.js schedules the rest: 5 s while the operator // is active on the page, 30 s when the tab is visible but idle, and nothing // while the tab is hidden. hx-trigger="every Ns [expr]" could express some // of that, but the filter is evaluated with `new Function`, which the - // panel's CSP (default-src 'self', no 'unsafe-eval') would silently break. + // panel's CSP (default-src 'self', no 'unsafe-eval') would break: htmx + // 2.0.4, src/htmx.js maybeGenerateConditional() compiles the filter with + // Function(); the EvalError is caught, logged as htmx:syntax:error, and the + // trigger is kept without its filter, so it would fire on every tick. var pollActiveMs = 5000; var pollIdleMs = 30000; // No pointer/keyboard/scroll input for this long → treat the tab as idle. @@ -238,6 +253,9 @@ } // Swapped-in markup still carries hx-trigger="load"; strip it so htmx does // not issue a duplicate GET on top of the response we just received. + // htmx 2.0.4, src/htmx.js swap(): htmx:afterSwap fires on the new + // elements before doSettle() runs the settle tasks, and only those + // (makeAjaxLoadTask -> processNode) act on hx-trigger. el.removeAttribute("hx-trigger"); schedulePoll(el); } diff --git a/internal/web/view/templates/layout.html b/internal/web/view/templates/layout.html index 9c4788b..57688fc 100644 --- a/internal/web/view/templates/layout.html +++ b/internal/web/view/templates/layout.html @@ -32,7 +32,11 @@ the head for the request-indicator classes. The panel uses no hx-indicator, and that injected element would be the one thing the Content-Security-Policy has to make an exception for, so it - is switched off here. */}} + is switched off here. htmx 2.0.4, src/htmx.js: the ready handler runs + mergeMetaConfig() before insertIndicatorStyles(), which adds the element + only while includeIndicatorStyles is not false. Both scripts are defer: + they run in this order once parsing ends and before DOMContentLoaded + (HTML Living Standard, parsing.html#the-end). */}}