package handlers import ( "fmt" "net/http" "net/http/httptest" "net/url" "strings" "testing" "github.com/gin-gonic/gin" "go_blog/models" ) // postForm 是表单编码请求的小助手,始终携带 CSRF 令牌。 func postForm(e *securityTestEnv, method, path, cookie, csrfToken string, fields url.Values) *httptest.ResponseRecorder { if fields == nil { fields = url.Values{} } if csrfToken != "" { fields.Set("_csrf", csrfToken) } return e.do(method, path, cookie, strings.NewReader(fields.Encode()), "application/x-www-form-urlencoded") } // TestStorageDirTraversalRejected 覆盖 SECURITY_TODO #22:管理员不得将 // storage_dir 设置为逃逸出存储根目录的值。 func TestStorageDirTraversalRejected(t *testing.T) { e := newSecurityTestEnv(t) admin := e.login(t, "admin") token := e.csrfTokenFor(t, admin) cases := []string{ "../evil", "foo/bar", "a\\b", "/abs/path", "..", ".", } for _, dir := range cases { w := postJSON(e, http.MethodPost, "/api/admin/settings/upload", admin, token, gin.H{"action": "save_config", "storage_dir": dir}) if w.Code != http.StatusBadRequest || respCode(w) != "settings_upload_illegal_dir" { t.Fatalf("storage_dir %q: status = %d, code = %q, want 400/settings_upload_illegal_dir", dir, w.Code, respCode(w)) } // 存储的值必须保持不变。 var u models.UploadConfig if err := e.db.First(&u, 1).Error; err != nil { t.Fatalf("load upload config: %v", err) } if u.StorageDir != "attachments" { t.Fatalf("storage_dir %q: persisted value = %q, want unchanged \"attachments\"", dir, u.StorageDir) } } // 安全的单段值可被接受。 w := postJSON(e, http.MethodPost, "/api/admin/settings/upload", admin, token, gin.H{"action": "save_config", "storage_dir": "my_attach-2"}) if w.Code != http.StatusOK || !respOK(w) { t.Fatalf("safe storage_dir: status = %d, body %s", w.Code, w.Body.String()) } var u models.UploadConfig if err := e.db.First(&u, 1).Error; err != nil { t.Fatalf("load upload config: %v", err) } if u.StorageDir != "my_attach-2" { t.Fatalf("persisted storage_dir = %q, want my_attach-2", u.StorageDir) } } // TestProfilePasswordMinLength 覆盖个人资料密码修改路径上的 // SECURITY_TODO #23。 func TestProfilePasswordMinLength(t *testing.T) { e := newSecurityTestEnv(t) alice := e.login(t, "alice") token := e.csrfTokenFor(t, alice) // 1 个字符的密码必须被拒绝,旧哈希保持不变。 w := postJSON(e, http.MethodPost, "/api/profile", alice, token, gin.H{"current_password": "pw-alice", "new_password": "a"}) if w.Code != http.StatusBadRequest || respCode(w) != "profile_password_short" { t.Fatalf("short password: status = %d, code = %q, want 400/profile_password_short", w.Code, respCode(w)) } var u models.User if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil { t.Fatalf("load alice: %v", err) } if !u.CheckPassword("pw-alice") { t.Fatal("old password no longer verifies after rejected change") } // 6 个字符的密码可被接受。 w = postJSON(e, http.MethodPost, "/api/profile", alice, token, gin.H{"current_password": "pw-alice", "new_password": "newpass6"}) if w.Code != http.StatusOK || !respOK(w) { t.Fatalf("valid password change: status = %d, body %s", w.Code, w.Body.String()) } if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil { t.Fatalf("reload alice: %v", err) } if !u.CheckPassword("newpass6") || u.CheckPassword("pw-alice") { t.Fatal("password change did not take effect") } } // TestProfileEmailValidation 覆盖个人资料路径上的 SECURITY_TODO #24。 func TestProfileEmailValidation(t *testing.T) { e := newSecurityTestEnv(t) alice := e.login(t, "alice") token := e.csrfTokenFor(t, alice) w := postJSON(e, http.MethodPost, "/api/profile", alice, token, gin.H{"email": "not-an-email"}) if w.Code != http.StatusBadRequest || respCode(w) != "profile_email_invalid" { t.Fatalf("invalid email: status = %d, code = %q", w.Code, respCode(w)) } var u models.User if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil { t.Fatalf("load alice: %v", err) } if u.Email != "" { t.Fatalf("invalid email was persisted: %q", u.Email) } w = postJSON(e, http.MethodPost, "/api/profile", alice, token, gin.H{"email": "alice@example.com"}) if w.Code != http.StatusOK || !respOK(w) { t.Fatalf("valid email: status = %d, body %s", w.Code, w.Body.String()) } if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil { t.Fatalf("reload alice: %v", err) } if u.Email != "alice@example.com" { t.Fatalf("valid email not persisted: %q", u.Email) } } // TestAdminUserPasswordAndEmailEnforcement 覆盖后台用户创建/更新路径上的 // SECURITY_TODO #23/#24。 func TestAdminUserPasswordAndEmailEnforcement(t *testing.T) { e := newSecurityTestEnv(t) admin := e.login(t, "admin") token := e.csrfTokenFor(t, admin) // 创建:短密码被拒绝(不创建任何行)。 create := gin.H{"username": "charlie", "password": "ab", "role": models.RoleAuthor} w := postJSON(e, http.MethodPost, "/api/admin/users", admin, token, create) if w.Code != http.StatusBadRequest || respCode(w) != "user_password_short" { t.Fatalf("create with short password: status = %d, code = %q, want 400/user_password_short", w.Code, respCode(w)) } // 创建:非法邮箱被拒绝。 create["password"] = "longenough" create["email"] = "abc" w = postJSON(e, http.MethodPost, "/api/admin/users", admin, token, create) if w.Code != http.StatusBadRequest || respCode(w) != "user_email_invalid" { t.Fatalf("create with invalid email: status = %d, code = %q", w.Code, respCode(w)) } var count int64 e.db.Model(&models.User{}).Where("username = ?", "charlie").Count(&count) if count != 0 { t.Fatal("charlie was created despite invalid input") } // 创建:合法数据成功。 create["email"] = "charlie@example.com" w = postJSON(e, http.MethodPost, "/api/admin/users", admin, token, create) if w.Code != http.StatusOK || !respOK(w) { t.Fatalf("create valid user: status = %d, body %s", w.Code, w.Body.String()) } e.db.Model(&models.User{}).Where("username = ?", "charlie").Count(&count) if count != 1 { t.Fatal("charlie was not created") } // 更新(密码重置路径):短密码被拒绝,哈希保持不变。 aliceID := userIDByUsername(t, e.db, "alice") w = postJSON(e, http.MethodPut, fmt.Sprintf("/api/admin/users/%d", aliceID), admin, token, gin.H{"password": "x", "role": models.RoleAuthor}) if w.Code != http.StatusBadRequest || respCode(w) != "user_password_short" { t.Fatalf("update with short password: status = %d, code = %q", w.Code, respCode(w)) } var u models.User if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil { t.Fatalf("load alice: %v", err) } if !u.CheckPassword("pw-alice") { t.Fatal("alice password changed by a rejected reset") } // 更新:非法邮箱被拒绝,旧值保留。 w = postJSON(e, http.MethodPut, fmt.Sprintf("/api/admin/users/%d", aliceID), admin, token, gin.H{"email": "bad", "role": models.RoleAuthor}) if w.Code != http.StatusBadRequest || respCode(w) != "user_email_invalid" { t.Fatalf("update with invalid email: status = %d, code = %q", w.Code, respCode(w)) } e.db.Where("username = ?", "alice").First(&u) if u.Email != "" { t.Fatalf("invalid admin-set email persisted: %q", u.Email) } } // TestRegisterRejectsInvalidEmail 覆盖注册上的 SECURITY_TODO #24。 func TestRegisterRejectsInvalidEmail(t *testing.T) { e := newSecurityTestEnv(t) if err := e.db.Model(&models.SiteSetting{}).Where("id = ?", 1).Update("allow_registration", true).Error; err != nil { t.Fatalf("enable registration: %v", err) } // 获取注册表单以获得匿名 CSRF 令牌 + 会话。 req := httptest.NewRequest(http.MethodGet, "/register", nil) w := httptest.NewRecorder() e.router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("GET /register: status = %d", w.Code) } m := csrfTokenRe.FindStringSubmatch(w.Body.String()) if m == nil { t.Fatal("register page did not render a CSRF token") } anonCookie := e.sessionCookie(w) fields := gin.H{ "username": "carol", "password": "secret1", "confirm_password": "secret1", "email": "abc", } w2 := postJSON(e, http.MethodPost, "/api/auth/register", anonCookie, m[1], fields) if w2.Code != http.StatusBadRequest { t.Fatalf("register invalid email: status = %d, want 400", w2.Code) } if code := respCode(w2); code != "register_email_invalid" { t.Fatalf("register invalid email: code = %q, want register_email_invalid", code) } var count int64 e.db.Model(&models.User{}).Where("username = ?", "carol").Count(&count) if count != 0 { t.Fatal("carol created with invalid email") } fields["email"] = "carol@example.com" w2 = postJSON(e, http.MethodPost, "/api/auth/register", anonCookie, m[1], fields) if w2.Code != http.StatusOK || !respOK(w2) { t.Fatalf("register valid email: status = %d, body %s", w2.Code, w2.Body.String()) } e.db.Model(&models.User{}).Where("username = ?", "carol").Count(&count) if count != 1 { t.Fatal("carol not created") } } // limiterEntryKey 通过扫描已跟踪条目返回某用户名的限流器键 // (前缀的客户端 IP 取决于测试传输方式)。 func limiterEntryKey(e *securityTestEnv, username string) string { for k := range e.limiter.entries { if strings.HasSuffix(k, "\x00"+username) { return k } } return "" } // TestLoginRateLimited 覆盖 SECURITY_TODO #10:重复失败会锁定 // IP+用户名键,成功登录后重置。 func TestLoginRateLimited(t *testing.T) { e := newSecurityTestEnv(t) loginAttempt := func(body gin.H) (*httptest.ResponseRecorder, string) { // 每次尝试使用全新的匿名会话(和 CSRF 令牌)。 req := httptest.NewRequest(http.MethodGet, "/login", nil) w := httptest.NewRecorder() e.router.ServeHTTP(w, req) m := csrfTokenRe.FindStringSubmatch(w.Body.String()) if m == nil { t.Fatal("login page did not render a CSRF token") } anonCookie := e.sessionCookie(w) w2 := postJSON(e, http.MethodPost, "/api/auth/login", anonCookie, m[1], body) return w2, anonCookie } bad := gin.H{"username": "alice", "password": "wrong-password"} for i := 0; i < maxLoginFailures; i++ { w, _ := loginAttempt(bad) if w.Code != http.StatusUnauthorized { t.Fatalf("attempt %d: status = %d, want 401", i+1, w.Code) } if code := respCode(w); code != "login_error" { t.Fatalf("attempt %d: code = %q, want login_error", i+1, code) } } // 下一次尝试(即使密码正确)也会被锁定。 good := gin.H{"username": "alice", "password": "pw-alice"} w, _ := loginAttempt(good) if w.Code != http.StatusTooManyRequests { t.Fatalf("locked attempt: status = %d, want 429", w.Code) } if code := respCode(w); code != "login_locked" { t.Fatalf("locked attempt: code = %q, want login_locked", code) } // 不同的键(用户名)不受影响。 w, _ = loginAttempt(gin.H{"username": "bob", "password": "pw-bob"}) if w.Code != http.StatusOK || respRedirect(w) != "/" { t.Fatalf("different user login during lock: status = %d, redirect = %q", w.Code, respRedirect(w)) } // 重置后,被锁定的键再次可用。 aliceKey := limiterEntryKey(e, "alice") if aliceKey == "" { t.Fatal("alice rate-limit entry not found") } e.limiter.Reset(aliceKey) w, _ = loginAttempt(good) if w.Code != http.StatusOK || respRedirect(w) != "/" { t.Fatalf("login after reset: status = %d, redirect = %q", w.Code, respRedirect(w)) } } // TestLoginTimingDoesNotRevealUser 断言 SECURITY_TODO #25 的结构性保证: // 未知用户名仍执行一次 bcrypt 比较(虚拟哈希)并记录一次失败, // 因此两个分支在设计上耗时不可区分。 func TestLoginTimingDoesNotRevealUser(t *testing.T) { e := newSecurityTestEnv(t) req := httptest.NewRequest(http.MethodGet, "/login", nil) w := httptest.NewRecorder() e.router.ServeHTTP(w, req) m := csrfTokenRe.FindStringSubmatch(w.Body.String()) if m == nil { t.Fatal("login page did not render a CSRF token") } anonCookie := e.sessionCookie(w) w2 := postJSON(e, http.MethodPost, "/api/auth/login", anonCookie, m[1], gin.H{"username": "does-not-exist-31415", "password": "anything"}) if w2.Code != http.StatusUnauthorized || respCode(w2) != "login_error" { t.Fatalf("unknown user: status = %d, code = %q", w2.Code, respCode(w2)) } // 未知用户的键必须被计入失败次数(若限流器共享), // 证明该分支走过了 Fail + 虚拟 bcrypt 路径。 if key := limiterEntryKey(e, "does-not-exist-31415"); key == "" { t.Fatal("unknown-user branch did not record a failure") } else if e.limiter.entries[key].failures != 1 { t.Fatalf("unknown-user failure count = %d, want 1", e.limiter.entries[key].failures) } } func TestSafeStorageDirNameAndValidators(t *testing.T) { for _, tc := range []struct { dir string ok bool }{ {"attachments", true}, {"my_attach-2", true}, {"A1-_", true}, {"", false}, {"../evil", false}, {"foo/bar", false}, {"a\\b", false}, {"/abs", false}, {"..", false}, {"a b", false}, {".hidden", false}, } { if got := safeStorageDirName(tc.dir); got != tc.ok { t.Errorf("safeStorageDirName(%q) = %v, want %v", tc.dir, got, tc.ok) } } if validatePassword("12345") { t.Error("validatePassword accepted 5 chars") } if !validatePassword("123456") { t.Error("validatePassword rejected 6 chars") } if !validateEmail("") || !validateEmail("user@example.com") { t.Error("validateEmail rejected empty or valid address") } if validateEmail("abc") || validateEmail("a@b@c") { t.Error("validateEmail accepted malformed address") } }