Files
go_blog/handlers/session_upload_security_test.go
dsh 9a5a7fa929 fix: SECURITY_TODO #32 零碎加固(status 枚举校验/严格 uint 解析/危险扩展名补充)
- admin_user.go: UserCreate/UserUpdate 的 status 无枚举校验,可存任意
  int(如 99)——新增 validUserStatus 限定 {0,1,2,3},非法 400
  (i18n user_status_invalid 中英新增)
- attachment.go: parseUintParam/parseUintForm 由 fmt.Sscanf("%d") 改为
  strconv.ParseUint 严格解析——"5abc" 不再被宽松解析为 5
- settings.go: dangerousUploadExtensions 补充 .xsl/.xslt/.shtml
  (XSLT 可内嵌脚本、SSI 可包含文件;nosniff 已兜底,仅完整性)
- 测试: TestUserStatusEnumRejected / TestParseUintStrict /
  TestAddUploadFileTypeRejectsDangerousExtensions 扩展用例
2026-08-27 21:48:27 +08:00

282 lines
10 KiB
Go

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)
// SECURITY_TODO #32:黑名单补充 .xsl/.xslt/.shtml 后并入同一用例。
for _, ext := range []string{"html", ".htm", "SVG", "xhtml", ".xml", "js", ".xsl", ".xslt", ".shtml"} {
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("<html><script>alert(document.cookie)</script></html>")
// 模拟黑名单之前已配置的危险类型(纵深防御):类别检查必须拒绝它。
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("<html><script>alert(1)</script></html>")
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)
}
}