// security_fixes_test.go — 安全审计修复项行为测试: // L4 enableChunk 强制、M2 presign 大小/类型校验、L3 提码长度、M3 chunk_size 上限。 package api import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/gin-gonic/gin" "fileshare/internal/model" "fileshare/internal/settings" ) // postJSON 以 JSON body 调用 POST 端点。 func postJSON(d *Deps, path string, body any) *httptest.ResponseRecorder { var reader *bytes.Reader if body == nil { reader = bytes.NewReader(nil) } else { raw, _ := json.Marshal(body) reader = bytes.NewReader(raw) } req := httptest.NewRequest(http.MethodPost, path, reader) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = req c.Params = append(c.Params, gin.Param{Key: "uploadID", Value: req.URL.Path[len("/presign/upload/confirm/"):]}) d.presignConfirm(c) return w } // sha256LegacyHash 构造旧版 sha256$salt$hash 格式(M1 迁移测试用)。 func sha256LegacyHash(password string) string { salt := make([]byte, 16) for i := range salt { salt[i] = byte(i) } saltHex := hex.EncodeToString(salt) sum := sha256.Sum256([]byte(saltHex + password)) return "sha256$" + saltHex + "$" + hex.EncodeToString(sum[:]) } // TestChunkToggleEnforced L4:enableChunk=0 时 /chunk 相关端点一律 403。 func TestChunkToggleEnforced(t *testing.T) { d := newPolicyTestDeps(t) // 默认 enableChunk=0 w := chunkInitJSON(d, `{"file_name":"a.png","file_size":500,"chunk_size":1024,"file_hash":"h"}`) if code, _ := respBody(t, w); code != http.StatusForbidden { t.Fatalf("enableChunk=0 时 init 应 403: %d %s", code, w.Body.String()) } // 开启后放行 if w := patchConfig(d, map[string]any{"enableChunk": 1}); w.Code != 200 { t.Fatalf("patch enableChunk: %d", w.Code) } w = chunkInitJSON(d, `{"file_name":"a.png","file_size":500,"chunk_size":1024,"file_hash":"h"}`) if code, _ := respBody(t, w); code != 200 { t.Fatalf("enableChunk=1 时 init 应 200: %d %s", code, w.Body.String()) } } // TestChunkSizeCap M3:chunk_size 超过 32MB 上限时 400。 func TestChunkSizeCap(t *testing.T) { d := newPolicyTestDeps(t) if w := patchConfig(d, map[string]any{"enableChunk": 1}); w.Code != 200 { t.Fatalf("patch enableChunk: %d", w.Code) } w := chunkInitJSON(d, `{"file_name":"a.bin","file_size":70000000000,"chunk_size":34000000,"file_hash":"h"}`) if code, _ := respBody(t, w); code != http.StatusBadRequest { t.Fatalf("chunk_size 超上限应 400: %d %s", code, w.Body.String()) } } // TestPickupCodeMinLen L3:4 位自定义码拒绝、5 位通过。 func TestPickupCodeMinLen(t *testing.T) { if err := validatePickupCode("abcd"); err == nil { t.Fatal("4 位码应被拒绝") } if err := validatePickupCode("abcde"); err != nil { t.Fatalf("5 位码应通过: %v", err) } } // TestPresignConfirmRejectsOversizeObject M2: // 直传会话 confirm 时,若对象实际大小超过策略上限,应删除对象并 403。 func TestPresignConfirmRejectsOversizeObject(t *testing.T) { d := newPolicyTestDeps(t) ctx := context.Background() // 声明 10 字节、策略上限 100 → 实际 PUT 500 字节对象 if err := d.Mgr.UpdateKV(ctx, map[string]any{"max_file_size": 100}); err != nil { t.Fatalf("UpdateKV: %v", err) } if err := d.Mgr.Reload(ctx); err != nil { t.Fatalf("Reload: %v", err) } uploadID := "test-oversize-confirm" savePath := "share/data/presign_test.bin" if _, err := d.Store.SaveFile(ctx, bytes.NewReader(make([]byte, 500)), savePath); err != nil { t.Fatalf("SaveFile: %v", err) } sess := model.PresignUploadSession{ UploadID: uploadID, FileName: "presign_test.bin", FileSize: 10, SavePath: savePath, Mode: "direct", ExpireValue: 1, ExpireStyle: "day", CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour), Engine: "local", } if err := d.DB.WithContext(ctx).Create(&sess).Error; err != nil { t.Fatalf("create session: %v", err) } res := model.StorageReservation{Token: "presign:" + uploadID, Size: 10, ExpiresAt: time.Now().Add(time.Hour)} if err := d.DB.WithContext(ctx).Create(&res).Error; err != nil { t.Fatalf("create reservation: %v", err) } w := postJSON(d, "/presign/upload/confirm/"+uploadID, nil) if w.Code != http.StatusForbidden { t.Fatalf("超限对象 confirm 应 403: %d %s", w.Code, w.Body.String()) } // 对象应被删除、预留应释放 if ok, _ := d.Store.FileExists(ctx, savePath); ok { t.Fatal("超限对象应被服务端删除") } var cnt int64 _ = d.DB.WithContext(ctx).Model(&model.StorageReservation{}).Where("token = ?", res.Token).Count(&cnt).Error if cnt != 0 { t.Fatal("预留应被释放") } } // TestPresignConfirmRejectsSizeMismatch M2:实际大小与声明差超过 ±1KB 时 400。 func TestPresignConfirmRejectsSizeMismatch(t *testing.T) { d := newPolicyTestDeps(t) ctx := context.Background() uploadID := "test-mismatch-confirm" savePath := "share/data/presign_mismatch.bin" if _, err := d.Store.SaveFile(ctx, bytes.NewReader(make([]byte, 2048)), savePath); err != nil { t.Fatalf("SaveFile: %v", err) } sess := model.PresignUploadSession{ UploadID: uploadID, FileName: "presign_mismatch.bin", FileSize: 10, SavePath: savePath, Mode: "proxy", // proxy 模式同样走大小核对(多引擎一致) ExpireValue: 1, ExpireStyle: "day", CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour), Engine: "local", } if err := d.DB.WithContext(ctx).Create(&sess).Error; err != nil { t.Fatalf("create session: %v", err) } res := model.StorageReservation{Token: "presign:" + uploadID, Size: 10, ExpiresAt: time.Now().Add(time.Hour)} if err := d.DB.WithContext(ctx).Create(&res).Error; err != nil { t.Fatalf("create reservation: %v", err) } w := postJSON(d, "/presign/upload/confirm/"+uploadID, nil) if w.Code != http.StatusBadRequest { t.Fatalf("大小不符 confirm 应 400: %d %s", w.Code, w.Body.String()) } } // TestAdminPasswordAutoUpgrade M1:明文/旧哈希经 VerifyPassword 后 NeedsRehash 为真, // bcrypt 哈希不再需要升级。 func TestAdminPasswordAutoUpgrade(t *testing.T) { if !settings.NeedsRehash("FileCodeBox2023") { t.Fatal("明文哈希需要升级") } legacy := sha256LegacyHash("pwd12345") if !settings.NeedsRehash(legacy) { t.Fatal("sha256 哈希需要升级") } if !settings.VerifyPassword("pwd12345", legacy) { t.Fatal("旧 sha256 哈希兼容校验失败") } b := settings.HashPassword("pwd12345") if settings.NeedsRehash(b) { t.Fatal("bcrypt 哈希不需要升级") } if !settings.VerifyPassword("pwd12345", b) { t.Fatal("bcrypt 校验失败") } if settings.VerifyPassword("wrong", b) { t.Fatal("错误密码不应通过") } }