Files
FileShare/server/internal/api/security_fixes_test.go
T
SKYMirror 6f1a925833
Release 镜像 / 测试(推送前置门禁) (push) Failing after 12s
Release 镜像 / 多架构构建并推送 ACR (push) Skipped
26.9:品牌统一(fileshare)+ 版本号改为日期式
- 数据库默认文件 filecodebox.db → fileshare.db(config.go 默认值与全部文档/编排同步)
- Go module filecodebox → fileshare(全部 import 同步,build/vet/test 全绿)
- 应用版本 APP_VERSION 2.5.6 → 26.9(health 接口已验证返回 26.9)
- deploy 编排统一:compose 项目名、Postgres 默认凭据、minio 桶名、env 注释
- JWT issuer、存储临时目录前缀、web 包名同步 fileshare
- CI:镜像 tag 以 APP_VERSION 为唯一版本源,main/tag 推送即发布
  ${VER} + latest;tag 触发时校验 tag 名与 APP_VERSION 一致,防错版
- 本地开发库文件已改名 fileshare.db(含 -shm/-wal 清理)
2026-09-05 06:32:18 +08:00

194 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 L4enableChunk=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 M3chunk_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 L34 位自定义码拒绝、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("错误密码不应通过")
}
}