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,261 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"filecodebox/internal/audit"
|
||||
"filecodebox/internal/model"
|
||||
"filecodebox/internal/response"
|
||||
)
|
||||
|
||||
// auditHooks 审计钩子:由 API 层在响应前后填充与落库。
|
||||
// 中间件负责计时与公共字段(IP/UA/设备/耗时),业务上下文通过 auditEntry 传递。
|
||||
type auditEntry struct {
|
||||
Entry audit.Entry
|
||||
// start 请求进入审计中间件的时刻,用于计算耗时。
|
||||
start time.Time
|
||||
// writer 下载动作时包装的响应计数器。
|
||||
writer *bytesCountWriter
|
||||
// skip 为 true 表示业务 handler 显式跳过审计(AuditSkip)。
|
||||
skip bool
|
||||
// recorded 防止重复落库。
|
||||
recorded bool
|
||||
}
|
||||
|
||||
// bytesCountWriter 统计响应体写出字节数(用于下载审计)。
|
||||
type bytesCountWriter struct {
|
||||
gin.ResponseWriter
|
||||
count int64
|
||||
}
|
||||
|
||||
func (w *bytesCountWriter) Write(b []byte) (int, error) {
|
||||
n, err := w.ResponseWriter.Write(b)
|
||||
w.count += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (w *bytesCountWriter) WriteString(s string) (int, error) {
|
||||
n, err := w.ResponseWriter.WriteString(s)
|
||||
w.count += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Classifier 判定请求是否属于需审计的动作;返回动作名与是否命中。
|
||||
type Classifier func(c *gin.Context) (action string, ok bool)
|
||||
|
||||
// DefaultClassifier 按任务合同的默认路由语义分类:
|
||||
// - 上传:POST /share/file、/share/text、/chunk/upload*、/presign*
|
||||
// - 下载:GET /share/download、/share/select、/share/metadata
|
||||
// - 管理(L5):POST/PATCH/DELETE 的敏感管理操作——登录/登出、配置与密码
|
||||
// 修改、存储引擎切换、文件更新/删除/策略动作
|
||||
//
|
||||
// API 层可传入自定义分类器覆盖。
|
||||
func DefaultClassifier(c *gin.Context) (string, bool) {
|
||||
path := c.FullPath()
|
||||
if path == "" {
|
||||
path = c.Request.URL.Path
|
||||
}
|
||||
p := strings.TrimRight(path, "/")
|
||||
switch c.Request.Method {
|
||||
case http.MethodPost, http.MethodPut:
|
||||
switch {
|
||||
case p == "/share/file" || p == "/share/text":
|
||||
return audit.ActionUpload, true
|
||||
case strings.HasPrefix(p, "/chunk/upload"):
|
||||
return audit.ActionUpload, true
|
||||
case strings.HasPrefix(p, "/presign"):
|
||||
return audit.ActionUpload, true
|
||||
}
|
||||
if adminAuditActions[p] {
|
||||
return audit.ActionAdmin, true
|
||||
}
|
||||
case http.MethodPatch, http.MethodDelete:
|
||||
if adminAuditActions[p] {
|
||||
return audit.ActionAdmin, true
|
||||
}
|
||||
case http.MethodGet:
|
||||
switch p {
|
||||
case "/share/download", "/share/select", "/share/metadata":
|
||||
return audit.ActionDownload, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// adminAuditActions 需要审计的管理端敏感操作路由(L5)。
|
||||
var adminAuditActions = map[string]bool{
|
||||
"/admin/login": true,
|
||||
"/admin/logout": true,
|
||||
"/admin/config/update": true,
|
||||
"/admin/settings/password": true,
|
||||
"/admin/storage/switch": true,
|
||||
"/admin/file/update": true,
|
||||
"/admin/file/delete": true,
|
||||
"/admin/file/batch-delete": true,
|
||||
"/admin/file/batch-update": true,
|
||||
"/admin/file/policy-action": true,
|
||||
"/admin/file/batch-policy-action": true,
|
||||
}
|
||||
|
||||
// Audit 审计中间件:对分类器命中的 upload/download/admin 动作写审计日志。
|
||||
// handler 通过 AuditSet 填充取件码/文件名/字节数等业务字段;
|
||||
// handler 未显式 AuditRecordRequest 时按 HTTP 状态兜底落库。
|
||||
func Audit(service *audit.Service, classify Classifier) gin.HandlerFunc {
|
||||
if classify == nil {
|
||||
classify = DefaultClassifier
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
|
||||
action, ok := classify(c)
|
||||
// 未命中审计动作的请求直接放行,不产生审计记录。
|
||||
// L5:admin 类动作同样需要建 auditEntry 并落库(登录失败/配置变更等)。
|
||||
if !ok {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
entry := audit.Entry{
|
||||
Action: action,
|
||||
IP: GetClientIP(c),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
}
|
||||
info := audit.ParseUserAgent(entry.UserAgent)
|
||||
entry.DeviceOS = info.OS
|
||||
entry.DeviceBrowser = info.Browser
|
||||
entry.DeviceType = info.Type
|
||||
|
||||
// 交给后续 handler 填充
|
||||
state := &auditEntry{Entry: entry, start: start}
|
||||
c.Set("auditEntry", state)
|
||||
|
||||
// 下载动作:包装 Writer 以捕获实际写出字节数(必须在 c.Next() 前替换)
|
||||
if action == audit.ActionDownload {
|
||||
state.writer = &bytesCountWriter{ResponseWriter: c.Writer}
|
||||
c.Writer = state.writer
|
||||
}
|
||||
|
||||
c.Next()
|
||||
|
||||
// 下载兜底统计:handler 未填 TransferredBytes 时取响应写出字节
|
||||
if action == audit.ActionDownload && state.Entry.TransferredBytes == 0 &&
|
||||
!state.recorded && !state.skip && state.writer != nil {
|
||||
state.Entry.TransferredBytes = state.writer.count
|
||||
}
|
||||
|
||||
// handler 未显式落库时兜底记录
|
||||
ae, exists := c.Get("auditEntry")
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
state, isState := ae.(*auditEntry)
|
||||
if !isState || state.recorded || state.skip {
|
||||
return
|
||||
}
|
||||
state.Entry.Duration = time.Since(start)
|
||||
state.Entry.Actor = resolveActor(c)
|
||||
status := c.Writer.Status()
|
||||
switch {
|
||||
case state.Entry.Result != "":
|
||||
// handler 已给出结论
|
||||
case status >= 500:
|
||||
state.Entry.Result = model.AuditResultFailed
|
||||
case status == 401 || status == 403 || status == 423 || status == 429 || status == 428:
|
||||
state.Entry.Result = model.AuditResultDenied
|
||||
case status >= 400:
|
||||
state.Entry.Result = model.AuditResultFailed
|
||||
default:
|
||||
state.Entry.Result = model.AuditResultSuccess
|
||||
}
|
||||
switch {
|
||||
case state.Entry.ErrorMsg != "":
|
||||
// handler 已给出错误信息
|
||||
case c.Errors.String() != "":
|
||||
state.Entry.ErrorMsg = c.Errors.String()
|
||||
case status >= 400:
|
||||
// 兜底:记录 HTTP 状态
|
||||
state.Entry.ErrorMsg = "HTTP " + itoa64(int64(status))
|
||||
}
|
||||
service.Record(state.Entry)
|
||||
state.recorded = true
|
||||
}
|
||||
}
|
||||
|
||||
// AuditEntry 获取当前请求的审计状态(由 Audit 中间件创建)。
|
||||
func AuditEntry(c *gin.Context) *auditEntry {
|
||||
if v, ok := c.Get("auditEntry"); ok {
|
||||
if ae, ok := v.(*auditEntry); ok {
|
||||
return ae
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuditSet 填充当前请求的审计字段;仅对已启用审计的请求生效。
|
||||
func AuditSet(c *gin.Context, fn func(e *audit.Entry)) {
|
||||
if ae := AuditEntry(c); ae != nil && fn != nil {
|
||||
fn(&ae.Entry)
|
||||
}
|
||||
}
|
||||
|
||||
// AuditRecordRequest 显式触发落库(含耗时);由 handler 在响应前调用。
|
||||
func AuditRecordRequest(c *gin.Context, service *audit.Service, result, errMsg string) {
|
||||
ae := AuditEntry(c)
|
||||
if ae == nil || ae.recorded || ae.skip {
|
||||
return
|
||||
}
|
||||
ae.Entry.Duration = time.Since(ae.start)
|
||||
ae.Entry.Result = result
|
||||
ae.Entry.ErrorMsg = errMsg
|
||||
ae.Entry.Actor = resolveActor(c)
|
||||
service.Record(ae.Entry)
|
||||
ae.recorded = true
|
||||
}
|
||||
|
||||
// AuditSkip 标记当前请求不写审计。
|
||||
func AuditSkip(c *gin.Context) {
|
||||
if ae := AuditEntry(c); ae != nil {
|
||||
ae.skip = true
|
||||
}
|
||||
}
|
||||
|
||||
// resolveActor 判断请求者角色:管理员 JWT 有效 → admin,否则 guest。
|
||||
func resolveActor(c *gin.Context) string {
|
||||
header := c.GetHeader("Authorization")
|
||||
if len(header) > 7 && header[:7] == "Bearer " {
|
||||
// 仅检查声明是否有效,不重复校验签名逻辑(AdminAuth 已处理受保护路由)
|
||||
if _, ok := c.Get("claims"); ok {
|
||||
return audit.ActorAdmin
|
||||
}
|
||||
}
|
||||
return audit.ActorGuest
|
||||
}
|
||||
|
||||
// AuditRecord 显式按结果落库;duration 由中间件按起始时间计算。
|
||||
func AuditRecord(c *gin.Context, service *audit.Service, result, errMsg string) {
|
||||
ae := AuditEntry(c)
|
||||
if ae == nil || ae.recorded || ae.skip {
|
||||
return
|
||||
}
|
||||
AuditRecordRequest(c, service, result, errMsg)
|
||||
}
|
||||
|
||||
// GuardNotInitialized 系统未初始化守卫:除 setup/health 外返回 428。
|
||||
func GuardNotInitialized(isInit func() bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if isInit() {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
if path == "/setup" || path == "/api/v1/health" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
response.Fail(c, 428, "系统未初始化,请先完成初始化")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user