From 8ab7b5be31d8febc5b317415df68d77de95eddbd Mon Sep 17 00:00:00 2001 From: samwaf Date: Wed, 16 Apr 2025 15:39:09 +0800 Subject: [PATCH] feat:add anti leech #138 --- model/hosts.go | 10 ++ model/request/waf_host_req.go | 2 + service/waf_service/waf_host.go | 2 + wafenginecore/check_antileech.go | 187 ++++++++++++++++++++++++++ wafenginecore/check_antileech_test.go | 177 ++++++++++++++++++++++++ wafenginecore/checkallowurl_test.go | 2 +- wafenginecore/checkdenyurl_test.go | 2 +- wafenginecore/wafengine.go | 13 +- wafenginecore/wafengine_test.go | 18 +-- 9 files changed, 396 insertions(+), 17 deletions(-) create mode 100644 wafenginecore/check_antileech.go create mode 100644 wafenginecore/check_antileech_test.go diff --git a/model/hosts.go b/model/hosts.go index 59173f8..5cd2dab 100644 --- a/model/hosts.go +++ b/model/hosts.go @@ -36,6 +36,7 @@ type Hosts struct { HealthyJSON string `json:"healthy_json"` //后端健康度检测 json InsecureSkipVerify int `json:"insecure_skip_verify"` //是否开启后端https证书有效性验证 默认 0 是校验 1 是不校验 CaptchaJSON string `json:"captcha_json"` //验证码配置 json + AntiLeechJSON string `json:"anti_leech_json"` //防盗链配置 json } type HostsDefense struct { @@ -67,3 +68,12 @@ type CaptchaConfig struct { ExpireTime int `json:"expire_time"` // 验证通过后的有效期(小时) IPMode string `json:"ip_mode"` // IP提取模式: "nic" 网卡模式 或 "proxy" 代理模式 } + +// AntiLeechConfig 防盗链配置 +type AntiLeechConfig struct { + IsEnableAntiLeech int `json:"is_enable_anti_leech"` // 是否开启防盗链 1开启 0关闭 + FileTypes string `json:"file_types"` // 需要防盗链的文件类型,例如: gif|jpg|jpeg|png|bmp|swf + ValidReferers string `json:"valid_referers"` // 允许的引用来源列表,使用分号(;)分隔 + Action string `json:"action"` // 对于非法引用的处理方式: redirect(重定向) 或 block(直接阻止) + RedirectURL string `json:"redirect_url"` // 重定向URL,当Action为redirect时使用 +} diff --git a/model/request/waf_host_req.go b/model/request/waf_host_req.go index 1841c8e..484d8fa 100644 --- a/model/request/waf_host_req.go +++ b/model/request/waf_host_req.go @@ -31,6 +31,7 @@ type WafHostAddReq struct { HealthyJSON string `json:"healthy_json"` //后端健康度检测 json InsecureSkipVerify int `json:"insecure_skip_verify"` //是否开启后端https证书有效性验证 默认 0 是校验 1 是不校验 CaptchaJSON string `json:"captcha_json"` //验证码配置 json + AntiLeechJSON string `json:"anti_leech_json"` //防盗链配置 json } type WafHostDelReq struct { @@ -70,6 +71,7 @@ type WafHostEditReq struct { HealthyJSON string `json:"healthy_json"` //后端健康度检测 json InsecureSkipVerify int `json:"insecure_skip_verify"` //是否开启后端https证书有效性验证 默认 0 是校验 1 是不校验 CaptchaJSON string `json:"captcha_json"` //验证码配置 json + AntiLeechJSON string `json:"anti_leech_json"` //防盗链配置 json } type WafHostGuardStatusReq struct { diff --git a/service/waf_service/waf_host.go b/service/waf_service/waf_host.go index f5549ab..c86ae17 100644 --- a/service/waf_service/waf_host.go +++ b/service/waf_service/waf_host.go @@ -62,6 +62,7 @@ func (receiver *WafHostService) AddApi(wafHostAddReq request.WafHostAddReq) (str HealthyJSON: wafHostAddReq.HealthyJSON, InsecureSkipVerify: wafHostAddReq.InsecureSkipVerify, CaptchaJSON: wafHostAddReq.CaptchaJSON, + AntiLeechJSON: wafHostAddReq.AntiLeechJSON, } global.GWAF_LOCAL_DB.Create(wafHost) return wafHost.Code, nil @@ -114,6 +115,7 @@ func (receiver *WafHostService) ModifyApi(wafHostEditReq request.WafHostEditReq) "HealthyJSON": wafHostEditReq.HealthyJSON, "InsecureSkipVerify": wafHostEditReq.InsecureSkipVerify, "CaptchaJSON": wafHostEditReq.CaptchaJSON, + "AntiLeechJSON": wafHostEditReq.AntiLeechJSON, } err := global.GWAF_LOCAL_DB.Debug().Model(model.Hosts{}).Where("CODE=?", wafHostEditReq.CODE).Updates(hostMap).Error diff --git a/wafenginecore/check_antileech.go b/wafenginecore/check_antileech.go new file mode 100644 index 0000000..849254e --- /dev/null +++ b/wafenginecore/check_antileech.go @@ -0,0 +1,187 @@ +package wafenginecore + +import ( + "SamWaf/common/zlog" + "SamWaf/innerbean" + "SamWaf/model" + "SamWaf/model/detection" + "SamWaf/model/wafenginmodel" + "encoding/json" + "net/http" + "net/url" + "regexp" + "strings" +) + +// CheckAntiLeech 检查防盗链 +func (waf *WafEngine) CheckAntiLeech(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: "", + } + antiLeechConfig := model.AntiLeechConfig{ + IsEnableAntiLeech: 0, + FileTypes: "", + ValidReferers: "", + Action: "", + RedirectURL: "", + } + + json.Unmarshal([]byte(hostTarget.Host.AntiLeechJSON), &antiLeechConfig) + // 检查是否启用了防盗链 + if antiLeechConfig.IsEnableAntiLeech == 0 { + return result + } + + // 检查请求的URL是否匹配需要防盗链的文件类型 + if !isProtectedFileType(r.URL.Path, antiLeechConfig.FileTypes) { + return result + } + + // 获取Referer + referer := r.Header.Get("Referer") + + // 检查Referer是否有效 + hostWithoutPort := r.Host + if strings.Contains(hostWithoutPort, ":") { + hostWithoutPort, _, _ = strings.Cut(hostWithoutPort, ":") + } + // 收集所有server_names + serverNames := []string{hostWithoutPort} + if hostTarget != nil { + bindMoreHost := hostTarget.Host.BindMoreHost + if bindMoreHost != "" { + lines := strings.Split(bindMoreHost, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + serverNames = append(serverNames, line) + } + } + } + } + if isValidReferer(referer, serverNames, strings.Split(antiLeechConfig.ValidReferers, ";")) { + return result + } + + // 如果Referer无效,根据配置的Action进行处理 + result.IsBlock = true + result.Title = "防盗链保护" + if antiLeechConfig.Action == "redirect" && antiLeechConfig.RedirectURL != "" { + // 防止重定向循环 + redirectURL, err := url.Parse(antiLeechConfig.RedirectURL) + if err == nil { + // 处理r.Host和redirectURL.Host,去掉端口,仅比较纯域名 + reqHost := r.Host + if strings.Contains(reqHost, ":") { + reqHost, _, _ = strings.Cut(reqHost, ":") + } + redirectHost := redirectURL.Host + if strings.Contains(redirectHost, ":") { + redirectHost, _, _ = strings.Cut(redirectHost, ":") + } + if reqHost == redirectHost && strings.HasPrefix(r.URL.Path, redirectURL.Path) { + result.IsBlock = true + result.Title = "防盗链保护" + result.Content = "检测到重定向循环,已阻断" + return result + } + } + // 记录日志 + zlog.Debug("防盗链保护: 检测到非法引用,重定向到: " + antiLeechConfig.RedirectURL) + + // 设置重定向URL + result.Content = "Redirecting..." + } else { + // 默认阻止访问 + result.Content = "您没有权限访问此资源" + } + + return result +} + +// isProtectedFileType 检查URL是否匹配需要防盗链的文件类型 +func isProtectedFileType(path string, fileTypes string) bool { + if fileTypes == "" { + return false + } + + pattern := ".*\\.(" + fileTypes + ")$" + matched, err := regexp.MatchString(pattern, strings.ToLower(path)) + if err != nil { + zlog.Error("防盗链正则匹配错误: " + err.Error()) + return false + } + + return matched +} + +// isValidReferer 检查Referer是否有效 +func isValidReferer(referer string, hosts []string, validReferers []string) bool { + // 如果没有设置有效的Referer列表,则默认允许所有 + if len(validReferers) == 0 { + return true + } + + // 如果没有Referer,检查是否允许none + if referer == "" { + return contains(validReferers, "none") + } + + // 解析Referer URL + parsedReferer, err := url.Parse(referer) + if err != nil { + return contains(validReferers, "blocked") + } + refererHost := parsedReferer.Host + + // 检查是否允许当前服务器名称(支持多域名) + if contains(validReferers, "server_names") { + for _, h := range hosts { + if refererHost == h { + return true + } + } + } + + // 检查是否匹配其他有效的Referer模式 + for _, validReferer := range validReferers { + // 跳过特殊关键字 + if validReferer == "none" || validReferer == "blocked" || validReferer == "server_names" { + continue + } + + // 处理正则表达式模式 (~开头) + if strings.HasPrefix(validReferer, "~") { + pattern := strings.TrimPrefix(validReferer, "~") + matched, err := regexp.MatchString(pattern, refererHost) + if err == nil && matched { + return true + } + } else if strings.HasPrefix(validReferer, "*.") { + // 处理通配符域名 (*.example.com) + suffix := strings.TrimPrefix(validReferer, "*") + if strings.HasSuffix(refererHost, suffix) { + return true + } + } else if refererHost == validReferer { + // 完全匹配 + return true + } + } + + return false +} + +// contains 检查字符串数组是否包含指定字符串 +func contains(arr []string, str string) bool { + for _, a := range arr { + if a == str { + return true + } + } + return false +} diff --git a/wafenginecore/check_antileech_test.go b/wafenginecore/check_antileech_test.go new file mode 100644 index 0000000..a22b40a --- /dev/null +++ b/wafenginecore/check_antileech_test.go @@ -0,0 +1,177 @@ +package wafenginecore + +import ( + "SamWaf/common/zlog" + "SamWaf/global" + "SamWaf/innerbean" + "SamWaf/model" + "SamWaf/model/wafenginmodel" + "encoding/json" + "net/http" + "net/url" + "testing" +) + +func TestCheckAntiLeech(t *testing.T) { + // 初始化日志 + zlog.InitZLog(global.GWAF_RELEASE, "json") + + // 初始化 WAF 引擎 + waf := &WafEngine{ + HostTarget: make(map[string]*wafenginmodel.HostSafe), + } + + // 构造防盗链配置JSON + antiLeechConfig := model.AntiLeechConfig{ + IsEnableAntiLeech: 1, + FileTypes: "gif|jpg|jpeg|png|bmp|swf", + ValidReferers: "none;server_names;*.example.com;~\\.google\\.;~\\.baidu\\.", + Action: "redirect", + RedirectURL: "https://example.com/403.jpg", + } + antiLeechJSON, _ := json.Marshal(antiLeechConfig) + + // 创建测试主机配置,模拟绑定多个域名 + hostTarget := &wafenginmodel.HostSafe{ + Host: model.Hosts{ + AntiLeechJSON: string(antiLeechJSON), + BindMoreHost: "alias1.com\nalias2.com", + }, + } + + // 创建全局主机配置 + globalHostTarget := &wafenginmodel.HostSafe{} + + // 测试用例 + testCases := []struct { + name string + url string + referer string + host string + expectBlocked bool + }{ + { + name: "允许的Referer", + url: "/images/test.jpg", + referer: "https://sub.example.com/page", + host: "mysite.com", + expectBlocked: false, + }, + { + name: "允许的搜索引擎Referer", + url: "/images/test.png", + referer: "https://www.google.com/search", + host: "mysite.com", + expectBlocked: false, + }, + { + name: "无Referer但允许none", + url: "/images/test.gif", + referer: "", + host: "mysite.com", + expectBlocked: false, + }, + { + name: "同站点Referer", + url: "/images/test.jpeg", + referer: "https://mysite.com/page", + host: "mysite.com", + expectBlocked: false, + }, + { + name: "非法Referer", + url: "/images/test.bmp", + referer: "https://attacker.com/page", + host: "mysite.com", + expectBlocked: true, + }, + { + name: "非防盗链文件类型", + url: "/documents/test.pdf", + referer: "https://attacker.com/page", + host: "mysite.com", + expectBlocked: false, + }, + // 新增后置场景测试 + { + name: "带查询参数的图片", + url: "/images/test.jpg?ver=1.2", + referer: "https://attacker.com/page", + host: "mysite.com", + expectBlocked: true, + }, + { + name: "带锚点的图片", + url: "/images/test.png#section", + referer: "https://attacker.com/page", + host: "mysite.com", + expectBlocked: true, + }, + { + name: "路径末尾带斜杠", + url: "/images/test.jpg/", + referer: "https://attacker.com/page", + host: "mysite.com", + expectBlocked: false, // 这种情况一般不会被正则识别为图片文件 + }, + { + name: "文件名大写扩展名", + url: "/images/TEST.JPG", + referer: "https://attacker.com/page", + host: "mysite.com", + expectBlocked: true, + }, + { + name: "带参数和锚点的图片", + url: "/images/test.jpeg?foo=bar#anchor", + referer: "https://attacker.com/page", + host: "mysite.com", + expectBlocked: true, + }, + { + name: "重定向目标再次访问", + url: "/403.jpg", + referer: "https://attacker.com/page", + host: "example.com", // 与RedirectURL主机一致 + expectBlocked: true, // 推荐阻断,防止重定向循环 + }, + { + name: "绑定域名alias1.com同站点Referer", + url: "/images/test.jpeg", + referer: "https://alias1.com/page", + host: "alias1.com", + expectBlocked: false, + }, + { + name: "绑定域名alias2.com同站点Referer", + url: "/images/test.jpeg", + referer: "https://alias2.com/page", + host: "alias2.com", + expectBlocked: false, + }, + { + name: "绑定域名alias1.com非法Referer", + url: "/images/test.jpeg", + referer: "https://evil.com/page", + host: "alias1.com", + expectBlocked: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req, _ := http.NewRequest("GET", tc.url, nil) + req.Host = tc.host + if tc.referer != "" { + req.Header.Set("Referer", tc.referer) + } + weblog := &innerbean.WebLog{ + URL: tc.url, + } + result := waf.CheckAntiLeech(req, weblog, url.Values{}, hostTarget, globalHostTarget) + if result.IsBlock != tc.expectBlocked { + t.Errorf("测试 '%s' 失败: 期望阻止=%v, 实际=%v", tc.name, tc.expectBlocked, result.IsBlock) + } + }) + } +} diff --git a/wafenginecore/checkallowurl_test.go b/wafenginecore/checkallowurl_test.go index 7092ee1..cc43283 100644 --- a/wafenginecore/checkallowurl_test.go +++ b/wafenginecore/checkallowurl_test.go @@ -16,7 +16,7 @@ func TestCheckAllowURL(t *testing.T) { t.Parallel() //初始化日志 - zlog.InitZLog(global.GWAF_RELEASE) + zlog.InitZLog(global.GWAF_RELEASE, "json") // 初始化 WAF 引擎 waf := &WafEngine{ HostTarget: make(map[string]*wafenginmodel.HostSafe), diff --git a/wafenginecore/checkdenyurl_test.go b/wafenginecore/checkdenyurl_test.go index 6d2747b..d70c0f3 100644 --- a/wafenginecore/checkdenyurl_test.go +++ b/wafenginecore/checkdenyurl_test.go @@ -16,7 +16,7 @@ func TestCheckDenyURL(t *testing.T) { t.Parallel() //初始化日志 - zlog.InitZLog(global.GWAF_RELEASE) + zlog.InitZLog(global.GWAF_RELEASE, "json") // 初始化 WAF 引擎 waf := &WafEngine{ HostTarget: make(map[string]*wafenginmodel.HostSafe), diff --git a/wafenginecore/wafengine.go b/wafenginecore/wafengine.go index c825487..e6cf542 100644 --- a/wafenginecore/wafengine.go +++ b/wafenginecore/wafengine.go @@ -349,6 +349,11 @@ func (waf *WafEngine) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // 添加防盗链检查 + if handleBlock(waf.CheckAntiLeech) { + return + } + // 验证码检测 captchaConfig := model.CaptchaConfig{ IsEnableCaptcha: 0, @@ -1089,9 +1094,7 @@ func (waf *WafEngine) ClearCcWindowsForIP(ip string) { if hostSafe.PluginIpRateLimiter != nil { // 获取清理前的请求计数 var countBefore int - if hostSafe.PluginIpRateLimiter.GetRequestCount != nil { - countBefore = hostSafe.PluginIpRateLimiter.GetRequestCount(ip) - } + countBefore = hostSafe.PluginIpRateLimiter.GetRequestCount(ip) // 清理该IP的限流记录 hostSafe.PluginIpRateLimiter.ClearWindowForIP(ip) @@ -1099,9 +1102,7 @@ func (waf *WafEngine) ClearCcWindowsForIP(ip string) { // 获取清理后的请求计数 var countAfter int - if hostSafe.PluginIpRateLimiter.GetRequestCount != nil { - countAfter = hostSafe.PluginIpRateLimiter.GetRequestCount(ip) - } + countAfter = hostSafe.PluginIpRateLimiter.GetRequestCount(ip) // 打印清理前后的计数信息 zlog.Debug(fmt.Sprintf("主机 %s 的 IP %s 限流记录: 清理前=%d, 清理后=%d", diff --git a/wafenginecore/wafengine_test.go b/wafenginecore/wafengine_test.go index 38de2a4..f45611b 100644 --- a/wafenginecore/wafengine_test.go +++ b/wafenginecore/wafengine_test.go @@ -114,7 +114,7 @@ func TestReplaceURLContent(t *testing.T) { t.Parallel() //初始化日志 - zlog.InitZLog(global.GWAF_RELEASE) + zlog.InitZLog(global.GWAF_RELEASE, "json") if v := recover(); v != nil { zlog.Error("error") } @@ -150,7 +150,7 @@ func TestGetOrgContent(t *testing.T) { t.Parallel() //初始化日志 - zlog.InitZLog(global.GWAF_RELEASE) + zlog.InitZLog(global.GWAF_RELEASE, "json") // 初始化WAF引擎 waf := &WafEngine{} @@ -274,7 +274,7 @@ func TestGetOrgContent(t *testing.T) { } // 调用测试函数 - result, err := waf.getOrgContent(resp) + result, err := waf.getOrgContent(resp, false) // 验证结果 if tc.expectedErr { @@ -320,7 +320,7 @@ func TestGetOrgContentWithChunkedEncoding(t *testing.T) { t.Parallel() //初始化日志 - zlog.InitZLog(global.GWAF_RELEASE) + zlog.InitZLog(global.GWAF_RELEASE, "json") // 初始化WAF引擎 waf := &WafEngine{} @@ -337,7 +337,7 @@ func TestGetOrgContentWithChunkedEncoding(t *testing.T) { resp.Header.Set("Content-Type", "text/html; charset=utf-8") // 调用测试函数 - result, err := waf.getOrgContent(resp) + result, err := waf.getOrgContent(resp, false) // 验证结果 if err != nil { @@ -352,7 +352,7 @@ func TestGetOrgContentWithEmptyBody(t *testing.T) { t.Parallel() //初始化日志 - zlog.InitZLog(global.GWAF_RELEASE) + zlog.InitZLog(global.GWAF_RELEASE, "json") // 初始化WAF引擎 waf := &WafEngine{} @@ -365,7 +365,7 @@ func TestGetOrgContentWithEmptyBody(t *testing.T) { resp.Header.Set("Content-Type", "text/html; charset=utf-8") // 调用测试函数 - result, err := waf.getOrgContent(resp) + result, err := waf.getOrgContent(resp, false) // 验证结果 if err != nil { @@ -384,7 +384,7 @@ func TestGetOrgContentWithErrors(t *testing.T) { t.Parallel() //初始化日志 - zlog.InitZLog(global.GWAF_RELEASE) + zlog.InitZLog(global.GWAF_RELEASE, "json") // 初始化WAF引擎 waf := &WafEngine{} @@ -399,7 +399,7 @@ func TestGetOrgContentWithErrors(t *testing.T) { resp.Header.Set("Content-Encoding", "gzip") // 调用测试函数 - _, err := waf.getOrgContent(resp) + _, err := waf.getOrgContent(resp, false) // 验证结果 if err == nil {