feat:file upload detection config

#844
This commit is contained in:
samwaf
2026-07-02 11:31:43 +08:00
parent 0e1ae0c4fd
commit 31b334622e
7 changed files with 494 additions and 0 deletions
+40
View File
@@ -52,6 +52,7 @@ type Hosts struct {
CookieSecurityJSON string `gorm:"type:text" json:"cookie_security_json"` //Cookie安全保护配置 jsonHttpOnly/Secure/SameSite
CsrfJSON string `gorm:"type:text" json:"csrf_json"` //CSRF防护配置 jsonOrigin/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"` // 最大空闲连接数
+2
View File
@@ -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 {
+2
View File
@@ -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
+26
View File
@@ -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
},
},
})
// 执行迁移
+254
View File
@@ -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("<?php")) || bytes.Contains(lower, []byte("<?=")) ||
bytes.Contains(lower, []byte("<%")) || bytes.Contains(lower, []byte("<script")) {
return true
}
return false
}
// scanWebshell 只扫传入的头部字节:脚本标记 + 危险函数组合判定(低误报),命中返回特征名
func scanWebshell(head []byte) (bool, string) {
if len(head) == 0 {
return false, ""
}
lower := bytes.ToLower(head)
// 1) 高可信:PHP 一句话木马
if (bytes.Contains(lower, []byte("<?")) || bytes.Contains(lower, []byte("$_"))) && reUploadPHPOneLiner.Match(lower) {
return true, "php-eval-superglobal"
}
// 2) PHP 脚本标记 + 危险函数
if bytes.Contains(lower, []byte("<?php")) || bytes.Contains(lower, []byte("<?=")) {
for _, f := range uploadPHPDangerFuncs {
if bytes.Contains(lower, []byte(f)) {
return true, "php:" + f
}
}
}
// 3) ASP/JSP 脚本标记 + 危险调用
if bytes.Contains(lower, []byte("<%")) {
for _, f := range uploadScriptDangerFuncs {
if bytes.Contains(lower, []byte(f)) {
return true, "script:" + f
}
}
}
return false, ""
}
// uploadBlock 构造拦截结果并标记风险等级
func uploadBlock(weblog *innerbean.WebLog, title, content string) detection.Result {
weblog.RISK_LEVEL = 3
return detection.Result{IsBlock: true, Title: title, Content: content}
}
// CheckUpload 文件上传内容检测:对 multipart 上传做 扩展名/大小/类型不符/Webshell 四维检测。
// 关键:禁用 r.ParseMultipartForm(会清空 body 致后端丢包);读整份 body 到内存解析副本并复位 r.Body。
func (waf *WafEngine) CheckUpload(r *http.Request, weblogbean *innerbean.WebLog, formValue url.Values,
hostTarget *wafenginmodel.HostSafe, globalHostTarget *wafenginmodel.HostSafe) detection.Result {
result := detection.Result{JumpGuardResult: false, IsBlock: false, Title: "", Content: ""}
cfg := model.ParseUploadSecurityConfig(hostTarget.Host.UploadSecurityJSON)
if cfg.IsEnable != 1 {
return result
}
// 只对可能携带上传体的方法(其余零成本放行,不碰 body)
m := strings.ToUpper(r.Method)
if m != http.MethodPost && m != http.MethodPut && m != http.MethodPatch {
return result
}
// 必须是 multipart/form-data 且能取到 boundary
mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || !strings.HasPrefix(mediaType, "multipart/") {
return result
}
boundary := params["boundary"]
if boundary == "" || r.Body == nil {
return result
}
// 路径门:IncludePaths 非空则只检测命中的;ExcludePaths 命中则跳过(优先)
if strings.TrimSpace(cfg.IncludePaths) != "" && !matchUploadPathPrefix(r.URL.Path, cfg.IncludePaths) {
return result
}
if matchUploadPathPrefix(r.URL.Path, cfg.ExcludePaths) {
return result
}
// 读 body(限检测缓冲上限)并复位 r.Body,保证后端能收到完整上传
limit := int64(cfg.MaxSizeKB)*1024 + uploadBufferSlack
raw, _ := io.ReadAll(io.LimitReader(r.Body, limit+1))
r.Body = io.NopCloser(bytes.NewReader(raw))
// 请求体超过可检测上限:fail-closed 默认拦,避免“填大绕过内容检测”
if int64(len(raw)) > 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
}
+160
View File
@@ -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一句话", "<?php @eval($_POST['x']);?>", true},
{"php assert", "<?php assert($_REQUEST['a']);", true},
{"php标记+system", "<?php system($cmd); ?>", 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", "<html><body><h1>hello</h1></body></html>", false},
{"正常文本", "just some plain text content", false},
}
for _, c := range cases {
if hit, _ := scanWebshell([]byte(c.body)); hit != c.want {
t.Errorf("%s: scanWebshell=%v, 期望 %v", c.name, hit, c.want)
}
}
}
func TestIsUploadTypeMismatch(t *testing.T) {
jpeg := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00}
php := []byte("<?php eval($_POST['x']); ?>")
html := []byte("<html><script>alert(1)</script></html>")
if isUploadTypeMismatch("photo.jpg", "image/jpeg", jpeg) {
t.Errorf("真 JPEG 声称图片不应判不符")
}
if !isUploadTypeMismatch("photo.jpg", "image/jpeg", php) {
t.Errorf("声称 jpg 实为 php 应判不符")
}
if !isUploadTypeMismatch("avatar.png", "image/png", html) {
t.Errorf("声称 png 实为 html 应判不符")
}
if isUploadTypeMismatch("script.js", "application/javascript", php) {
t.Errorf("非图片声明不做类型不符判定(避免误报),应放行")
}
}
func TestOverUploadSizeAndPathPrefix(t *testing.T) {
if overUploadSize(1024, 1) {
t.Errorf("恰好 1KB 不应超")
}
if !overUploadSize(2049, 2) {
t.Errorf("2049>2KB 应超")
}
if !matchUploadPathPrefix("/api/upload/x", "/api/upload\n/img") {
t.Errorf("应命中前缀")
}
if matchUploadPathPrefix("/other", "/api/upload\n/img") {
t.Errorf("不应命中")
}
}
// buildMultipart 构造一个含单个文件 part 的 multipart body
func buildMultipart(t *testing.T, field, filename, contentType string, content []byte) (*bytes.Buffer, string) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
fw, err := w.CreateFormFile(field, filename)
if err != nil {
t.Fatalf("CreateFormFile: %v", err)
}
fw.Write(content)
w.Close()
return &buf, w.FormDataContentType()
}
const uploadEnabledJSON = `{"is_enable":1,"check_ext":1,"check_content":1,"check_magic":1,"check_size":1,"max_size_kb":10240,"over_limit_action":"block"}`
func TestCheckUploadBlocksAndResetsBody(t *testing.T) {
waf := &WafEngine{}
buf, ct := buildMultipart(t, "file", "shell.php", "application/octet-stream", []byte("<?php @eval($_POST['x']);"))
raw := buf.Bytes()
r := httptest.NewRequest("POST", "/upload", bytes.NewReader(raw))
r.Header.Set("Content-Type", ct)
weblog := &innerbean.WebLog{}
hostTarget := &wafenginmodel.HostSafe{Host: model.Hosts{UploadSecurityJSON: uploadEnabledJSON}}
res := waf.CheckUpload(r, weblog, url.Values{}, hostTarget, hostTarget)
if !res.IsBlock {
t.Fatalf("恶意 .php 上传应被拦截,实际 %+v", res)
}
// 关键:r.Body 必须已复位为完整原始字节(否则代理转发丢包)
after, _ := io.ReadAll(r.Body)
if !bytes.Equal(after, raw) {
t.Errorf("CheckUpload 后 r.Body 未复位为完整原始 body(转发会丢包)")
}
}
func TestCheckUploadCleanPasses(t *testing.T) {
waf := &WafEngine{}
jpeg := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00, 0x01}
buf, ct := buildMultipart(t, "file", "photo.jpg", "image/jpeg", jpeg)
raw := buf.Bytes()
r := httptest.NewRequest("POST", "/upload", bytes.NewReader(raw))
r.Header.Set("Content-Type", ct)
weblog := &innerbean.WebLog{}
hostTarget := &wafenginmodel.HostSafe{Host: model.Hosts{UploadSecurityJSON: uploadEnabledJSON}}
res := waf.CheckUpload(r, weblog, url.Values{}, hostTarget, hostTarget)
if res.IsBlock {
t.Errorf("正常图片上传不应拦截,实际 %+v", res)
}
after, _ := io.ReadAll(r.Body)
if !bytes.Equal(after, raw) {
t.Errorf("正常上传 r.Body 应完整复位")
}
}
func TestCheckUploadDisabledSkips(t *testing.T) {
waf := &WafEngine{}
buf, ct := buildMultipart(t, "file", "shell.php", "application/octet-stream", []byte("<?php eval($_POST[0]);"))
r := httptest.NewRequest("POST", "/upload", bytes.NewReader(buf.Bytes()))
r.Header.Set("Content-Type", ct)
weblog := &innerbean.WebLog{}
hostTarget := &wafenginmodel.HostSafe{Host: model.Hosts{UploadSecurityJSON: ""}} // 未开启
res := waf.CheckUpload(r, weblog, url.Values{}, hostTarget, hostTarget)
if res.IsBlock {
t.Errorf("未开启文件上传检测不应拦截")
}
}
+10
View File
@@ -118,6 +118,11 @@ func inferAttackType(ruleTitle string) string {
return "scan_tool"
}
// 文件上传内容检测(含 Webshell)
if strings.Contains(ruleTitle, "文件上传") || strings.Contains(ruleTitle, "upload") || strings.Contains(ruleTitle, "webshell") {
return "upload_attack"
}
// RCE远程代码执行
if strings.Contains(ruleTitle, "rce") || strings.Contains(ruleTitle, "代码执行") || strings.Contains(ruleTitle, "命令执行") {
return "rce_attack"
@@ -600,6 +605,11 @@ func (waf *WafEngine) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// 文件上传内容检测(multipart 上传的扩展名/Webshell/类型/大小)
if handleBlock(waf.CheckUpload) {
return
}
// 验证码检测
captchaConfig := model.ParseCaptchaConfig(hostTarget.Host.CaptchaJSON)