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
+3
View File
@@ -61,6 +61,9 @@ func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Mo
m.setupLimiter.startSweeper()
m.loginLimiter.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)
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)
},
}
// 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 {
logf("panel: full backup: %v", 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("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
// 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
+21 -1
View File
@@ -250,6 +250,8 @@ func firstNonEmpty(ss ...string) string {
func inboundFlash(r *http.Request) string {
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") != "":
return "Upstream saved."
case r.URL.Query().Get("recipients") != "":
@@ -284,12 +286,30 @@ func (h *Handlers) HandleInboundTransport(w http.ResponseWriter, r *http.Request
host := r.PostFormValue("host")
port := r.PostFormValue("port")
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{
TransportErr: inboundErrorMessage(err, "upstream", "the upstream"),
})
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)
}
@@ -1,6 +1,7 @@
package handlers
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
@@ -227,3 +228,37 @@ func TestInboundDeletePageNamesWhatGoesWithTheDomain(t *testing.T) {
has(t, "delete page, any recipient", body, `remove its upstream.`)
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)
}
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)
}
// 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)
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)
}
}
if found == 0 {
t.Errorf("%s: no numbered boxes in the cards layout", name)
if want := map[string]int{"health": 8, "kit": 3}[name]; found != want {
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 &OutLog{
p := &OutLog{
Meta: m,
Head: Head{Kicker: "Outbound", Title: "Outbound log", Lead: lead},
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")},
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
+25 -2
View File
@@ -119,17 +119,26 @@
// what the markup said (HTML Living Standard, the input, select and
// 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).
// 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() {
var fields = document.querySelectorAll("input, select, textarea");
for (var i = 0; i < fields.length; 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;
}
if (f.type === "checkbox" || f.type === "radio") {
if (f.checked !== f.defaultChecked) {
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") {
var drawn = -1;
for (var j = 0; j < f.options.length; j++) {
@@ -298,13 +307,27 @@
// issueAjaxRequest(verb, path, resolveTarget(context.source), ...), and
// with no source issueAjaxRequest() falls back to the document's body).
function triggerPoll(el) {
htmx.ajax("GET", el.getAttribute("hx-get"), {
var done = htmx.ajax("GET", el.getAttribute("hx-get"), {
source: el,
target: "#" + el.id,
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) {
if (!el || !el.id || !el.hasAttribute("data-poll")) {
return;
+1 -1
View File
@@ -15,7 +15,7 @@
{{template "health_mail" .}}
{{template "box_open" .Configuration}}
{{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 "body_close"}}
{{template "box_close"}}
+4
View File
@@ -61,6 +61,7 @@ const document = {
const window = { addEventListener: on("window"), confirm: () => true };
const requests = [];
const htmx = {
config: {},
ajax(verb, path, ctx) {
requests.push({ verb, path, ctx });
},
@@ -79,6 +80,9 @@ vm.runInNewContext(fs.readFileSync(process.argv[2], "utf8"), {
});
// --- 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" });
inPage = { [box.id]: box };
+11 -1
View File
@@ -30,7 +30,7 @@ const window = {
},
};
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) {
@@ -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 selected option", (f) => (f[4].selectedIndex = 0), true);
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.
fields = drawn();