package handlers import ( "bytes" "fmt" "image" "image/color" "image/png" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "github.com/gin-gonic/gin" "go_blog/models" ) // seedUploadType 直接在数据库中插入上传文件类型行并重载配置缓存, // 模拟修复之前创建的策略行。 func seedUploadType(t *testing.T, e *securityTestEnv, ext, category string) { t.Helper() if err := e.db.Create(&models.UploadFileType{ Extension: ext, MimeType: "application/octet-stream", Category: category, Enabled: true, }).Error; err != nil { t.Fatalf("seed upload type %s: %v", ext, err) } models.LoadConfigCache(e.db) } // pngBytes 生成一张小的合法 PNG。 func pngBytes(t *testing.T) []byte { t.Helper() img := image.NewRGBA(image.Rect(0, 0, 8, 8)) for x := 0; x < 8; x++ { for y := 0; y < 8; y++ { img.Set(x, y, color.RGBA{R: 0x33, G: 0x66, B: 0x99, A: 0xff}) } } var buf bytes.Buffer if err := png.Encode(&buf, img); err != nil { t.Fatalf("encode png: %v", err) } return buf.Bytes() } // multipartUpload 提交携带一个文件字段的 multipart 表单。 func (e *securityTestEnv) multipartUpload(t *testing.T, path, cookie, csrfToken, fieldName, filename string, content []byte, fields map[string]string) *httptest.ResponseRecorder { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) mw.WriteField("_csrf", csrfToken) for k, v := range fields { mw.WriteField(k, v) } fw, _ := mw.CreateFormFile(fieldName, filename) fw.Write(content) mw.Close() return e.do(http.MethodPost, path, cookie, strings.NewReader(buf.String()), mw.FormDataContentType()) } func reloadAlice(t *testing.T, e *securityTestEnv) models.User { t.Helper() var alice models.User if err := e.db.Where("username = ?", "alice").First(&alice).Error; err != nil { t.Fatalf("load alice: %v", err) } return alice } // --- #20:已禁用 / 已锁定 / 已删除用户的过期会话 --- func TestDisabledUserSessionInvalidated(t *testing.T) { e := newSecurityTestEnv(t) aliceCookie := e.login(t, "alice") // 健全性检查:账户正常时会话有效。 if w := e.do(http.MethodGet, "/my/whoami", aliceCookie, nil, ""); w.Code != http.StatusOK { t.Fatalf("pre-disable /my/whoami: status=%d", w.Code) } for _, tc := range []struct { name string status int }{ {"disabled", models.StatusDisabled}, {"locked", models.StatusLocked}, } { e.db.Model(&models.User{}).Where("username = ?", "alice").Update("status", tc.status) w := e.do(http.MethodGet, "/my/whoami", aliceCookie, nil, "") if w.Code != http.StatusFound || w.Header().Get("Location") != "/login" { t.Fatalf("%s user with stale cookie: status=%d location=%q, want 302 /login", tc.name, w.Code, w.Header().Get("Location")) } // 恢复状态,使下一个用例重新从正常账户开始。 e.db.Model(&models.User{}).Where("username = ?", "alice").Update("status", models.StatusNormal) } } func TestSoftDeletedUserSessionInvalidated(t *testing.T) { e := newSecurityTestEnv(t) aliceCookie := e.login(t, "alice") e.db.Where("username = ?", "alice").Delete(&models.User{}) w := e.do(http.MethodGet, "/my/whoami", aliceCookie, nil, "") if w.Code != http.StatusFound || w.Header().Get("Location") != "/login" { t.Fatalf("soft-deleted user with stale cookie: status=%d location=%q, want 302 /login", w.Code, w.Header().Get("Location")) } } func TestDisabledUserCommentsRequireApproval(t *testing.T) { e := newSecurityTestEnv(t) aliceCookie := e.login(t, "alice") token := e.csrfTokenFor(t, aliceCookie) // 该场景下访客必须通过审核。 e.db.Model(&models.CommentConfig{}).Where("id = ?", 1).Update("guest_require_approval", true) models.LoadConfigCache(e.db) postComment := func() models.Comment { t.Helper() w := postJSON(e, http.MethodPost, "/api/article/alice-post/comments", aliceCookie, token, gin.H{ "name": "alice", "email": "alice@example.com", "content": "comment body", }) if w.Code != http.StatusOK { t.Fatalf("POST comment: status=%d body=%s", w.Code, w.Body.String()) } var cm models.Comment if err := e.db.Last(&cm).Error; err != nil { t.Fatalf("load comment: %v", err) } return cm } // 对照组:alice 为正常用户时,其评论自动通过。 if cm := postComment(); cm.Status != models.CommentApproved { t.Fatalf("normal user comment status=%d, want approved", cm.Status) } // 被锁定后,她的过期会话不再赋予自动通过权限: // SetUserContext 将她视为未登录,因此评论遵循访客审核策略。 e.db.Model(&models.User{}).Where("username = ?", "alice").Update("status", models.StatusLocked) if cm := postComment(); cm.Status != models.CommentPending { t.Fatalf("locked user comment status=%d, want pending", cm.Status) } } // --- #21:头像上传 XSS 链 --- func TestAddUploadFileTypeRejectsDangerousExtensions(t *testing.T) { e := newSecurityTestEnv(t) admin := e.login(t, "admin") token := e.csrfTokenFor(t, admin) for _, ext := range []string{"html", ".htm", "SVG", "xhtml", ".xml", "js"} { w := postJSON(e, http.MethodPost, "/api/admin/settings/upload", admin, token, gin.H{"action": "add_type", "extension": ext, "category": models.CategoryImage}) if w.Code != http.StatusBadRequest || respCode(w) != "settings_upload_dangerous_ext" { t.Fatalf("add type %q: status=%d code=%q, want 400/settings_upload_dangerous_ext", ext, w.Code, respCode(w)) } var count int64 normalized := strings.ToLower(ext) if !strings.HasPrefix(normalized, ".") { normalized = "." + normalized } e.db.Model(&models.UploadFileType{}).Where("extension = ?", normalized).Count(&count) if count != 0 { t.Fatalf("dangerous extension %q was persisted", ext) } } // 对照组:良性的扩展名仍然被接受。 w := postJSON(e, http.MethodPost, "/api/admin/settings/upload", admin, token, gin.H{"action": "add_type", "extension": "md", "category": models.CategoryDocument}) if w.Code != http.StatusOK || !respOK(w) { t.Fatalf("add benign type: status=%d body=%s", w.Code, w.Body.String()) } var count int64 e.db.Model(&models.UploadFileType{}).Where("extension = ?", ".md").Count(&count) if count != 1 { t.Fatalf("benign extension .md not created (count=%d)", count) } } func TestUploadAvatarRejectsNonImage(t *testing.T) { e := newSecurityTestEnv(t) aliceCookie := e.login(t, "alice") token := e.csrfTokenFor(t, aliceCookie) htmlPayload := []byte("") // 模拟黑名单之前已配置的危险类型(纵深防御):类别检查必须拒绝它。 seedUploadType(t, e, ".html", models.CategoryOther) w := e.multipartUpload(t, "/api/profile/avatar", aliceCookie, token, "avatar", "evil.html", htmlPayload, nil) if w.Code != http.StatusBadRequest { t.Fatalf("upload .html (other category): status=%d body=%s, want 400", w.Code, w.Body.String()) } // 即使是误分类为 "image" 的旧版 .html 行,也会被解码步骤拦下—— // 不会再存储原始字节。 seedUploadType(t, e, ".htm", models.CategoryImage) w = e.multipartUpload(t, "/api/profile/avatar", aliceCookie, token, "avatar", "evil.htm", htmlPayload, nil) if w.Code != http.StatusBadRequest { t.Fatalf("upload .htm (image category): status=%d body=%s, want 400", w.Code, w.Body.String()) } // 伪装在白名单图片扩展名之后的 HTML 同样被拒绝。 seedUploadType(t, e, ".jpg", models.CategoryImage) w = e.multipartUpload(t, "/api/profile/avatar", aliceCookie, token, "avatar", "x.jpg", htmlPayload, nil) if w.Code != http.StatusBadRequest { t.Fatalf("upload html as .jpg: status=%d body=%s, want 400", w.Code, w.Body.String()) } // 未存储任何内容,头像保持不变。 if alice := reloadAlice(t, e); alice.Avatar != "" { t.Fatalf("avatar unexpectedly set to %q", alice.Avatar) } if _, err := os.Stat(filepath.Join(e.storageDir, "avatars")); !os.IsNotExist(err) { entries, _ := os.ReadDir(filepath.Join(e.storageDir, "avatars")) for _, en := range entries { t.Logf("avatars dir entry: %s", en.Name()) } t.Fatal("avatar directory should not contain any file after rejected uploads") } // 真实图片被接受,并处理为规范化 JPEG。 seedUploadType(t, e, ".png", models.CategoryImage) w = e.multipartUpload(t, "/api/profile/avatar", aliceCookie, token, "avatar", "me.png", pngBytes(t), nil) if w.Code != http.StatusOK { t.Fatalf("upload valid png: status=%d body=%s", w.Code, w.Body.String()) } alice := reloadAlice(t, e) if want := fmt.Sprintf("%d.jpg", alice.ID); alice.Avatar != want { t.Fatalf("avatar = %q, want %q", alice.Avatar, want) } if _, err := os.Stat(filepath.Join(e.storageDir, "avatars", alice.Avatar)); err != nil { t.Fatalf("processed avatar file missing: %v", err) } } func TestUpdateProfileAvatarRejectsNonImage(t *testing.T) { e := newSecurityTestEnv(t) aliceCookie := e.login(t, "alice") token := e.csrfTokenFor(t, aliceCookie) seedUploadType(t, e, ".png", models.CategoryImage) seedUploadType(t, e, ".html", models.CategoryImage) // 旧版误分类的行 // 白名单扩展名背后的 HTML 必须被 JSON API 拒绝, // 且不得向 avatars/ 写入任何内容。 htmlPayload := []byte("") w := e.multipartUpload(t, "/api/profile/avatar", aliceCookie, token, "avatar", "evil.html", htmlPayload, nil) if w.Code != http.StatusBadRequest { t.Fatalf("upload html avatar: status=%d body=%s, want 400", w.Code, w.Body.String()) } if alice := reloadAlice(t, e); alice.Avatar != "" { t.Fatalf("avatar unexpectedly set to %q", alice.Avatar) } // 真实图片经过处理并以 JPEG 保存。 w = e.multipartUpload(t, "/api/profile/avatar", aliceCookie, token, "avatar", "me.png", pngBytes(t), nil) if w.Code != http.StatusOK { t.Fatalf("upload valid avatar: status=%d body=%s", w.Code, w.Body.String()) } alice := reloadAlice(t, e) if want := fmt.Sprintf("%d.jpg", alice.ID); alice.Avatar != want { t.Fatalf("avatar = %q, want %q", alice.Avatar, want) } if _, err := os.Stat(filepath.Join(e.storageDir, "avatars", alice.Avatar)); err != nil { t.Fatalf("processed avatar file missing: %v", err) } }