- #20 AuthRequired 改为 AuthRequired(db):受保护路由每次回库校验 Status == StatusNormal 且未软删,失败清 session(保留 lang/csrf_token, 与登录轮换口径一致)并 302 /login;session user_id 先断言为数值再入 GORM(呼应 #19),AdminRequired 同步加固;SetUserContext 仅在用户 存在且状态正常时置 is_logged_in——禁用用户发评论不再自动通过, 回落游客审核策略 - #21 头像两个分支(UploadAvatar / UpdateProfile)强制 Category == image,解码失败直接拒绝、删除"回退存原始字节"路径, 统一经 processAvatar 解码→256px 缩放→JPEG 重编码;addUploadFileType 增加危险扩展黑名单(.html/.htm/.xhtml/.xht/.svg/.xml/.js/.mjs), 拒绝添加并在上传设置页提示(模板 + 中英 i18n) - 附带修复:processAvatar 依赖的 png/gif 解码器此前未注册(旧代码靠 回退存原始字节掩盖,PNG 头像从未真正处理过),补 blank import - 新增回归测试 session_upload_security_test.go(6 用例:禁用/锁定/ 软删旧 cookie 302、锁定用户评论转 pending、6 组危险扩展拒绝、 伪装扩展名头像拒绝且磁盘零写入、正常图片转存 .jpg;已变异验证: 去掉任一修复对应测试即失败) - SECURITY_TODO.md 勾选 #20/#21 并更新执行顺序
296 lines
11 KiB
Go
296 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"go_blog/models"
|
|
)
|
|
|
|
// seedUploadType inserts an upload file-type row directly in the DB and
|
|
// reloads the config cache, simulating policy rows created before a fix.
|
|
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 renders a small valid 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 posts a multipart form carrying one file field.
|
|
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: stale sessions of disabled / locked / deleted users ---
|
|
|
|
func TestDisabledUserSessionInvalidated(t *testing.T) {
|
|
e := newSecurityTestEnv(t)
|
|
aliceCookie := e.login(t, "alice")
|
|
|
|
// Sanity: the session works while the account is normal.
|
|
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"))
|
|
}
|
|
// Restore so the next case starts from a normal account again.
|
|
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)
|
|
|
|
// Guests must pass moderation for this scenario.
|
|
e.db.Model(&models.CommentConfig{}).Where("id = ?", 1).Update("guest_require_approval", true)
|
|
models.LoadConfigCache(e.db)
|
|
|
|
postComment := func() models.Comment {
|
|
t.Helper()
|
|
form := url.Values{}
|
|
form.Set("name", "alice")
|
|
form.Set("email", "alice@example.com")
|
|
form.Set("content", "comment body")
|
|
form.Set("_csrf", token)
|
|
w := e.do(http.MethodPost, "/article/alice-post/comments", aliceCookie,
|
|
strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
|
|
if w.Code != http.StatusFound {
|
|
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
|
|
}
|
|
|
|
// Control: while alice is a normal user her comment is auto-approved.
|
|
if cm := postComment(); cm.Status != models.CommentApproved {
|
|
t.Fatalf("normal user comment status=%d, want approved", cm.Status)
|
|
}
|
|
|
|
// After being locked, her stale session no longer grants auto-approval:
|
|
// SetUserContext reports her as logged out, so the comment follows the
|
|
// guest moderation policy.
|
|
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: avatar upload XSS chain ---
|
|
|
|
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"} {
|
|
form := url.Values{}
|
|
form.Set("_csrf", token)
|
|
form.Set("action", "add_type")
|
|
form.Set("extension", ext)
|
|
form.Set("category", models.CategoryImage)
|
|
w := e.do(http.MethodPost, "/admin/settings/upload", admin,
|
|
strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
|
|
if w.Code != http.StatusFound || !strings.Contains(w.Header().Get("Location"), "error=dangerous_ext") {
|
|
t.Fatalf("add type %q: status=%d location=%q, want 302 with error=dangerous_ext",
|
|
ext, w.Code, w.Header().Get("Location"))
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Control: a benign extension is still accepted.
|
|
form := url.Values{}
|
|
form.Set("_csrf", token)
|
|
form.Set("action", "add_type")
|
|
form.Set("extension", "md")
|
|
form.Set("category", models.CategoryDocument)
|
|
w := e.do(http.MethodPost, "/admin/settings/upload", admin,
|
|
strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
|
|
if w.Code != http.StatusFound || !strings.Contains(w.Header().Get("Location"), "saved=1") {
|
|
t.Fatalf("add benign type: status=%d location=%q", w.Code, w.Header().Get("Location"))
|
|
}
|
|
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>")
|
|
|
|
// Simulate a pre-existing dangerous type configured before the blacklist
|
|
// (defense in depth): the category check must reject it.
|
|
seedUploadType(t, e, ".html", models.CategoryOther)
|
|
w := e.multipartUpload(t, "/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())
|
|
}
|
|
|
|
// Even a legacy .html row miscategorized as "image" is stopped by the
|
|
// decode step — raw bytes are never stored anymore.
|
|
seedUploadType(t, e, ".htm", models.CategoryImage)
|
|
w = e.multipartUpload(t, "/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 disguised behind a whitelisted image extension is likewise rejected.
|
|
seedUploadType(t, e, ".jpg", models.CategoryImage)
|
|
w = e.multipartUpload(t, "/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())
|
|
}
|
|
|
|
// Nothing was stored and the avatar is unchanged.
|
|
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")
|
|
}
|
|
|
|
// A real image is accepted, processed to a normalized JPEG.
|
|
seedUploadType(t, e, ".png", models.CategoryImage)
|
|
w = e.multipartUpload(t, "/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) // legacy miscategorized row
|
|
|
|
// HTML behind a whitelisted extension must be rejected with the upload
|
|
// error redirect, and nothing may be written to avatars/.
|
|
htmlPayload := []byte("<html><script>alert(1)</script></html>")
|
|
w := e.multipartUpload(t, "/profile", aliceCookie, token, "avatar", "evil.html", htmlPayload,
|
|
map[string]string{"display_name": "alice"})
|
|
if w.Code != http.StatusFound || !strings.Contains(w.Header().Get("Location"), "error=upload") {
|
|
t.Fatalf("update profile with html avatar: status=%d location=%q, want 302 error=upload",
|
|
w.Code, w.Header().Get("Location"))
|
|
}
|
|
if alice := reloadAlice(t, e); alice.Avatar != "" {
|
|
t.Fatalf("avatar unexpectedly set to %q", alice.Avatar)
|
|
}
|
|
|
|
// A real image goes through processing and is saved as JPEG.
|
|
w = e.multipartUpload(t, "/profile", aliceCookie, token, "avatar", "me.png", pngBytes(t),
|
|
map[string]string{"display_name": "alice"})
|
|
if w.Code != http.StatusFound || !strings.Contains(w.Header().Get("Location"), "saved=1") {
|
|
t.Fatalf("update profile with valid avatar: status=%d location=%q", w.Code, w.Header().Get("Location"))
|
|
}
|
|
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)
|
|
}
|
|
}
|