mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-09-01 15:32:55 +08:00
@@ -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"` // 最大空闲连接数
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 执行迁移
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("未开启文件上传检测不应拦截")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user