audit 4: findings of the two reviews
test / test (push) Canceled after 0s

- Two inbound domains on one upstream could never change its TLS mode,
  each save refused for the other (medium): SetTransportAs changes the
  mode for every domain on the upstream when the one saving may change
  them all; a domain user who may not is refused with the mode, not the
  other tenant's domain name (security F1: the first version named it).
- A deletion's leftover DKIM key is removed by Resync (Reload
  configuration, a restore's first start) for names no domain has.
- The backup's write deadline moves before Check.
- hasUnsavedEdit skips password and file fields and compares a multiple
  select's options; htmx.config.timeout 30 s, so a refresh never answered
  ends and is tried again; a failed refresh's promise is handled.
- An empty filtered Outbound log says no message matches the filters.
- The dummy bcrypt hash is made at start; a DMARC part over 8 MiB is
  refused for its size like the other two limits.
- Health's Configuration box says what Reload writes (design first,
  32dfe4a).
- Docs: CHANGELOG, guide § Inbound, security.md (the shared upstream's
  mode, the profile form's "username taken"), architecture.md (owed to an
  address), the plan's notes corrected.
Tests: the shared upstream (service, handler), the leftover key, all eight
Health boxes ordered, the subject from before migration 0011, the new JS
cases.

Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 636 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-15 12:46:52 +03:00
co-authored by Claude Opus 5.5
parent 32dfe4a2cf
commit eaa7de0754
23 changed files with 359 additions and 83 deletions
+15 -7
View File
@@ -61,12 +61,13 @@ Migration `0011` (a table, nothing existing changes).
- **Recalculate rate limit dropped what was typed on the page.** The button - **Recalculate rate limit dropped what was typed on the page.** The button
leaves the page; when a field holds an edit not saved yet, it now asks leaves the page; when a field holds an edit not saved yet, it now asks
first (audit 2026-10-12, L19). first (audit 2026-10-12, L19).
- **Deleting a domain could leave its applications able to send.** When - **A domain deletion that failed half way said "internal error".** When
OpenDKIM's tables failed to rebuild after the domain's row was gone, the OpenDKIM's tables failed to rebuild after the domain's row was gone, the
sender map was not rebuilt either and kept the deleted applications' sender map was not rebuilt either and kept the deleted applications'
logins, and the page said "internal error". Every rebuild now runs, and logins (their accounts were already gone), and the DKIM key stayed. Every
the page says the domain is deleted and that Reload configuration rebuild now runs, the page says the domain is deleted and that Reload
finishes the rest (audit 2026-10-12, L17). configuration finishes the rest, and Reload configuration removes the
keys of domains that no longer exist (audit 2026-10-12, L17).
- **A notification whose copy failed for one user was never sent to them** - **A notification whose copy failed for one user was never sent to them**
once another user's copy had gone out, and a copy held back by the hourly once another user's copy had gone out, and a copy held back by the hourly
ceiling was dropped. A notification is now owed to each recipient: the ceiling was dropped. A notification is now owed to each recipient: the
@@ -87,9 +88,16 @@ Migration `0011` (a table, nothing existing changes).
failed as damaged; it is now refused for its size, and `mail.log` says so. failed as damaged; it is now refused for its size, and `mail.log` says so.
- **Two inbound domains to one upstream with different TLS modes** wrote two - **Two inbound domains to one upstream with different TLS modes** wrote two
policy lines for one next hop, and Postfix used the first; the form's policy lines for one next hop, and Postfix used the first; the form's
choice for the other domain did nothing. Saving the second is refused, choice for the other domain did nothing. An upstream now has one TLS mode:
naming the domain whose mode the upstream has, and the policy map holds saving it for one domain changes it for the others forwarded there, when
each next hop once (audit 2026-10-12, L5). the one saving manages them all; a domain administrator who does not is
refused, told the mode in use and not the other domain. The policy map
holds each next hop once (audit 2026-10-12, L5).
- **A monitoring refresh that never got an answer stopped the refreshes.**
Requests the panel's pages make in the background now give up after 30
seconds and are tried again.
- **An empty filtered Outbound log said "No messages logged yet."** It says
that no message matches the filters.
## [2.5.3] - 2026-10-15 ## [2.5.3] - 2026-10-15
+4 -1
View File
@@ -401,7 +401,10 @@ is sent; the operator's view is guide § Notifications.
who are still recipients. A user who comes to hear of a subject only while who are still recipients. A user who comes to hear of a subject only while
it is open is not mailed about it, and its ending goes to those who were it is open is not mailed about it, and its ending goes to those who were
told. A subject opened before `2.5.4` has no such record, and its ending told. A subject opened before `2.5.4` has no such record, and its ending
goes to every recipient, as it did. goes to every recipient, as it did. It is owed to an address: a user who
changes their e-mail while a subject is open is a new address, told
neither its follow-up nor its end - accepted, a subject lasts hours to
days.
- **The notifier's own mail is not a subject.** A notification waiting for a - **The notifier's own mail is not a subject.** A notification waiting for a
mailbox that does not answer, or its bounce coming back to mailbox that does not answer, or its bounce coming back to
`postmaster@<hostname>` - which receives no mail - is left out of the `postmaster@<hostname>` - which receives no mail - is left out of the
+30 -2
View File
@@ -1200,7 +1200,8 @@ command whose leftover is the owner's alone.
would have been cut at 60 s, so `HandleBackup` moves its write deadline would have been cut at 60 s, so `HandleBackup` moves its write deadline
to two hours through `http.ResponseController` (no wrapper of the to two hours through `http.ResponseController` (no wrapper of the
ResponseWriter in the chain; e2e checks the log has no failure to move ResponseWriter in the chain; e2e checks the log has no failure to move
it). A test pins the four values. it). A test checks the four are set and long enough for a form and a
DNS page.
- [x] **Polling recovery**: schedule from `htmx:afterRequest` on the box (or pass - [x] **Polling recovery**: schedule from `htmx:afterRequest` on the box (or pass
`source`), handle `sendError`/`timeout` with back-off; `RequireAuth` `source`), handle `sendError`/`timeout` with back-off; `RequireAuth`
answers an `HX-Request` with `HX-Redirect: /login` instead of a 303. A answers an `HX-Request` with `HX-Redirect: /login` instead of a 303. A
@@ -1261,7 +1262,9 @@ command whose leftover is the owner's alone.
poll, the pages, the rows' links and the message's way back; poll, the pages, the rows' links and the message's way back;
`SendLogFilter.Status`, `Since`, `Until` (whole UTC days, as the `SendLogFilter.Status`, `Since`, `Until` (whole UTC days, as the
Inbound log's). A status not in the list is no narrowing. Plans: a Inbound log's). A status not in the list is no narrowing. Plans: a
domain user's filtered page still goes by `idx_send_log_domain_id`; the domain user's filtered page goes by `idx_send_log_domain_id` (with a day
filter by `idx_send_log_domain_id_created_at` and a sort - not pinned by
the plan test); the
global role's status-only count walks the table (a page view or a 5 s global role's status-only count walks the table (a page view or a 5 s
poll while the log is open on a status - tens of thousands of rows at poll while the log is open on a status - tens of thousands of rows at
the retention limit, milliseconds), accepted. the retention limit, milliseconds), accepted.
@@ -1388,6 +1391,31 @@ command whose leftover is the owner's alone.
saved before keep the first by domain name, as Postfix did). Tests in saved before keep the first by domain name, as Postfix did). Tests in
inbound and postfix (`TestRenderInboundMapsOneTLSLinePerNextHop`). inbound and postfix (`TestRenderInboundMapsOneTLSLinePerNextHop`).
*Changed by the reviews of stage 4 (2026-10-15; technical and security):*
**two domains on one upstream could never change its TLS mode** - each
save was refused for the other (medium); `inbound.Service.SetTransportAs`
now changes the mode for every domain on the upstream when the one saving
may change them all, and refuses a domain user who may not with the mode,
not the other tenant's name (the security review's F1: the first version's
refusal named it). The deletion's leftover key is removed by Resync (Reload
configuration, a restore's start) for names no domain has. The backup's
write deadline moves before Check. `hasUnsavedEdit` skips password and file
fields (a password manager filling the export password made Recalculate
ask) and compares a multiple select's options. `htmx.config.timeout` 30 s,
so a refresh that is never answered ends and is tried again; the rejected
promise of a failed refresh is handled. A filtered empty Outbound log says
so. The dummy hash is made at start. A DMARC part over 8 MiB is refused for
its size like the others. The Configuration box on Health says what Reload
writes (`design:` first). Tests: the shared upstream (service and handler),
the leftover key, all eight Health boxes ordered, the legacy subject.
Accepted, with the reason: a notification is owed to an address
(architecture.md § Notifications); SERVFAIL from every resolver is Unknown
- it is not an answer about the record; Reload repeats the restore's list
of steps by hand (`HandleReload` and `mailStack.Resync`, both commented); a
failed `SetNotifyAddressees` is logged and the subject stays said, the next
follow-up finds nobody owed; the profile form's "username taken" is
documented in security.md.
### Stage 5 — image and Postfix hardening (M17–M19, M21, L30–L32) → `2.6.0` ### Stage 5 — image and Postfix hardening (M17–M19, M21, L30–L32) → `2.6.0`
- [ ] **Supervisor socket**: a group of its own (`supervisor`), panel only; - [ ] **Supervisor socket**: a group of its own (`supervisor`), panel only;
+4 -3
View File
@@ -1016,9 +1016,10 @@ reports](#dmarc-reports).) Outbound 465/587 is unchanged.
**Panel** (`/inbound/domains`): the global role adds and deletes domains. On a **Panel** (`/inbound/domains`): the global role adds and deletes domains. On a
domain's page, set the upstream host/port and TLS to that hop (opportunistic / domain's page, set the upstream host/port and TLS to that hop (opportunistic /
required / off), and choose recipients. An upstream has one TLS mode: Postfix required / off), and choose recipients. An upstream has one TLS mode: Postfix
keeps the TLS policy per next hop, not per domain, so a second domain sent to keeps the TLS policy per next hop, not per domain, so the mode saved for one
the same host and port with another mode is refused, naming the domain that domain is the mode of every domain forwarded to the same host and port, and
already uses it. Recipients are an allow-list, or any address at that they are changed with it; a *domain* user who does not manage all of them is
refused, and told the mode in use. Recipients are an allow-list, or any address at that
domain. Inbound domains are assigned to a user with the *domain* role domain. Inbound domains are assigned to a user with the *domain* role
separately from outbound ones (see [Users](#users)); such a user sees and separately from outbound ones (see [Users](#users)); such a user sees and
changes the upstream and recipients of the inbound domains assigned to them, changes the upstream and recipients of the inbound domains assigned to them,
+9 -2
View File
@@ -610,7 +610,12 @@ deferred item from the roadmap.
panel itself makes no request to the upstream. Trigger to revisit: `domain` panel itself makes no request to the upstream. Trigger to revisit: `domain`
users who are not trusted operators — the same condition as for CSRF tokens users who are not trusted operators — the same condition as for CSRF tokens
— at which point an allow-list of upstream networks, or restricting the — at which point an allow-list of upstream networks, or restricting the
field to the global role, is the fix. field to the global role, is the fix. Since `2.5.4` an upstream has one TLS
mode for every inbound domain forwarded to it (Postfix keys the policy by
the next hop): a `domain` user saving one that another tenant's domain
uses with another mode is refused and told that mode - not the domain -
so the form tells whether some domain forwards to a given host and port.
Accepted with the rest of this item.
- **Account's profile (username, e-mail) is saved without the password.** - **Account's profile (username, e-mail) is saved without the password.**
Saving the profile does not ask for the current password, as changing the Saving the profile does not ask for the current password, as changing the
password does. Since 2.3.0 the e-mail is where notifications go, so a stolen password does. Since 2.3.0 the e-mail is where notifications go, so a stolen
@@ -619,7 +624,9 @@ deferred item from the roadmap.
certificate expires, sender addresses of old queued mail - and the user certificate expires, sender addresses of old queued mail - and the user
stops hearing it. Nothing in a notification gives access. A `domain` user stops hearing it. Nothing in a notification gives access. A `domain` user
can likewise point signed mail of this server at a third party's address, can likewise point signed mail of this server at a third party's address,
a dozen messages a day per assigned domain at most. It a dozen messages a day per assigned domain at most. The profile form also
says when a username is taken, so a signed-in user can tell which
usernames exist; signing in does not (`2.5.4`). It
becomes a requirement to ask for the current password when changing becomes a requirement to ask for the current password when changing
`users.email` as soon as [password-reset](roadmap.md#password-reset) mails a `users.email` as soon as [password-reset](roadmap.md#password-reset) mails a
link to it — from then on whoever can change the address could take the link to it — from then on whoever can change the address could take the
+4 -1
View File
@@ -197,7 +197,10 @@ func readEncodedBody(hdr textproto.MIMEHeader, r io.Reader) ([]byte, error) {
case "quoted-printable": case "quoted-printable":
body = quotedprintable.NewReader(r) body = quotedprintable.NewReader(r)
} }
data, err := io.ReadAll(io.LimitReader(body, 8<<20)) data, err := readAtMost(body, maxAggregateXML)
if errors.Is(err, errTooLarge) {
return nil, fmt.Errorf("the report part is larger than %d MiB", maxAggregateXML>>20)
}
if err != nil { if err != nil {
return nil, fmt.Errorf("read part body: %w", err) return nil, fmt.Errorf("read part body: %w", err)
} }
+17
View File
@@ -50,6 +50,23 @@ func TestDeleteRunsEveryRebuildAndSaysTheDomainIsGone(t *testing.T) {
if _, err := os.Stat(keyDir); err != nil { if _, err := os.Stat(keyDir); err != nil {
t.Errorf("the key went while the tables may still name it: %v", err) t.Errorf("the key went while the tables may still name it: %v", err)
} }
// Reload configuration finishes the deletion: the tables rebuilt, the
// key of a domain that is gone goes too; the others' stay.
odk.reload = func() error { return nil }
keep, err := s.Add("kept.example")
if err != nil {
t.Fatal(err)
}
if err := s.Resync(); err != nil {
t.Fatalf("Resync: %v", err)
}
if _, err := os.Stat(keyDir); !os.IsNotExist(err) {
t.Errorf("the deleted domain's key is still there after Resync: %v", err)
}
if _, err := os.Stat(filepath.Join(odk.keysDir, keep.Name)); err != nil {
t.Errorf("Resync removed a domain's key: %v", err)
}
} }
// The sender map failing is the same: deleted, said so; the tables were // The sender map failing is the same: deleted, said so; the tables were
+18
View File
@@ -92,6 +92,24 @@ func (o *OpenDKIM) RemoveKey(domainName string) error {
return nil return nil
} }
// KeyDomains lists the names that have a key directory.
func (o *OpenDKIM) KeyDomains() ([]string, error) {
entries, err := os.ReadDir(o.keysDir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("list key dirs: %w", err)
}
var out []string
for _, e := range entries {
if e.IsDir() {
out = append(out, e.Name())
}
}
return out, nil
}
// ExportKey returns a domain's DKIM private key as PKCS#1 PEM, for carrying in // ExportKey returns a domain's DKIM private key as PKCS#1 PEM, for carrying in
// a domain export so the receiving instance signs with the same key and the // a domain export so the receiving instance signs with the same key and the
// DNS TXT record never has to change (architecture.md § Persistence). It // DNS TXT record never has to change (architecture.md § Persistence). It
+28 -1
View File
@@ -267,8 +267,35 @@ func (s *Service) ClearRateLimit(domainID int64) error {
// OpenDKIM. It backs the manual reload button (architecture.md § Panel HTTP // OpenDKIM. It backs the manual reload button (architecture.md § Panel HTTP
// surface) and doubles as a recovery path if the tables ever drift from the // surface) and doubles as a recovery path if the tables ever drift from the
// database. // database.
//
// It also removes the key directories of names no domain has and that are
// not the server's own: what a deletion left when the tables could not be
// rebuilt (Delete keeps the key then). The "Reload configuration" the page
// points to after such a deletion finishes it here (review of audit stage 4).
func (s *Service) Resync() error { func (s *Service) Resync() error {
return s.resync() if err := s.resync(); err != nil {
return err
}
domains, err := s.store.ListDomains()
if err != nil {
return err
}
keep := map[string]bool{s.serverName: true}
for _, d := range domains {
keep[d.Name] = true
}
names, err := s.odk.KeyDomains()
if err != nil {
return err
}
for _, name := range names {
if !keep[name] {
if err := s.odk.RemoveKey(name); err != nil {
return fmt.Errorf("remove the key of %s, a domain that is gone: %w", name, err)
}
}
}
return nil
} }
// resync rebuilds KeyTable/SigningTable from the current domain set and reloads. // resync rebuilds KeyTable/SigningTable from the current domain set and reloads.
+53 -26
View File
@@ -5,6 +5,7 @@
package inbound package inbound
import ( import (
"fmt"
"strings" "strings"
"github.com/mixeme/selfpost/internal/postfix" "github.com/mixeme/selfpost/internal/postfix"
@@ -54,51 +55,77 @@ func (s *Service) Add(name string) (store.InboundDomain, error) {
return d, nil return d, nil
} }
// SetTransport validates and saves the upstream, then rebuilds the maps. // SetTransport validates and saves the upstream, then rebuilds the maps; the
// other domains forwarded to the same upstream take its TLS mode with it
// (SetTransportAs, with every domain changeable).
func (s *Service) SetTransport(id int64, host, portRaw, tlsMode string) error { func (s *Service) SetTransport(id int64, host, portRaw, tlsMode string) error {
if _, err := s.store.GetInboundDomain(id); err != nil { _, err := s.SetTransportAs(id, host, portRaw, tlsMode, func(store.InboundDomain) bool { return true })
return err return err
}
// UpstreamShared refuses an upstream another inbound domain forwards to with
// another TLS mode, when the one saving may not change that domain. Its text
// is for the log; the panel tells a domain user the mode, not the domain,
// which is another tenant's (security review of audit stage 4).
type UpstreamShared struct {
Upstream string
Mode string
Domain string
}
func (e *UpstreamShared) Error() string {
return fmt.Sprintf("%s is the upstream of %s too, with TLS mode %s", e.Upstream, e.Domain, e.Mode)
}
// SetTransportAs validates and saves the upstream of domain id, then rebuilds
// the maps. An upstream has one TLS mode: Postfix's TLS policy is keyed by the
// next hop, not by the domain (smtp_tls_policy_maps; Postfix 3.7.11,
// README_FILES/TLS_README: the key is the verbatim next-hop), so two domains
// to one [host]:port share one mode - the map held both keys and the first by
// domain name won with a warning (audit 2026-10-12, L5). The other domains
// forwarded to the upstream with another mode take the new one, when
// mayChange allows each; one it does not is an *UpstreamShared and nothing is
// saved. Before, the save was refused, and two domains sharing an upstream
// could never change its mode (review of audit stage 4). changed is how many
// other domains took the mode.
func (s *Service) SetTransportAs(id int64, host, portRaw, tlsMode string, mayChange func(store.InboundDomain) bool) (changed int, err error) {
if _, err := s.store.GetInboundDomain(id); err != nil {
return 0, err
} }
host = normalizeHost(host) host = normalizeHost(host)
if err := checkHost(host); err != nil { if err := checkHost(host); err != nil {
return err return 0, err
} }
port, err := parsePort(portRaw) port, err := parsePort(portRaw)
if err != nil { if err != nil {
return err return 0, err
} }
if err := checkTLSMode(tlsMode); err != nil { if err := checkTLSMode(tlsMode); err != nil {
return err return 0, err
} }
if err := s.checkOneTLSModePerUpstream(id, host, port, tlsMode); err != nil {
return err
}
if err := s.store.UpdateInboundTransport(id, host, port, tlsMode); err != nil {
return err
}
return s.Resync()
}
// checkOneTLSModePerUpstream refuses an upstream another inbound domain
// already forwards to with another TLS mode. Postfix's TLS policy is keyed by
// the next hop, not by the domain (smtp_tls_policy_maps; Postfix 3.7.11,
// README_FILES/TLS_README: the key is the verbatim next-hop), so two domains
// to one [host]:port share one mode: the map held both keys, and the first by
// domain name won with a warning - the form's choice for the second domain
// was not what Postfix did (audit 2026-10-12, L5).
func (s *Service) checkOneTLSModePerUpstream(id int64, host string, port int, tlsMode string) error {
domains, err := s.store.ListInboundDomains() domains, err := s.store.ListInboundDomains()
if err != nil { if err != nil {
return err return 0, err
} }
var others []store.InboundDomain
for _, d := range domains { for _, d := range domains {
if d.ID == id || d.Port != port || normalizeHost(d.Host) != host || d.TLSMode == tlsMode { if d.ID == id || d.Port != port || normalizeHost(d.Host) != host || d.TLSMode == tlsMode {
continue continue
} }
return invalid("[%s]:%d is already the upstream of %s, with TLS mode %s: an upstream has one TLS mode - choose %s here, or change it on %s first", if !mayChange(d) {
host, port, d.Name, d.TLSMode, d.TLSMode, d.Name) return 0, &UpstreamShared{Upstream: fmt.Sprintf("[%s]:%d", host, port), Mode: d.TLSMode, Domain: d.Name}
} }
return nil others = append(others, d)
}
if err := s.store.UpdateInboundTransport(id, host, port, tlsMode); err != nil {
return 0, err
}
for _, d := range others {
if err := s.store.UpdateInboundTransport(d.ID, d.Host, d.Port, tlsMode); err != nil {
return 0, err
}
}
return len(others), s.Resync()
} }
// SetRecipients validates the mode and, in list mode, every address, then // SetRecipients validates the mode and, in list mode, every address, then
+35 -20
View File
@@ -2,7 +2,6 @@ package inbound
import ( import (
"errors" "errors"
"strings"
"testing" "testing"
"github.com/mixeme/selfpost/internal/postfix" "github.com/mixeme/selfpost/internal/postfix"
@@ -14,9 +13,11 @@ type nullMaps struct{}
func (nullMaps) RebuildInboundMaps([]postfix.InboundRoute) error { return nil } func (nullMaps) RebuildInboundMaps([]postfix.InboundRoute) error { return nil }
// An upstream has one TLS mode, since Postfix keys the policy by the next // An upstream has one TLS mode, since Postfix keys the policy by the next
// hop: a second domain saved to the same [host]:port with another mode is // hop (audit 2026-10-12, L5): the other domains forwarded to it take the mode
// refused with the first domain's name and mode; the same mode is fine // saved for one of them, when the one saving may change them; one they may
// (audit 2026-10-12, L5). // not is refused, and nothing is saved. Two domains on one upstream can
// change its mode (review of stage 4: they could not, each refused for the
// other).
func TestOneTLSModePerUpstream(t *testing.T) { func TestOneTLSModePerUpstream(t *testing.T) {
st, err := store.Open(t.TempDir() + "/test.db") st, err := store.Open(t.TempDir() + "/test.db")
if err != nil { if err != nil {
@@ -24,30 +25,44 @@ func TestOneTLSModePerUpstream(t *testing.T) {
} }
defer st.Close() defer st.Close()
s := NewService(st, nullMaps{}) s := NewService(st, nullMaps{})
a, err := s.Add("a.example") a, _ := s.Add("a.example")
b, _ := s.Add("b.example")
c, _ := s.Add("c.example")
mode := func(id int64) string {
d, err := st.GetInboundDomain(id)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
b, err := s.Add("b.example") return d.TLSMode
if err != nil { }
all := func(store.InboundDomain) bool { return true }
if _, err := s.SetTransportAs(a.ID, "10.0.0.8", "25", "encrypt", all); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := s.SetTransport(a.ID, "10.0.0.8", "25", "encrypt"); err != nil { if n, err := s.SetTransportAs(b.ID, "[10.0.0.8]", "25", "encrypt", all); err != nil || n != 0 {
t.Fatal(err) t.Fatalf("the same mode: %d, %v", n, err)
} }
err = s.SetTransport(b.ID, "[10.0.0.8]", "25", "may") // Either domain changes the mode, for both.
var verr *ValidationError if n, err := s.SetTransportAs(a.ID, "10.0.0.8", "25", "may", all); err != nil || n != 1 {
if !errors.As(err, &verr) || !strings.Contains(err.Error(), "[10.0.0.8]:25 is already the upstream of a.example, with TLS mode encrypt") { t.Fatalf("a changes the shared mode: %d, %v", n, err)
t.Fatalf("a second mode for one upstream = %v", err)
} }
if err := s.SetTransport(b.ID, "10.0.0.8", "25", "encrypt"); err != nil { if mode(a.ID) != "may" || mode(b.ID) != "may" {
t.Errorf("the same mode refused: %v", err) t.Errorf("modes after a's change: %s, %s", mode(a.ID), mode(b.ID))
} }
if err := s.SetTransport(b.ID, "10.0.0.8", "2525", "may"); err != nil { // Another port is another next hop.
t.Errorf("another port refused: %v", err) if n, err := s.SetTransportAs(c.ID, "10.0.0.8", "2525", "none", all); err != nil || n != 0 {
t.Errorf("another port: %d, %v", n, err)
} }
// The domain itself may change its own mode.
if err := s.SetTransport(a.ID, "10.0.0.8", "25", "may"); err != nil { // One that may not change b is refused, with b's mode, and saves nothing.
t.Errorf("a domain changing its own mode refused: %v", err) notB := func(d store.InboundDomain) bool { return d.ID != b.ID }
_, err = s.SetTransportAs(c.ID, "10.0.0.8", "25", "encrypt", notB)
var shared *UpstreamShared
if !errors.As(err, &shared) || shared.Mode != "may" || shared.Domain != "b.example" || shared.Upstream != "[10.0.0.8]:25" {
t.Fatalf("a mode for another's upstream = %v", err)
}
if d, _ := st.GetInboundDomain(c.ID); d.Port != 2525 || mode(a.ID) != "may" {
t.Errorf("the refused save changed something: c on %d, a %s", d.Port, mode(a.ID))
} }
} }
+16
View File
@@ -644,3 +644,19 @@ func TestDNSUnknownIsNeverReported(t *testing.T) {
t.Errorf("the message speaks of SPF, which was not known:\n%s", body) t.Errorf("the message speaks of SPF, which was not known:\n%s", body)
} }
} }
// A subject opened before 2.5.4 has no record of who was told: no follow-up
// is sent, and its end goes to everyone who is to hear of it, as it did.
func TestASubjectFromBeforeTheTableEndsForEveryone(t *testing.T) {
b := newBench(t)
since := b.now.Add(-3 * time.Hour)
if err := b.st.SetNotifyState(store.NotifyState{Key: "dns:example.com", Phase: phaseOpen, Since: since, SentAt: since}); err != nil {
t.Fatal(err)
}
b.dns["example.com"] = badDKIM()
want(t, "still wrong", b.hour())
b.dns["example.com"] = good()
want(t, "first good check", b.hour())
cleared := "[SelfPost] example.com: DNS records match again"
want(t, "over", b.hour(), "mine@example.org|"+cleared, "root@example.org|"+cleared)
}
+3
View File
@@ -61,6 +61,9 @@ func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Mo
m.setupLimiter.startSweeper() m.setupLimiter.startSweeper()
m.loginLimiter.startSweeper() m.loginLimiter.startSweeper()
m.passwordLimiter.startSweeper() m.passwordLimiter.startSweeper()
// Made now rather than at the first unknown name, which would otherwise
// pay for two bcrypt runs (review of audit stage 4).
go noSuchUserHash()
m.setup = newSetupManager(st, cfg.Hostname, setupTokenPath) m.setup = newSetupManager(st, cfg.Hostname, setupTokenPath)
return m return m
} }
+11 -9
View File
@@ -79,6 +79,17 @@ func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) {
logf("panel: full backup: %s", msg) logf("panel: full backup: %s", msg)
}, },
} }
// The server's write deadline is for pages (cmd/panel newHTTPServer); an
// archive streams for as long as it takes, and the read-through before it
// (Check) is as long as the archive: the deadline moves first, so a
// refusal after a long Check can still be written (review of stage 4).
// Two hours carry a gigabyte at a little over 1 Mbit/s, and still end a
// client that keeps the connection and stops reading, which TCP alone
// would not (net/http, Go 1.26: ResponseController.SetWriteDeadline).
if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(2 * time.Hour)); err != nil {
logf("panel: full backup: lift the write deadline: %v", err)
}
if err := backup.ValidateDeployRoot(h.cfg.DeployRoot); err != nil { if err := backup.ValidateDeployRoot(h.cfg.DeployRoot); err != nil {
logf("panel: full backup: %v", err) logf("panel: full backup: %v", err)
h.renderBackupPageWith(w, r, http.StatusBadRequest, "", deployBackupErr(err)) h.renderBackupPageWith(w, r, http.StatusBadRequest, "", deployBackupErr(err))
@@ -107,15 +118,6 @@ func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
// The server's write deadline is for pages (cmd/panel newHTTPServer); an
// archive streams for as long as it takes. Two hours carry a gigabyte at
// a little over 1 Mbit/s, and still end a client that keeps the
// connection and stops reading, which TCP alone would not (net/http, Go
// 1.26: ResponseController.SetWriteDeadline).
if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(2 * time.Hour)); err != nil {
logf("panel: full backup: lift the write deadline: %v", err)
}
// Everything below streams: past this point headers (and possibly some // Everything below streams: past this point headers (and possibly some
// bytes) are already on the wire, so a failure cannot switch to a clean // bytes) are already on the wire, so a failure cannot switch to a clean
// error page. Log it and let the truncated download fail loudly on the // error page. Log it and let the truncated download fail loudly on the
+21 -1
View File
@@ -250,6 +250,8 @@ func firstNonEmpty(ss ...string) string {
func inboundFlash(r *http.Request) string { func inboundFlash(r *http.Request) string {
switch { switch {
case r.URL.Query().Get("shared") != "":
return "Upstream saved. Its TLS mode is one for every domain forwarded to it, and the other domains on it were changed too."
case r.URL.Query().Get("saved") != "": case r.URL.Query().Get("saved") != "":
return "Upstream saved." return "Upstream saved."
case r.URL.Query().Get("recipients") != "": case r.URL.Query().Get("recipients") != "":
@@ -284,12 +286,30 @@ func (h *Handlers) HandleInboundTransport(w http.ResponseWriter, r *http.Request
host := r.PostFormValue("host") host := r.PostFormValue("host")
port := r.PostFormValue("port") port := r.PostFormValue("port")
tlsMode := r.PostFormValue("tls_mode") tlsMode := r.PostFormValue("tls_mode")
if err := h.inbound.SetTransport(d.ID, host, port, tlsMode); err != nil { p, _ := h.principal(r)
changed, err := h.inbound.SetTransportAs(d.ID, host, port, tlsMode, func(o store.InboundDomain) bool {
return p.CanAccessInboundDomain(o.ID)
})
var shared *inbound.UpstreamShared
if errors.As(err, &shared) {
// The other domain is another tenant's: its mode is said, its name
// is not (security review of audit stage 4).
logf("panel: inbound: save upstream of %s: %v", d.Name, err)
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{
TransportErr: "This upstream is also used, with TLS mode " + shared.Mode + ", by an inbound domain you do not manage, and an upstream has one TLS mode: choose " + shared.Mode + ", or ask the administrator to change it for both.",
})
return
}
if err != nil {
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{ h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{
TransportErr: inboundErrorMessage(err, "upstream", "the upstream"), TransportErr: inboundErrorMessage(err, "upstream", "the upstream"),
}) })
return return
} }
if changed > 0 {
http.Redirect(w, r, fmt.Sprintf("/inbound/domains/%d?saved=1&shared=%d", d.ID, changed), http.StatusSeeOther)
return
}
http.Redirect(w, r, fmt.Sprintf("/inbound/domains/%d?saved=1", d.ID), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/inbound/domains/%d?saved=1", d.ID), http.StatusSeeOther)
} }
@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@@ -227,3 +228,37 @@ func TestInboundDeletePageNamesWhatGoesWithTheDomain(t *testing.T) {
has(t, "delete page, any recipient", body, `remove its upstream.`) has(t, "delete page, any recipient", body, `remove its upstream.`)
lacks(t, "delete page, any recipient", body, `recipient`) lacks(t, "delete page, any recipient", body, `recipient`)
} }
// A domain user saving an upstream another tenant's inbound domain shares,
// with another TLS mode, is told the mode and not the domain; the global
// role changes the mode for both (security and technical reviews of audit
// stage 4).
func TestSharedUpstreamTLSModeForADomainUser(t *testing.T) {
h, st := inboundHandlers(t)
mine, _ := h.inbound.Add("mine.example")
theirs, _ := h.inbound.Add("theirs.example")
if err := h.inbound.SetTransport(theirs.ID, "10.0.0.8", "25", "encrypt"); err != nil {
t.Fatal(err)
}
p := userWith(t, st, "tenant", store.RoleDomain, store.Reach{InboundDomainIDs: []int64{mine.ID}})
form := url.Values{"host": {"10.0.0.8"}, "port": {"25"}, "tls_mode": {"may"}}
rec := inboundCall(h, http.MethodPost, fmt.Sprintf("/inbound/domains/%d/upstream", mine.ID), form, p)
body := rec.Body.String()
if rec.Code != http.StatusBadRequest || !strings.Contains(body, "with TLS mode encrypt, by an inbound domain you do not manage") {
t.Fatalf("a domain user on another's upstream = %d:\n%s", rec.Code, body)
}
if strings.Contains(body, "theirs.example") {
t.Error("the refusal names the other tenant's domain")
}
if err := h.inbound.SetTransport(mine.ID, "10.0.0.8", "25", "encrypt"); err != nil {
t.Fatal(err)
}
rec = inboundCall(h, http.MethodPost, fmt.Sprintf("/inbound/domains/%d/upstream", mine.ID), form, globalPrincipal)
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "shared=1") {
t.Fatalf("the global role changing a shared mode = %d %q", rec.Code, rec.Header().Get("Location"))
}
if d, _ := st.GetInboundDomain(theirs.ID); d.TLSMode != "may" {
t.Errorf("the other domain kept %s", d.TLSMode)
}
}
@@ -268,9 +268,12 @@ func TestOutLogFiltersByStatusAndDay(t *testing.T) {
t.Errorf("today shows %d rows, want 4", got) t.Errorf("today shows %d rows, want 4", got)
} }
for _, q := range []string{"from=" + tomorrow, "to=" + yesterday} { for _, q := range []string{"from=" + tomorrow, "to=" + yesterday} {
if body := getBody(t, h.HandleDeliveries, "/outbound/log?"+q); strings.Contains(body, `">Details</a>`) { body := getBody(t, h.HandleDeliveries, "/outbound/log?"+q)
if strings.Contains(body, `">Details</a>`) {
t.Errorf("%s shows rows from today", q) t.Errorf("%s shows rows from today", q)
} }
// Filtered to nothing is said so, not "no messages logged yet".
has(t, q, body, "No messages match these filters.")
} }
body := getBody(t, h.HandleDeliveries, "/outbound/log?status=rejected&from="+today) body := getBody(t, h.HandleDeliveries, "/outbound/log?status=rejected&from="+today)
has(t, "filtered", body, `name="from" value="`+today+`"`, has(t, "filtered", body, `name="from" value="`+today+`"`,
+2 -2
View File
@@ -51,8 +51,8 @@ func TestCardsBoxesCarryTheirNumberAsTheirOrder(t *testing.T) {
t.Errorf("%s: panel.css has no rule for data-order %s", name, order) t.Errorf("%s: panel.css has no rule for data-order %s", name, order)
} }
} }
if found == 0 { if want := map[string]int{"health": 8, "kit": 3}[name]; found != want {
t.Errorf("%s: no numbered boxes in the cards layout", name) t.Errorf("%s: %d numbered boxes found in the cards layout, want %d", name, found, want)
} }
} }
} }
+7 -1
View File
@@ -117,7 +117,7 @@ func NewOutLog(m Meta, retentionDays int, canSettings bool, domains, apps []stri
} }
return out return out
} }
return &OutLog{ p := &OutLog{
Meta: m, Meta: m,
Head: Head{Kicker: "Outbound", Title: "Outbound log", Lead: lead}, Head: Head{Kicker: "Outbound", Title: "Outbound log", Lead: lead},
Messages: Box{No: "01", Title: "Messages", Help: topicLink(HelpLog, "What the statuses mean"), Filter: &Filter{ Messages: Box{No: "01", Title: "Messages", Help: topicLink(HelpLog, "What the statuses mean"), Filter: &Filter{
@@ -137,6 +137,12 @@ func NewOutLog(m Meta, retentionDays int, canSettings bool, domains, apps []stri
Foot: Foot{ID: "out-log-foot", End: Plain("New rows appear on their own")}, Foot: Foot{ID: "out-log-foot", End: Plain("New rows appear on their own")},
filter: f, filter: f,
} }
// Filtered to nothing is not an empty log (review of audit stage 4),
// as on the Inbound log.
if f != (OutLogFilter{}) {
p.Empty.Text = Plain("No messages match these filters.")
}
return p
} }
// DetailHref is the address of one message's page, carrying the filters and the // DetailHref is the address of one message's page, carrying the filters and the
+25 -2
View File
@@ -119,17 +119,26 @@
// what the markup said (HTML Living Standard, the input, select and // what the markup said (HTML Living Standard, the input, select and
// textarea elements). A select none of whose options says selected was // textarea elements). A select none of whose options says selected was
// drawn with its first option, or none when it is a list (size or multiple). // drawn with its first option, or none when it is a list (size or multiple).
// Password and file fields are not edits of the page: a password manager
// fills the first, nothing draws the second (review of stage 4).
function hasUnsavedEdit() { function hasUnsavedEdit() {
var fields = document.querySelectorAll("input, select, textarea"); var fields = document.querySelectorAll("input, select, textarea");
for (var i = 0; i < fields.length; i++) { for (var i = 0; i < fields.length; i++) {
var f = fields[i]; var f = fields[i];
if (f.disabled || f.type === "hidden" || f.type === "submit" || f.type === "button" || f.type === "reset") { if (f.disabled || f.type === "hidden" || f.type === "submit" || f.type === "button" ||
f.type === "reset" || f.type === "password" || f.type === "file") {
continue; continue;
} }
if (f.type === "checkbox" || f.type === "radio") { if (f.type === "checkbox" || f.type === "radio") {
if (f.checked !== f.defaultChecked) { if (f.checked !== f.defaultChecked) {
return true; return true;
} }
} else if (f.tagName === "SELECT" && f.multiple) {
for (var m = 0; m < f.options.length; m++) {
if (f.options[m].selected !== f.options[m].defaultSelected) {
return true;
}
}
} else if (f.tagName === "SELECT") { } else if (f.tagName === "SELECT") {
var drawn = -1; var drawn = -1;
for (var j = 0; j < f.options.length; j++) { for (var j = 0; j < f.options.length; j++) {
@@ -298,12 +307,26 @@
// issueAjaxRequest(verb, path, resolveTarget(context.source), ...), and // issueAjaxRequest(verb, path, resolveTarget(context.source), ...), and
// with no source issueAjaxRequest() falls back to the document's body). // with no source issueAjaxRequest() falls back to the document's body).
function triggerPoll(el) { function triggerPoll(el) {
htmx.ajax("GET", el.getAttribute("hx-get"), { var done = htmx.ajax("GET", el.getAttribute("hx-get"), {
source: el, source: el,
target: "#" + el.id, target: "#" + el.id,
swap: el.getAttribute("hx-swap") || "outerHTML" swap: el.getAttribute("hx-swap") || "outerHTML"
}); });
// A failed refresh is handled by htmx:afterRequest below; the promise
// htmx.ajax returns is rejected too, and is not to be left unhandled.
if (done && done.catch) {
done.catch(function () {});
} }
}
// A request that never ends - a panel stopped mid-answer, a proxy holding
// it - would end no refresh, and no next one would be scheduled: htmx's
// default timeout is none (htmx 2.0.4, src/htmx.js: config.timeout 0, and
// issueAjaxRequest() takes requestConfig.timeout from it when neither the
// call nor hx-request says one; xhr.timeout = requestConfig.timeout). Thirty seconds is twice the
// slowest page the panel draws, one that asks DNS; then htmx:timeout,
// and htmx:afterRequest with it.
htmx.config.timeout = 30000;
function schedulePoll(el) { function schedulePoll(el) {
if (!el || !el.id || !el.hasAttribute("data-poll")) { if (!el || !el.id || !el.hasAttribute("data-poll")) {
+1 -1
View File
@@ -15,7 +15,7 @@
{{template "health_mail" .}} {{template "health_mail" .}}
{{template "box_open" .Configuration}} {{template "box_open" .Configuration}}
{{template "body_open"}} {{template "body_open"}}
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p> <p class="mb-3">Regenerates the OpenDKIM and Postfix configuration and the spam filter's lists from the database, removes the keys of deleted domains, and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
{{template "action" .Reload}} {{template "action" .Reload}}
{{template "body_close"}} {{template "body_close"}}
{{template "box_close"}} {{template "box_close"}}
+4
View File
@@ -61,6 +61,7 @@ const document = {
const window = { addEventListener: on("window"), confirm: () => true }; const window = { addEventListener: on("window"), confirm: () => true };
const requests = []; const requests = [];
const htmx = { const htmx = {
config: {},
ajax(verb, path, ctx) { ajax(verb, path, ctx) {
requests.push({ verb, path, ctx }); requests.push({ verb, path, ctx });
}, },
@@ -79,6 +80,9 @@ vm.runInNewContext(fs.readFileSync(process.argv[2], "utf8"), {
}); });
// --- the cases -------------------------------------------------------------- // --- the cases --------------------------------------------------------------
// A request that never ends is ended: htmx's default is no timeout.
check(htmx.config.timeout === 30000, "htmx.config.timeout is " + htmx.config.timeout + ", want 30000");
const box = element("overview-stats", { "data-poll": "", "hx-get": "/overview/stats", "hx-trigger": "load" }); const box = element("overview-stats", { "data-poll": "", "hx-get": "/overview/stats", "hx-trigger": "load" });
inPage = { [box.id]: box }; inPage = { [box.id]: box };
+11 -1
View File
@@ -30,7 +30,7 @@ const window = {
}, },
}; };
vm.runInNewContext(fs.readFileSync(process.argv[2], "utf8"), { vm.runInNewContext(fs.readFileSync(process.argv[2], "utf8"), {
document, window, htmx: { ajax() {} }, setTimeout, clearTimeout, Date, Object, Math, console, document, window, htmx: { config: {}, ajax() {} }, setTimeout, clearTimeout, Date, Object, Math, console,
}); });
function input(type, value, defaultValue) { function input(type, value, defaultValue) {
@@ -85,6 +85,16 @@ scenario("a box unticked", (f) => (f[2].checked = false), true);
scenario("a select moved off its first option", (f) => (f[3].selectedIndex = 1), true); scenario("a select moved off its first option", (f) => (f[3].selectedIndex = 1), true);
scenario("a select moved off its selected option", (f) => (f[4].selectedIndex = 0), true); scenario("a select moved off its selected option", (f) => (f[4].selectedIndex = 0), true);
scenario("a disabled field differs", (f) => { f[0].value = "150"; f[0].disabled = true; }, false); scenario("a disabled field differs", (f) => { f[0].value = "150"; f[0].disabled = true; }, false);
// A password manager filling a password field, a file chosen: not edits.
scenario("a password filled in", (f) => f.push(input("password", "secret", "")), false);
scenario("a file chosen", (f) => f.push(input("file", "x.json", "")), false);
// A list that takes several: any option off what was drawn.
scenario("a multiple select changed", (f) => f.push(Object.assign(select(-1, [false, false]), {
multiple: true, options: [{ selected: true, defaultSelected: false }, { selected: false, defaultSelected: false }],
})), true);
scenario("a multiple select as drawn", (f) => f.push(Object.assign(select(-1, [false, false]), {
multiple: true, options: [{ selected: true, defaultSelected: true }, { selected: false, defaultSelected: false }],
})), false);
// Answered yes: the submission goes on. // Answered yes: the submission goes on.
fields = drawn(); fields = drawn();