diff --git a/model/hosts.go b/model/hosts.go index 16cc220..93c3e7f 100644 --- a/model/hosts.go +++ b/model/hosts.go @@ -52,6 +52,7 @@ type Hosts struct { CookieSecurityJSON string `gorm:"type:text" json:"cookie_security_json"` //Cookie安全保护配置 json(HttpOnly/Secure/SameSite) CsrfJSON string `gorm:"type:text" json:"csrf_json"` //CSRF防护配置 json(Origin/Referer 强校验) TamperJSON string `gorm:"type:text" json:"tamper_json"` //网页防篡改配置 json(响应基线比对) + UploadSecurityJSON string `gorm:"type:text" json:"upload_security_json"` //文件上传内容检测配置 json(扩展名/Webshell/类型/大小) IPMode string `gorm:"size:20" json:"ip_mode"` //IP提取模式: "nic" 网卡模式 或 "proxy" 代理模式 } @@ -274,6 +275,45 @@ func ParseTamperConfig(jsonStr string) TamperConfig { return c } +// UploadSecurityConfig 文件上传内容检测配置(multipart 上传的扩展名/Webshell/类型/大小四维检测) +type UploadSecurityConfig struct { + IsEnable int `json:"is_enable"` // 1 开启 0 关闭(默认0,老站点不受影响) + CheckExt int `json:"check_ext"` // 1 启用扩展名黑名单检测 + ExtBlacklist string `json:"ext_blacklist"` // 危险扩展名黑名单,逗号分隔;空用默认 + CheckContent int `json:"check_content"` // 1 启用 Webshell 内容特征检测 + CheckMagic int `json:"check_magic"` // 1 启用“声明类型与真实内容不符”检测 + CheckSize int `json:"check_size"` // 1 启用单文件大小上限检测 + MaxSizeKB int `json:"max_size_kb"` // 单文件大小上限KB=检测缓冲上限,默认10240(10MB) + OverLimitAction string `json:"over_limit_action"` // 请求体超过检测上限时:block(默认,fail-closed防绕过)/pass(放行不检测) + IncludePaths string `json:"include_paths"` // 只检测这些路径前缀,换行分隔;空=所有路径 + ExcludePaths string `json:"exclude_paths"` // 跳过这些路径前缀,换行分隔;优先于 include +} + +// DefaultUploadExtBlacklist 默认危险扩展名黑名单 +const DefaultUploadExtBlacklist = "php,php2,php3,php4,php5,php7,pht,phtml,phar,jsp,jspx,jspa,jsw,jsv,jspf,asp,aspx,asa,asax,ascx,ashx,asmx,cer,cdx,exe,dll,sh,bat,cmd,com,cgi,pl,py,jar,war" + +// ParseUploadSecurityConfig 解析文件上传检测配置;空 JSON 给默认值(默认关闭、超限拦、10MB) +func ParseUploadSecurityConfig(jsonStr string) UploadSecurityConfig { + c := UploadSecurityConfig{ + IsEnable: 0, + OverLimitAction: "block", + MaxSizeKB: 10240, + } + if jsonStr == "" { + return c + } + if err := json.Unmarshal([]byte(jsonStr), &c); err != nil { + return UploadSecurityConfig{IsEnable: 0, OverLimitAction: "block", MaxSizeKB: 10240} + } + if c.OverLimitAction == "" { + c.OverLimitAction = "block" + } + if c.MaxSizeKB <= 0 { + c.MaxSizeKB = 10240 + } + return c +} + // TransportConfig 传输配置 type TransportConfig struct { MaxIdleConns int `json:"max_idle_conns"` // 最大空闲连接数 diff --git a/model/request/waf_host_req.go b/model/request/waf_host_req.go index a1c8f00..2c54c6d 100644 --- a/model/request/waf_host_req.go +++ b/model/request/waf_host_req.go @@ -46,6 +46,7 @@ type WafHostAddReq struct { CookieSecurityJSON string `json:"cookie_security_json"` //Cookie安全保护配置 json CsrfJSON string `json:"csrf_json"` //CSRF防护配置 json TamperJSON string `json:"tamper_json"` //网页防篡改配置 json + UploadSecurityJSON string `json:"upload_security_json"` //文件上传内容检测配置 json IPMode string `json:"ip_mode"` //IP提取模式: "nic" 网卡模式 或 "proxy" 代理模式 } @@ -101,6 +102,7 @@ type WafHostEditReq struct { CookieSecurityJSON string `json:"cookie_security_json"` //Cookie安全保护配置 json CsrfJSON string `json:"csrf_json"` //CSRF防护配置 json TamperJSON string `json:"tamper_json"` //网页防篡改配置 json + UploadSecurityJSON string `json:"upload_security_json"` //文件上传内容检测配置 json IPMode string `json:"ip_mode"` //IP提取模式: "nic" 网卡模式 或 "proxy" 代理模式 } type WafHostGuardStatusReq struct { diff --git a/service/waf_service/waf_host.go b/service/waf_service/waf_host.go index 93e769c..99e605b 100644 --- a/service/waf_service/waf_host.go +++ b/service/waf_service/waf_host.go @@ -104,6 +104,7 @@ func (receiver *WafHostService) AddApi(wafHostAddReq request.WafHostAddReq) (str CookieSecurityJSON: wafHostAddReq.CookieSecurityJSON, CsrfJSON: wafHostAddReq.CsrfJSON, TamperJSON: wafHostAddReq.TamperJSON, + UploadSecurityJSON: wafHostAddReq.UploadSecurityJSON, IPMode: wafHostAddReq.IPMode, } global.GWAF_LOCAL_DB.Create(wafHost) @@ -172,6 +173,7 @@ func (receiver *WafHostService) ModifyApi(wafHostEditReq request.WafHostEditReq) "CookieSecurityJSON": wafHostEditReq.CookieSecurityJSON, "CsrfJSON": wafHostEditReq.CsrfJSON, "TamperJSON": wafHostEditReq.TamperJSON, + "UploadSecurityJSON": wafHostEditReq.UploadSecurityJSON, "IPMode": wafHostEditReq.IPMode, } err := global.GWAF_LOCAL_DB.Debug().Model(model.Hosts{}).Where("CODE=?", wafHostEditReq.CODE).Updates(hostMap).Error diff --git a/wafdb/migrations_core.go b/wafdb/migrations_core.go index 5ecc66a..4d5d026 100644 --- a/wafdb/migrations_core.go +++ b/wafdb/migrations_core.go @@ -1179,6 +1179,32 @@ func RunCoreDBMigrations(db *gorm.DB) error { return nil }, }, + { + ID: "202607020001_add_hosts_upload_security_json", + Migrate: func(tx *gorm.DB) error { + zlog.Info("迁移 202607020001: 为 hosts 表添加 upload_security_json 字段") + if tx.Migrator().HasColumn(&model.Hosts{}, "upload_security_json") { + zlog.Info("upload_security_json 字段已存在,跳过添加") + return nil + } + if err := tx.Migrator().AddColumn(&model.Hosts{}, "upload_security_json"); err != nil { + return fmt.Errorf("添加 upload_security_json 字段失败: %w", err) + } + defaultJSON := `{"is_enable":0,"check_ext":0,"ext_blacklist":"","check_content":0,"check_magic":0,"check_size":0,"max_size_kb":10240,"over_limit_action":"block","include_paths":"","exclude_paths":""}` + if err := tx.Exec("UPDATE hosts SET upload_security_json = ? WHERE upload_security_json IS NULL OR upload_security_json = ''", defaultJSON).Error; err != nil { + zlog.Warn("设置 upload_security_json 默认值失败", "error", err.Error()) + } + zlog.Info("upload_security_json 字段添加成功") + return nil + }, + Rollback: func(tx *gorm.DB) error { + zlog.Info("回滚 202607020001: 删除 hosts 表的 upload_security_json 字段") + if tx.Migrator().HasColumn(&model.Hosts{}, "upload_security_json") { + return tx.Migrator().DropColumn(&model.Hosts{}, "upload_security_json") + } + return nil + }, + }, }) // 执行迁移 diff --git a/wafenginecore/check_upload.go b/wafenginecore/check_upload.go new file mode 100644 index 0000000..536b593 --- /dev/null +++ b/wafenginecore/check_upload.go @@ -0,0 +1,254 @@ +package wafenginecore + +import ( + "SamWaf/innerbean" + "SamWaf/model" + "SamWaf/model/detection" + "SamWaf/model/wafenginmodel" + "bytes" + "io" + "mime" + "mime/multipart" + "net/http" + "net/url" + "regexp" + "strings" +) + +const ( + uploadScanHeadBytes = 64 * 1024 // 每个文件只扫前 64KB 找 webshell 特征 + uploadBufferSlack = 64 * 1024 // 读 body 时相对单文件上限的余量(表单字段+boundary) +) + +// PHP 脚本标记出现时才检查的危险函数(小写) +var uploadPHPDangerFuncs = []string{ + "eval(", "assert(", "system(", "passthru(", "shell_exec(", "popen(", "proc_open(", + "base64_decode(", "gzinflate(", "str_rot13(", "create_function(", "call_user_func(", + "$_post", "$_get", "$_request", "preg_replace(", "array_map(", +} + +// ASP/JSP 脚本标记出现时才检查的危险调用(小写) +var uploadScriptDangerFuncs = []string{ + "runtime.getruntime", "processbuilder", ".exec(", "wscript.shell", + "server.createobject", "eval request", "execute(", "eval(", +} + +// 高可信正则:PHP 一句话木马(子串预过滤门控后才跑) +var reUploadPHPOneLiner = regexp.MustCompile(`(?i)(eval|assert)\s*\(\s*\$_(post|get|request|server|cookie)`) + +// 图片扩展名(用于“声称图片却是脚本”判断;不含 svg,svg 本身是 xml 易误报) +var uploadImageExts = map[string]bool{ + "jpg": true, "jpeg": true, "png": true, "gif": true, "bmp": true, "webp": true, "ico": true, "tiff": true, +} + +// extractUploadExts 提取文件名的所有扩展名段(去空字节、取 basename、小写、按 . 拆),用于双扩展名/空字节绕过检测 +func extractUploadExts(filename string) []string { + name := filename + if i := strings.IndexByte(name, 0x00); i >= 0 { // 空字节截断 x.php\x00.jpg + name = name[:i] + } + if idx := strings.LastIndexAny(name, "/\\"); idx >= 0 { + name = name[idx+1:] + } + name = strings.ToLower(strings.TrimSpace(name)) + parts := strings.Split(name, ".") + if len(parts) <= 1 { + return nil + } + exts := make([]string, 0, len(parts)-1) + for _, p := range parts[1:] { + p = strings.TrimRight(strings.TrimSpace(p), ". ") + if p != "" { + exts = append(exts, p) + } + } + return exts +} + +// matchDangerousExt 文件名任一扩展名段命中黑名单即危险(防 shell.php.jpg 双扩展名) +func matchDangerousExt(filename, blacklist string) (bool, string) { + bl := blacklist + if strings.TrimSpace(bl) == "" { + bl = model.DefaultUploadExtBlacklist + } + set := map[string]bool{} + for _, e := range strings.Split(bl, ",") { + e = strings.ToLower(strings.TrimSpace(e)) + if e != "" { + set[e] = true + } + } + for _, ext := range extractUploadExts(filename) { + if set[ext] { + return true, ext + } + } + return false, "" +} + +// overUploadSize 文件大小是否超过上限 +func overUploadSize(size, maxKB int) bool { + return maxKB > 0 && size > maxKB*1024 +} + +// matchUploadPathPrefix 路径是否命中任一前缀(换行分隔) +func matchUploadPathPrefix(path, lines string) bool { + for _, line := range strings.Split(lines, "\n") { + line = strings.TrimSpace(line) + if line != "" && strings.HasPrefix(path, line) { + return true + } + } + return false +} + +// hasUploadImageExt 文件名是否是图片扩展名(不含 svg) +func hasUploadImageExt(filename string) bool { + exts := extractUploadExts(filename) + if len(exts) == 0 { + return false + } + return uploadImageExts[exts[len(exts)-1]] +} + +// isUploadTypeMismatch 保守判断“声称图片/媒体但真实内容是脚本/HTML” +func isUploadTypeMismatch(filename, declaredCT string, head []byte) bool { + claimImage := strings.HasPrefix(strings.ToLower(declaredCT), "image/") || hasUploadImageExt(filename) + if !claimImage || len(head) == 0 { + return false + } + real := strings.ToLower(http.DetectContentType(head)) + if strings.HasPrefix(real, "image/") { + return false // 真的是图片,放行 + } + // 声称图片但真实是 html/text/xml,或内容含脚本标记 → 不符 + if strings.HasPrefix(real, "text/") || strings.Contains(real, "html") || strings.Contains(real, "xml") { + return true + } + lower := bytes.ToLower(head) + if bytes.Contains(lower, []byte(" limit { + if strings.EqualFold(cfg.OverLimitAction, "pass") { + return result + } + return uploadBlock(weblogbean, "文件上传检测-上传体过大", "上传体超过可检测上限,已拦截") + } + + mr := multipart.NewReader(bytes.NewReader(raw), boundary) + for { + part, e := mr.NextPart() + if e != nil { + break + } + fn := part.FileName() + if fn == "" { // 非文件字段跳过(文本字段由 SQLi/XSS 检测覆盖) + part.Close() + continue + } + declaredCT := part.Header.Get("Content-Type") + fileContent, _ := io.ReadAll(io.LimitReader(part, limit+1)) + part.Close() + + // 便宜→贵,命中即短路 + if cfg.CheckExt == 1 { + if bad, ext := matchDangerousExt(fn, cfg.ExtBlacklist); bad { + return uploadBlock(weblogbean, "文件上传检测-危险扩展名", "检测到危险文件扩展名(."+ext+"),已拦截") + } + } + if cfg.CheckSize == 1 && overUploadSize(len(fileContent), cfg.MaxSizeKB) { + return uploadBlock(weblogbean, "文件上传检测-文件过大", "上传文件超过大小上限,已拦截") + } + head := fileContent + if len(head) > uploadScanHeadBytes { + head = head[:uploadScanHeadBytes] + } + if cfg.CheckMagic == 1 && isUploadTypeMismatch(fn, declaredCT, head) { + return uploadBlock(weblogbean, "文件上传检测-类型不符", "文件真实内容与声明类型不符,已拦截") + } + if cfg.CheckContent == 1 { + if hit, sig := scanWebshell(head); hit { + return uploadBlock(weblogbean, "文件上传检测-Webshell", "检测到Webshell特征("+sig+"),已拦截") + } + } + } + return result +} diff --git a/wafenginecore/check_upload_test.go b/wafenginecore/check_upload_test.go new file mode 100644 index 0000000..9ee7eba --- /dev/null +++ b/wafenginecore/check_upload_test.go @@ -0,0 +1,160 @@ +package wafenginecore + +import ( + "SamWaf/innerbean" + "SamWaf/model" + "SamWaf/model/wafenginmodel" + "bytes" + "io" + "mime/multipart" + "net/http/httptest" + "net/url" + "testing" +) + +func TestMatchDangerousExt(t *testing.T) { + cases := []struct { + name string + file string + want bool + }{ + {"php", "shell.php", true}, + {"大写php", "shell.PHP", true}, + {"双扩展名", "shell.php.jpg", true}, // Apache 误配会执行 .php + {"空字节绕过", "shell.php\x00.jpg", true}, // 空字节截断后 shell.php + {"jsp", "a.jsp", true}, + {"正常图片", "photo.jpg", false}, + {"正常png", "logo.png", false}, + {"正常pdf", "doc.pdf", false}, + {"无扩展名", "README", false}, + } + for _, c := range cases { + if bad, _ := matchDangerousExt(c.file, ""); bad != c.want { + t.Errorf("%s: matchDangerousExt(%q)=%v, 期望 %v", c.name, c.file, bad, c.want) + } + } +} + +func TestScanWebshell(t *testing.T) { + cases := []struct { + name string + body string + want bool + }{ + {"php一句话", "", true}, + {"php assert", "", true}, + {"jsp exec", "<% Runtime.getRuntime().exec(request.getParameter(\"c\")); %>", true}, + {"asp eval", "<%eval request(\"cmd\")%>", true}, + {"压缩JS含eval不误报", "!function(){var a=eval('1+1');return a}()", false}, + {"正常HTML", "