Files
FileShare/server/internal/api/security_fixes_test.go
T
SKYMirror 7f060dd0e4 26.9(安全审计修复版)
Go 1.27.1 (Gin+GORM) + Vue 3 文件快传服务:

- 安全审计全部修复(docs/security-audit-2026-09-05.md):
  bcrypt 密码哈希与自动升级、presign 直传服务端大小/内容校验、
  全局请求体上限、依赖升级(govulncheck 0 命中)、janitor 后台清理、
  管理端审计动作落库、/admin CORS 收紧、通知内容白名单净化、
  会话默认 7 天、限流缓存故障降级、robots.txt 端点等
- 前端:取件链接复制修复(不再重复拼接提取码)、markdown 净化器加固
- Redis 支持库号(FCB_REDIS_DB / redis://…/db URL)
- 文档:docs/api/* 与 openapi.yaml 同步最新行为(robots.txt、
  提码 5 位起、chunk 32MiB 上限、admin 审计动作等)

验证:gofmt/go vet/go test 全绿;二进制端到端冒烟通过
2026-09-05 04:22:41 +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"
"filecodebox/internal/model"
"filecodebox/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("错误密码不应通过")
}
}