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 全绿;二进制端到端冒烟通过
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
// 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 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("错误密码不应通过")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user