安全审计 #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 默认单轮跑法无关)
182 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|