Files
mailgo/internal/web/handlers/admin_validation_test.go
T
kevin f2a6203714 fix(security): 管理端输入校验/密码最小长度/明文认证限制,修复封禁计数丢失更新
安全审计 #23/#24/#25/#27(security_todo.md P3):

- #23 用户名/域名格式校验:用户名白名单 ^[a-zA-Z0-9._-]{1,64}$
  (杜绝含 @ 用户名导致的 GetByEmail 解析歧义),域名按标签校验
  (字母/数字/连字符、标签 ≤63、总长 ≤253,允许单标签内部域名);
  CreateUser/UpdateUser/CreateDomain 接入,新增 renderUserFormError
  共用错误渲染
- #24 密码最小长度 8 位:统一 minPasswordLength,接入自助修改、
  管理员创建、管理员重置三处,新增中/英/日 i18n 文案
- #25 POP3/IMAP 明文认证限制:配置了 TLS 且来源非回环时必须先
  STLS/STARTTLS 再认证;新增 store.IsLoopbackIP 与
  [imap]/[pop3] allow_insecure_auth 配置项(默认 false 强制)。
  顺带修复存量缺陷:POP3S 隐式 TLS 端口 tlsActive 恒为 false
  (原仅 STLS 命令置位),连接中心注册的 TLS 标记同步修正。
  部署注意:默认行为收紧,内网明文老客户端需改用 TLS 或显式
  配置 allow_insecure_auth = true
- #27 IncrementFail 并发首建窗口丢失更新:冲突 INSERT 由
  DoNothing 改为 fail_count 自增,TestIncrementFailConcurrent
  在 -race -count=5 下从偶发失败变为稳定通过
- 新增 17 项单测(校验矩阵/handler 拒绝放行路径/settings 密码/
  POP3 明文拒绝与放行/回环判定)
- security_todo.md 勾选 #23/#24/#25/#27;登记存量备注:4 个
  cache=shared 测试在 -count>1 下非幂等(UNIQUE 冲突,与 CI
  默认单轮跑法无关)
2026-08-28 15:09:55 +08:00

182 lines
5.5 KiB
Go

package handlers
// 管理端输入校验回归测试(#23/#24):用户名/域名格式白名单、密码最小
// 长度。含纯函数校验与 handler 层拒绝路径。
import (
"html/template"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"mail_go/internal/db"
"mail_go/internal/storage"
"mail_go/internal/store"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestUsernameReWhitelist(t *testing.T) {
valid := []string{"alice", "a.b_c-d", "A1", "x", "user.01"}
invalid := []string{"", "a@b", "a b", "a/b", "a\\b", "中文", "a:b"}
for _, name := range valid {
if !usernameRe.MatchString(name) {
t.Fatalf("usernameRe(%q) = false, want true", name)
}
}
for _, name := range invalid {
if usernameRe.MatchString(name) {
t.Fatalf("usernameRe(%q) = true, want false", name)
}
}
if usernameRe.MatchString(strings.Repeat("a", 65)) {
t.Fatal("usernameRe should reject 65-char username")
}
}
func TestValidDomainName(t *testing.T) {
valid := []string{"example.com", "localhost", "a-b.co", "A1.Example.COM", "mail.example.co.uk"}
invalid := []string{
"", "a@b", "a b", "-a.com", "a-.com", "a..com", ".a.com", "a.com.",
"例え.jp", strings.Repeat("a", 64) + ".com", strings.Repeat("a", 254),
}
for _, name := range valid {
if !validDomainName(name) {
t.Fatalf("validDomainName(%q) = false, want true", name)
}
}
for _, name := range invalid {
if validDomainName(name) {
t.Fatalf("validDomainName(%q) = true, want false", name)
}
}
}
func newAdminTestHandler(t *testing.T) (*AdminHandler, *store.Stores) {
t.Helper()
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.Mailbox{}, &db.MailboxState{}); err != nil {
t.Fatal(err)
}
stores := store.NewStores(gdb)
if err := stores.Users.Create(&db.User{ID: 1, Username: "admin", Domain: db.Domain{Name: "example.com"}, DomainID: 1, IsAdmin: true}); err != nil {
t.Fatal(err)
}
attStorage := storage.NewAttachmentStorage(t.TempDir())
return NewAdminHandler(stores, attStorage, filepath.Join(t.TempDir(), "tls"), "", nil, 30, nil, nil), stores
}
func newAdminTestRouter(h *AdminHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
tmpl := template.Must(template.New("").Funcs(testTemplateFuncs()).ParseGlob(filepath.Join("..", "templates", "*.html")))
template.Must(tmpl.ParseGlob(filepath.Join("..", "templates", "admin", "*.html")))
r.SetHTMLTemplate(tmpl)
r.Use(func(c *gin.Context) {
c.Set("userID", uint(1))
c.Set("currentUser", &db.User{ID: 1, Username: "admin", Domain: db.Domain{Name: "example.com"}, IsAdmin: true})
c.Next()
})
r.POST("/admin/users", h.CreateUser)
r.POST("/admin/users/:id", h.UpdateUser)
r.POST("/admin/domains", h.CreateDomain)
return r
}
func postForm(r *gin.Engine, path, form string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.ServeHTTP(w, req)
return w
}
func TestCreateUserRejectsInvalidUsername(t *testing.T) {
h, stores := newAdminTestHandler(t)
r := newAdminTestRouter(h)
w := postForm(r, "/admin/users", "username=a@b&password=secret123&domain_id=1")
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
if _, total, _ := stores.Users.ListAll(1, 100); total != 1 {
t.Fatalf("user count = %d, want 1 (not created)", total)
}
}
func TestCreateUserRejectsShortPassword(t *testing.T) {
h, stores := newAdminTestHandler(t)
r := newAdminTestRouter(h)
w := postForm(r, "/admin/users", "username=bob&password=short&domain_id=1")
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
if _, total, _ := stores.Users.ListAll(1, 100); total != 1 {
t.Fatalf("user count = %d, want 1 (not created)", total)
}
}
func TestCreateUserAcceptsValidInput(t *testing.T) {
h, stores := newAdminTestHandler(t)
r := newAdminTestRouter(h)
w := postForm(r, "/admin/users", "username=bob&password=secret123&domain_id=1")
if w.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", w.Code)
}
if _, total, _ := stores.Users.ListAll(1, 100); total != 2 {
t.Fatalf("user count = %d, want 2", total)
}
}
func TestUpdateUserRejectsInvalidUsername(t *testing.T) {
h, stores := newAdminTestHandler(t)
r := newAdminTestRouter(h)
w := postForm(r, "/admin/users/1", "username=a@b")
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
u, err := stores.Users.GetByID(1)
if err != nil {
t.Fatal(err)
}
if u.Username != "admin" {
t.Fatalf("username = %q, want unchanged admin", u.Username)
}
}
func TestCreateDomainRejectsInvalidName(t *testing.T) {
h, stores := newAdminTestHandler(t)
r := newAdminTestRouter(h)
w := postForm(r, "/admin/domains", "name=a@b")
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
if _, total, _ := stores.Domains.List(1, 100); total != 1 {
t.Fatalf("domain count = %d, want 1 (not created)", total)
}
}
func TestCreateDomainAcceptsValidName(t *testing.T) {
h, stores := newAdminTestHandler(t)
r := newAdminTestRouter(h)
w := postForm(r, "/admin/domains", "name=corp.example.net")
if w.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", w.Code)
}
if _, total, _ := stores.Domains.List(1, 100); total != 2 {
t.Fatalf("domain count = %d, want 2", total)
}
}