- profile.go:UpdateProfile 改 JSON 绑定(profileRequest),移除内嵌
头像文件分支(头像统一走 /api/profile/avatar,XSS 链校验保留在
UploadAvatar);错误改 APIError(400 profile_wrong_password/
profile_password_short/profile_email_invalid、404 user_not_found、
500),成功 {ok,redirect:/profile?saved=1}
- main.go:/profile 组旧 POST 移除,/api/profile[/avatar] 分组收敛
- profile.html:主表单改 blogAPI(头像 cropper 流程独立不变),
新增 profileError 错误区
- 测试:TestProfilePasswordMinLength/TestProfileEmailValidation 改
JSON(400+code);TestUpdateProfileAvatarRejectsNonImage 改打
/api/profile/avatar;env 路由同步 /api/profile
- go build/vet/test 全绿
394 lines
14 KiB
Go
394 lines
14 KiB
Go
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")
|
|
}
|
|
}
|