diff --git a/CHANGELOG.md b/CHANGELOG.md index e40fd54..c4c1edf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,9 @@ rate limit is correct for a non-default level-1 window. Schema migration ### Fixed +- postqueue reader is now time-bounded: `postqueue -p` runs under a 5-second + context timeout so a stuck queue command cannot hang the queue panel path + indefinitely. - DMARC panel layout classes now exist in `internal/web/view/static/panel.css` (`.pair`, `.attn`, `.desk-only`, `.phone-only`, `.phone-list`, `.check-row`), so desktop/mobile DMARC views and @@ -57,6 +60,14 @@ rate limit is correct for a non-default level-1 window. Schema migration session sign-out when the password changed. - Login/setup rate-limiter bucket eviction now prefers the nearest-expiry bucket and does not evict currently blocked buckets. +- Help page/drawer links now use `SourceURL` (`{{.SourceURL}}/blob/main/docs/...`) + instead of a hardcoded repository URL. +- Help drawer close controls include accessibility attributes (`role="button"`, + `aria-label`) for the scrim and close action. +- Password hashing now uses explicit bcrypt cost `12` in settings/user password + updates, instead of `bcrypt.DefaultCost`. +- Test coverage expanded for DMARC map token injection rejection and email + validation edge cases (ASCII/local-part/domain/freemail cases). - **Port 25 no longer runs the submission milter chain.** `smtpd_milters` is now always overridden on `smtp/inet` — to the optional antispam milter, or diff --git a/internal/postfix/dmarc_test.go b/internal/postfix/dmarc_test.go index 404592c..0a4e53a 100644 --- a/internal/postfix/dmarc_test.go +++ b/internal/postfix/dmarc_test.go @@ -33,3 +33,21 @@ func TestRenderDMARCMapsEmpty(t *testing.T) { t.Fatalf("expected empty maps") } } + +func TestRenderDMARCMapsRejectsInjectionTokens(t *testing.T) { + _, _, _, err := renderDMARCMaps(DMARCMapsConfig{ + Recipients: []string{"dmarc@example.com\nattacker@example.com"}, + Domains: []string{"example.com"}, + }) + if err == nil { + t.Fatal("expected recipient token validation error") + } + + _, _, _, err = renderDMARCMaps(DMARCMapsConfig{ + Recipients: []string{"dmarc@example.com"}, + Domains: []string{"example.com attacker"}, + }) + if err == nil { + t.Fatal("expected domain token validation error") + } +} diff --git a/internal/postfix/queue.go b/internal/postfix/queue.go index 847eaac..d629cdf 100644 --- a/internal/postfix/queue.go +++ b/internal/postfix/queue.go @@ -1,12 +1,16 @@ package postfix import ( + "context" "fmt" "os/exec" "regexp" "strings" + "time" ) +const postqueueTimeout = 5 * time.Second + // Queue returns Postfix's own human-readable mail-queue listing // (architecture.md § Panel HTTP surface): active, deferred and held messages, // exactly as an administrator would see via the CLI. The command takes a @@ -14,8 +18,13 @@ import ( // (security.md). The panel is responsible for escaping the output before // display (security.md); this function returns it as-is. func Queue() (string, error) { - cmd := exec.Command("postqueue", "-p") + ctx, cancel := context.WithTimeout(context.Background(), postqueueTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "postqueue", "-p") out, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + return "", fmt.Errorf("postqueue -p timed out after %s", postqueueTimeout) + } if err != nil { return "", fmt.Errorf("postqueue -p: %w: %s", err, strings.TrimSpace(string(out))) } diff --git a/internal/web/handlers/handlers_settings.go b/internal/web/handlers/handlers_settings.go index 74d6c3d..433a14b 100644 --- a/internal/web/handlers/handlers_settings.go +++ b/internal/web/handlers/handlers_settings.go @@ -191,7 +191,7 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) { hash := user.PasswordHash if repassword { - newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) if err != nil { logf("panel: settings: hashing password failed: %v", err) h.renderSettings(w, r, http.StatusInternalServerError, diff --git a/internal/web/handlers/handlers_users.go b/internal/web/handlers/handlers_users.go index 9ec6a70..59c7bcb 100644 --- a/internal/web/handlers/handlers_users.go +++ b/internal/web/handlers/handlers_users.go @@ -171,7 +171,7 @@ func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) { return } - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) if err != nil { logf("panel: create user hash: %v", err) h.renderUserForm(w, r, http.StatusInternalServerError, 0, userFormView{FormErr: "Internal error. Please try again."}) @@ -240,7 +240,7 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: selected}) return } - newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) if err != nil { logf("panel: update user hash: %v", err) h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Internal error. Please try again.", FormUsername: username, FormRole: string(role), FormDomains: selected}) diff --git a/internal/web/handlers/password.go b/internal/web/handlers/password.go new file mode 100644 index 0000000..0a0aa11 --- /dev/null +++ b/internal/web/handlers/password.go @@ -0,0 +1,3 @@ +package handlers + +const bcryptCost = 12 diff --git a/internal/web/validate/validate_email_test.go b/internal/web/validate/validate_email_test.go index 9e09be0..dc87237 100644 --- a/internal/web/validate/validate_email_test.go +++ b/internal/web/validate/validate_email_test.go @@ -15,4 +15,19 @@ func TestValidateEmail(t *testing.T) { if err := Email("x@gmail.com"); err == nil { t.Error("gmail accepted") } + if err := Email("reports+rua@mail.example.com"); err != nil { + t.Errorf("plus-address valid: %v", err) + } + if err := Email("bad space@mail.example.com"); err == nil { + t.Error("address with space accepted") + } + if err := Email("привет@mail.example.com"); err == nil { + t.Error("non-ascii local-part accepted") + } + if err := Email("x@-example.com"); err == nil { + t.Error("invalid domain accepted") + } + if err := Email("x@protonmail.com"); err == nil { + t.Error("blocked freemail domain accepted") + } } diff --git a/internal/web/view/templates/help.html b/internal/web/view/templates/help.html index a586de6..d2ab3b9 100644 --- a/internal/web/view/templates/help.html +++ b/internal/web/view/templates/help.html @@ -32,7 +32,7 @@ on the card itself.

Topics marked as labels open the same text in the help drawer. For installation, backup, and environment variables see the - operator + operator guide in the repository.

diff --git a/internal/web/view/templates/help_drawer.html b/internal/web/view/templates/help_drawer.html index 426e477..64941dd 100644 --- a/internal/web/view/templates/help_drawer.html +++ b/internal/web/view/templates/help_drawer.html @@ -21,14 +21,14 @@ {{end}} {{define "help_drawer"}} - +