From 4204ee279c211db69c897b940b0a0e67aab34fed Mon Sep 17 00:00:00 2001
From: Mikhail Yenuchenko
Date: Wed, 19 Aug 2026 19:15:30 +0300
Subject: [PATCH] phase4-6: harden help/docs links and auth defaults
Close remaining low-priority review follow-ups by removing hardcoded help URLs, improving help drawer accessibility, raising explicit bcrypt cost, and adding validation/injection coverage; also bound postqueue reads with a timeout to avoid hangs.
Co-Authored-By: Claude Sonnet 5
Co-authored-by: Cursor
---
CHANGELOG.md | 11 +++++++++++
internal/postfix/dmarc_test.go | 18 ++++++++++++++++++
internal/postfix/queue.go | 11 ++++++++++-
internal/web/handlers/handlers_settings.go | 2 +-
internal/web/handlers/handlers_users.go | 4 ++--
internal/web/handlers/password.go | 3 +++
internal/web/validate/validate_email_test.go | 15 +++++++++++++++
internal/web/view/templates/help.html | 2 +-
internal/web/view/templates/help_drawer.html | 6 +++---
9 files changed, 64 insertions(+), 8 deletions(-)
create mode 100644 internal/web/handlers/password.go
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.