From 835e715f46ecfa5faadb9e7ad3dee9ec58a939e7 Mon Sep 17 00:00:00 2001 From: samwaf Date: Mon, 24 Mar 2025 10:12:24 +0800 Subject: [PATCH] feat:captcha #112 #3 --- ThirdLicense | 22 +- cache/waf_cache.go | 7 + docs/compile.md | 2 +- enums/cache_enum.go | 2 + global/config.go | 1 + go.mod | 4 + go.sum | 9 + innerbean/web_log.go | 1 + main.go | 3 + model/hosts.go | 8 + model/request/waf_host_req.go | 4 +- service/waf_service/waf_host.go | 2 + wafbot/platform_determine.go | 3 + wafenginecore/check_captcha.go | 53 ++++ wafenginecore/checkbot.go | 1 + wafenginecore/wafcaptcha/click.go | 263 +++++++++++++++++ wafenginecore/wafcaptcha/service.go | 436 ++++++++++++++++++++++++++++ wafenginecore/wafengine.go | 36 +++ waftask/task_config.go | 5 + 19 files changed, 858 insertions(+), 4 deletions(-) create mode 100644 wafbot/platform_determine.go create mode 100644 wafenginecore/check_captcha.go create mode 100644 wafenginecore/wafcaptcha/click.go create mode 100644 wafenginecore/wafcaptcha/service.go diff --git a/ThirdLicense b/ThirdLicense index b3a111c..392e553 100644 --- a/ThirdLicense +++ b/ThirdLicense @@ -123,4 +123,24 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. + + + +wenlng/go-captcha + +Apache License + +Copyright (c) 2021 Awen + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/cache/waf_cache.go b/cache/waf_cache.go index 0e162d7..0712255 100644 --- a/cache/waf_cache.go +++ b/cache/waf_cache.go @@ -63,6 +63,13 @@ func (wafCache *WafCache) SetWithTTlRenewTime(key string, value interface{}, ttl ttl: ttl, } } +func (wafCache *WafCache) GetBytes(key string) ([]byte, error) { + key1Value := wafCache.Get(key) + if str, ok := key1Value.([]byte); ok { + return str, nil + } + return nil, errors.New("数据不存在") +} func (wafCache *WafCache) GetString(key string) (string, error) { key1Value := wafCache.Get(key) if str, ok := key1Value.(string); ok { diff --git a/docs/compile.md b/docs/compile.md index 2573be6..830cfd7 100644 --- a/docs/compile.md +++ b/docs/compile.md @@ -87,7 +87,7 @@ docker run --rm -v %cd%:/workspace samwaflocalcompile ## 集成的三方库 前端: 使用TDesign Vue Starter 后端: gorm,excelize(360EntSecGroup-Skylar),godlp(bytedance),gin,gocron, - grule-rule-engine,ip2region,sqlitedriver,viper,libinjection-go,corazawaf,go-acme/lego + grule-rule-engine,ip2region,sqlitedriver,viper,libinjection-go,corazawaf,go-acme/lego,wenlng/go-captcha 数据: ipv6(GeoLite2-Country.mmdb) by maxmind ## TODO List diff --git a/enums/cache_enum.go b/enums/cache_enum.go index 5f27537..7e07c37 100644 --- a/enums/cache_enum.go +++ b/enums/cache_enum.go @@ -7,4 +7,6 @@ const ( CACHE_TOKEN = "CACHE_TOKEN" //鉴权信息 CACHE_DNS_BOT_IP = "CACHE_DNS_BOT_IP" //IP反向域名解析 CACHE_DNS_NORMAL_IP = "CACHE_DNS_NORMAL_IP" //正常IP + CACHE_CAPTCHA_TRY = "CACHE_CAPTCHA_TRY" //验证码临时 + CACHE_CAPTCHA_PASS = "CACHE_CAPTCHA_PASS" //通过验证的码 ) diff --git a/global/config.go b/global/config.go index d0890d2..a802890 100644 --- a/global/config.go +++ b/global/config.go @@ -29,6 +29,7 @@ var ( GCONFIG_RECORD_DNS_BOT_EXPIRE_HOURS int64 = 24 //DNS bot有效期 单位小时 默认1天 GCONFIG_RECORD_DNS_NORMAL_EXPIRE_HOURS int64 = 7 * 24 //DNS 正常有效期 单位小时 默认7天 GCONFIG_RECORD_SPIDER_DENY int64 = 0 //爬虫禁止访问开关 默认 0 只检测不阻止访问 1 检测并阻止访问 + GCONFIG_RECORD_FAKE_SPIDER_CAPTCHA int64 = 0 //伪爬虫进行图形挑战开关 0 放过 1 显示图形验证码 GCONFIG_RECORD_HIDE_SERVER_HEADER int64 = 1 // 是否隐藏Server头信息 1隐藏 0不隐藏 GCONFIG_RECORD_FORCE_BIND_2FA int64 = 0 // 是否强制绑定双因素认证(1强制 0不强制) GCONFIG_RECORD_DEBUG_ENABLE int64 = 0 //调试开关 默认关闭 diff --git a/go.mod b/go.mod index 07c3277..157c9fc 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/gin-gonic/gin v1.10.0 github.com/go-acme/lego/v4 v4.20.4 github.com/go-co-op/gocron v1.17.1 + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 github.com/gorilla/websocket v1.5.0 github.com/hyperjumptech/grule-rule-engine v1.15.0 github.com/kardianos/service v1.2.2 @@ -27,6 +28,8 @@ require ( github.com/spf13/viper v1.18.2 github.com/stretchr/testify v1.9.0 github.com/twmb/franz-go v1.18.0 + github.com/wenlng/go-captcha-assets v1.0.5 + github.com/wenlng/go-captcha/v2 v2.0.3 go.uber.org/zap v1.25.0 golang.org/x/mod v0.21.0 golang.org/x/net v0.33.0 @@ -117,6 +120,7 @@ require ( golang.org/x/arch v0.12.0 // indirect golang.org/x/crypto v0.31.0 // indirect golang.org/x/exp v0.0.0-20240213143201-ec583247a57a // indirect + golang.org/x/image v0.16.0 // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/tools v0.25.0 // indirect google.golang.org/protobuf v1.36.1 // indirect diff --git a/go.sum b/go.sum index cfaed88..1d68eef 100644 --- a/go.sum +++ b/go.sum @@ -102,6 +102,8 @@ github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -258,6 +260,10 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= github.com/valllabh/ocsf-schema-golang v1.0.3 h1:eR8k/3jP/OOqB8LRCtdJ4U+vlgd/gk5y3KMXoodrsrw= github.com/valllabh/ocsf-schema-golang v1.0.3/go.mod h1:sZ3as9xqm1SSK5feFWIR2CuGeGRhsM7TR1MbpBctzPk= +github.com/wenlng/go-captcha-assets v1.0.5 h1:TL+31Qe/kJwcuYyU+jHedjSTZnMu1XKgktKL++lH9Js= +github.com/wenlng/go-captcha-assets v1.0.5/go.mod h1:zinRACsdYcL/S6pHgI9Iv7FKTU41d00+43pNX+b9+MM= +github.com/wenlng/go-captcha/v2 v2.0.3 h1:QTZ39/gVDisPSgvL9O2X2HbTuj5P/z8QsdGB/aayg9c= +github.com/wenlng/go-captcha/v2 v2.0.3/go.mod h1:5hac1em3uXoyC5ipZ0xFv9umNM/waQvYAQdr0cx/h34= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -280,6 +286,8 @@ golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20240213143201-ec583247a57a h1:HinSgX1tJRX3KsL//Gxynpw5CTOAIPhgL4W8PNiIpVE= golang.org/x/exp v0.0.0-20240213143201-ec583247a57a/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= +golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw= +golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= @@ -331,6 +339,7 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= diff --git a/innerbean/web_log.go b/innerbean/web_log.go index a5f0c31..b102a82 100644 --- a/innerbean/web_log.go +++ b/innerbean/web_log.go @@ -33,6 +33,7 @@ type WebLog struct { UNIX_ADD_TIME int64 `json:"unix_add_time" gorm:"index"` //添加日期unix RISK_LEVEL int `json:"risk_level"` //危险等级 0:正常 1:轻微 2:有害 3:严重 4:特别严重 GUEST_IDENTIFICATION string `json:"guest_identification"` //访客身份识别 + IsBot int `json:"is_bot"` //是否是机器人 0 不是机器人 1 机器人 TimeSpent int64 `json:"time_spent"` //用时 NetSrcIp string `json:"net_src_ip"` //获取的原始IP SrcByteBody []byte `json:"src_byte_body"` //原始body信息 diff --git a/main.go b/main.go index 591e6f7..18b8c5b 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,7 @@ import ( "SamWaf/wafconfig" "SamWaf/wafdb" "SamWaf/wafenginecore" + "SamWaf/wafenginecore/wafcaptcha" "SamWaf/wafinit" "SamWaf/wafmangeweb" "SamWaf/wafnotify" @@ -167,6 +168,8 @@ func (m *wafSystenService) run() { //初始化cache global.GCACHE_WAFCACHE = cache.InitWafCache() + //初始化验证码服务 + wafcaptcha.InitCaptchaService(global.GCACHE_WAFCACHE) //初始化锁写不锁度 global.GWAF_MEASURE_PROCESS_DEQUEENGINE = cache.InitWafOnlyLockWrite() // 创建 Snowflake 实例 diff --git a/model/hosts.go b/model/hosts.go index 5734ed1..9e348b3 100644 --- a/model/hosts.go +++ b/model/hosts.go @@ -35,6 +35,7 @@ type Hosts struct { ResponseTimeOut int `json:"response_time_out"` //响应超时时间 默认60秒,为0则无限等待 HealthyJSON string `json:"healthy_json"` //后端健康度检测 json InsecureSkipVerify int `json:"insecure_skip_verify"` //是否开启后端https证书有效性验证 默认 0 是校验 1 是不校验 + CaptchaJSON string `json:"captcha_json"` //验证码配置 json } type HostsDefense struct { @@ -58,3 +59,10 @@ type HealthyConfig struct { ExpectedCodes string `json:"expected_codes"` // 预期状态码 LastErrorReason string `json:"last_error_reason"` // 最后一次错误原因 } + +// CaptchaConfig 验证码配置 +type CaptchaConfig struct { + IsEnableCaptcha int `json:"is_enable_captcha"` // 是否开启验证码 1开启 0关闭 + ExcludeURLs string `json:"exclude_urls"` // 排除验证码的URL列表 + ExpireTime int `json:"expire_time"` // 验证通过后的有效期(小时) +} diff --git a/model/request/waf_host_req.go b/model/request/waf_host_req.go index 1acda77..1841c8e 100644 --- a/model/request/waf_host_req.go +++ b/model/request/waf_host_req.go @@ -30,7 +30,7 @@ type WafHostAddReq struct { ResponseTimeOut int `json:"response_time_out"` //响应超时时间 HealthyJSON string `json:"healthy_json"` //后端健康度检测 json InsecureSkipVerify int `json:"insecure_skip_verify"` //是否开启后端https证书有效性验证 默认 0 是校验 1 是不校验 - + CaptchaJSON string `json:"captcha_json"` //验证码配置 json } type WafHostDelReq struct { @@ -69,7 +69,7 @@ type WafHostEditReq struct { ResponseTimeOut int `json:"response_time_out"` //响应超时时间 HealthyJSON string `json:"healthy_json"` //后端健康度检测 json InsecureSkipVerify int `json:"insecure_skip_verify"` //是否开启后端https证书有效性验证 默认 0 是校验 1 是不校验 - + CaptchaJSON string `json:"captcha_json"` //验证码配置 json } type WafHostGuardStatusReq struct { diff --git a/service/waf_service/waf_host.go b/service/waf_service/waf_host.go index 45f56bf..5bde94c 100644 --- a/service/waf_service/waf_host.go +++ b/service/waf_service/waf_host.go @@ -61,6 +61,7 @@ func (receiver *WafHostService) AddApi(wafHostAddReq request.WafHostAddReq) (str ResponseTimeOut: wafHostAddReq.ResponseTimeOut, HealthyJSON: wafHostAddReq.HealthyJSON, InsecureSkipVerify: wafHostAddReq.InsecureSkipVerify, + CaptchaJSON: wafHostAddReq.CaptchaJSON, } global.GWAF_LOCAL_DB.Create(wafHost) return wafHost.Code, nil @@ -112,6 +113,7 @@ func (receiver *WafHostService) ModifyApi(wafHostEditReq request.WafHostEditReq) "ResponseTimeOut": wafHostEditReq.ResponseTimeOut, "HealthyJSON": wafHostEditReq.HealthyJSON, "InsecureSkipVerify": wafHostEditReq.InsecureSkipVerify, + "CaptchaJSON": wafHostEditReq.CaptchaJSON, } err := global.GWAF_LOCAL_DB.Debug().Model(model.Hosts{}).Where("CODE=?", wafHostEditReq.CODE).Updates(hostMap).Error diff --git a/wafbot/platform_determine.go b/wafbot/platform_determine.go new file mode 100644 index 0000000..06998d0 --- /dev/null +++ b/wafbot/platform_determine.go @@ -0,0 +1,3 @@ +package wafbot + +//TODO 拉取最新数据进行更新 diff --git a/wafenginecore/check_captcha.go b/wafenginecore/check_captcha.go new file mode 100644 index 0000000..97ca345 --- /dev/null +++ b/wafenginecore/check_captcha.go @@ -0,0 +1,53 @@ +package wafenginecore + +import ( + "SamWaf/enums" + "SamWaf/global" + "SamWaf/innerbean" + "SamWaf/wafenginecore/wafcaptcha" + "net/http" +) + +// checkCaptchaToken 返回false 要验证信息 ,true 不验证信息 +func (waf *WafEngine) checkCaptchaToken(r *http.Request, log innerbean.WebLog) bool { + // 首先从Cookie中获取验证标识 + cookie, err := r.Cookie("samwaf_captcha_token") + if err == nil && cookie.Value != "" { + // 检查缓存中是否存在该标识 + if global.GCACHE_WAFCACHE.IsKeyExist(enums.CACHE_CAPTCHA_PASS + cookie.Value) { + return true + } + } + + // 如果Cookie中没有或无效,则检查请求头 + token := r.Header.Get("X-SamWaf-Captcha-Token") + if token != "" { + // 检查缓存中是否存在该标识 + if global.GCACHE_WAFCACHE.IsKeyExist(enums.CACHE_CAPTCHA_PASS + token) { + return true + } + } + //是bot而且危险程度是0,那么不用进行验证码挑战 + if log.IsBot == 1 { + if log.RISK_LEVEL == 0 { + return true + } else { + if log.GUEST_IDENTIFICATION == "查询超时" || log.GUEST_IDENTIFICATION == "查询失败" { + return true + } + //伪爬虫是否开启图形验证 + if global.GCONFIG_RECORD_FAKE_SPIDER_CAPTCHA == 0 { + return true + } + } + + } + return false +} + +// 处理验证码 +func (waf *WafEngine) handleCaptchaRequest(w http.ResponseWriter, r *http.Request, expireTime int, log innerbean.WebLog) { + // 使用验证码服务处理请求 + captchaService := wafcaptcha.GetService() + captchaService.HandleCaptchaRequest(w, r, expireTime, log) +} diff --git a/wafenginecore/checkbot.go b/wafenginecore/checkbot.go index a5a54c4..ea1b295 100644 --- a/wafenginecore/checkbot.go +++ b/wafenginecore/checkbot.go @@ -38,6 +38,7 @@ func (waf *WafEngine) CheckBot(r *http.Request, weblogbean *innerbean.WebLog, fo botResult = global.GCACHE_WAFCACHE.Get(enums.CACHE_DNS_BOT_IP + weblogbean.SRC_IP).(wafbot.BotResult) } if botResult.IsBot == true { + weblogbean.IsBot = 1 if botResult.IsNormalBot { weblogbean.GUEST_IDENTIFICATION = botResult.BotName } else { diff --git a/wafenginecore/wafcaptcha/click.go b/wafenginecore/wafcaptcha/click.go new file mode 100644 index 0000000..b6a0280 --- /dev/null +++ b/wafenginecore/wafcaptcha/click.go @@ -0,0 +1,263 @@ +package wafcaptcha + +/* +import ( + "encoding/json" + "fmt" + "github.com/golang/freetype/truetype" + "github.com/wenlng/go-captcha-assets/bindata/chars" + "github.com/wenlng/go-captcha-assets/resources/fonts/fzshengsksjw" + "github.com/wenlng/go-captcha-assets/resources/images" + "github.com/wenlng/go-captcha/v2/base/option" + "github.com/wenlng/go-captcha/v2/click" + "log" + "net/http" + "os" + "strconv" + "strings" + "sync/atomic" + "time" +) + +var textCapt click.Captcha +var lightTextCapt click.Captcha + +func init() { + builder := click.NewBuilder( + click.WithRangeLen(option.RangeVal{Min: 4, Max: 6}), + click.WithRangeVerifyLen(option.RangeVal{Min: 2, Max: 4}), + //click.WithRangeLen(option.RangeVal{Min: 2, Max: 4}), + //click.WithDisabledRangeVerifyLen(true), + click.WithRangeThumbColors([]string{ + "#1f55c4", + "#780592", + "#2f6b00", + "#910000", + "#864401", + "#675901", + "#016e5c", + }), + click.WithRangeColors([]string{ + "#fde98e", + "#60c1ff", + "#fcb08e", + "#fb88ff", + "#b4fed4", + "#cbfaa9", + "#78d6f8", + }), + ) + + // fonts + fonts, err := fzshengsksjw.GetFont() + if err != nil { + log.Fatalln(err) + } + + // background images + imgs, err := images.GetImages() + if err != nil { + log.Fatalln(err) + } + + // thumb images + //thumbImages, err := thumbs.GetThumbs() + //if err != nil { + // log.Fatalln(err) + //} + + // set resources + builder.SetResources( + click.WithChars(chars.GetChineseChars()), + //click.WithChars([]string{ + // "1A", + // "5E", + // "3d", + // "0p", + // "78", + // "DL", + // "CB", + // "9M", + //}), + //click.WithChars(chars.GetAlphaChars()), + click.WithFonts([]*truetype.Font{fonts}), + click.WithBackgrounds(imgs), + //click.WithThumbBackgrounds(thumbImages), + ) + textCapt = builder.Make() + + // ============================ + + builder.Clear() + builder.SetOptions( + click.WithRangeLen(option.RangeVal{Min: 4, Max: 6}), + click.WithRangeVerifyLen(option.RangeVal{Min: 2, Max: 4}), + click.WithRangeThumbColors([]string{ + "#4a85fb", + "#d93ffb", + "#56be01", + "#ee2b2b", + "#cd6904", + "#b49b03", + "#01ad90", + }), + ) + builder.SetResources( + click.WithChars(chars.GetChineseChars()), + click.WithFonts([]*truetype.Font{fonts}), + click.WithBackgrounds(imgs), + ) + lightTextCapt = builder.Make() +} + +// GetClickBasicCaptData . +func GetClickBasicCaptData(w http.ResponseWriter, r *http.Request) { + var capt click.Captcha + if r.URL.Query().Get("type") == "light" { + capt = lightTextCapt + } else { + capt = textCapt + } + + captData, err := capt.Generate() + if err != nil { + log.Fatalln(err) + } + + dotData := captData.GetData() + if dotData == nil { + bt, _ := json.Marshal(map[string]interface{}{ + "code": 1, + "message": "gen captcha data failed", + }) + _, _ = fmt.Fprintf(w, string(bt)) + return + } + + var masterImageBase64, thumbImageBase64 string + masterImageBase64, err = captData.GetMasterImage().ToBase64() + if err != nil { + bt, _ := json.Marshal(map[string]interface{}{ + "code": 1, + "message": "base64 data failed", + }) + _, _ = fmt.Fprintf(w, string(bt)) + return + } + + thumbImageBase64, err = captData.GetThumbImage().ToBase64() + if err != nil { + bt, _ := json.Marshal(map[string]interface{}{ + "code": 1, + "message": "base64 data failed", + }) + _, _ = fmt.Fprintf(w, string(bt)) + return + } + + dotsByte, _ := json.Marshal(dotData) + key := GenUniqueId() + cache.WriteCache(key, dotsByte) + fmt.Println("dot>>>>>", string(dotsByte)) + + bt, _ := json.Marshal(map[string]interface{}{ + "code": 0, + "captcha_key": key, + "image_base64": masterImageBase64, + "thumb_base64": thumbImageBase64, + }) + + _, _ = fmt.Fprintf(w, string(bt)) +} + +var num int64 + +const ( + Continuity = "20060102150405" +) + +func GenUniqueId() string { + t := time.Now() + s := t.Format(Continuity) + m := t.UnixNano()/1e6 - t.UnixNano()/1e9*1e3 + ms := sup(m, 3) + p := os.Getpid() % 1000 + ps := sup(int64(p), 3) + i := atomic.AddInt64(&num, 1) + r := i % 10000 + rs := sup(r, 4) + n := fmt.Sprintf("%s%s%s%s", s, ms, ps, rs) + return n +} + +func sup(i int64, n int) string { + m := fmt.Sprintf("%d", i) + for len(m) < n { + m = fmt.Sprintf("0%s", m) + } + return m +} + +// CheckClickData . +func CheckClickData(w http.ResponseWriter, r *http.Request) { + code := 1 + _ = r.ParseForm() + dots := r.Form.Get("dots") + key := r.Form.Get("key") + if dots == "" || key == "" { + bt, _ := json.Marshal(map[string]interface{}{ + "code": code, + "message": "dots or key param is empty", + }) + _, _ = fmt.Fprintf(w, string(bt)) + return + } + + cacheDataByte := cache.ReadCache(key) + if len(cacheDataByte) == 0 { + bt, _ := json.Marshal(map[string]interface{}{ + "code": code, + "message": "illegal key", + }) + _, _ = fmt.Fprintf(w, string(bt)) + return + } + src := strings.Split(dots, ",") + + var dct map[int]*click.Dot + if err := json.Unmarshal(cacheDataByte, &dct); err != nil { + bt, _ := json.Marshal(map[string]interface{}{ + "code": code, + "message": "illegal key", + }) + _, _ = fmt.Fprintf(w, string(bt)) + return + } + + chkRet := false + if (len(dct) * 2) == len(src) { + for i := 0; i < len(dct); i++ { + dot := dct[i] + j := i * 2 + k := i*2 + 1 + sx, _ := strconv.ParseFloat(fmt.Sprintf("%v", src[j]), 64) + sy, _ := strconv.ParseFloat(fmt.Sprintf("%v", src[k]), 64) + + chkRet = click.CheckPoint(int64(sx), int64(sy), int64(dot.X), int64(dot.Y), int64(dot.Width), int64(dot.Height), 0) + if !chkRet { + break + } + } + } + + if chkRet { + code = 0 + } + + bt, _ := json.Marshal(map[string]interface{}{ + "code": code, + }) + _, _ = fmt.Fprintf(w, string(bt)) + return +} +*/ diff --git a/wafenginecore/wafcaptcha/service.go b/wafenginecore/wafcaptcha/service.go new file mode 100644 index 0000000..b8e0c4a --- /dev/null +++ b/wafenginecore/wafcaptcha/service.go @@ -0,0 +1,436 @@ +package wafcaptcha + +import ( + "SamWaf/cache" + "SamWaf/common/zlog" + "SamWaf/enums" + "SamWaf/global" + "SamWaf/innerbean" + "SamWaf/utils" + "encoding/json" + "fmt" + "github.com/golang/freetype/truetype" + uuid "github.com/satori/go.uuid" + "github.com/wenlng/go-captcha-assets/bindata/chars" + "github.com/wenlng/go-captcha-assets/resources/fonts/fzshengsksjw" + "github.com/wenlng/go-captcha-assets/resources/images" + "github.com/wenlng/go-captcha/v2/base/option" + "github.com/wenlng/go-captcha/v2/click" + "log" + "net/http" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "go.uber.org/zap" +) + +var ( + captchaService *CaptchaService + once sync.Once +) + +// CaptchaService 验证码服务结构体 +type CaptchaService struct { + cache *cache.WafCache + //text + textCapt click.Captcha + lightTextCapt click.Captcha +} + +// InitCaptchaService 初始化验证码服务,传入缓存引用 +func InitCaptchaService(cache *cache.WafCache) { + once.Do(func() { + captchaService = &CaptchaService{ + cache: cache, + } + captchaService.InitTextCapt() + }) +} +func (s *CaptchaService) InitTextCapt() { + builder := click.NewBuilder( + click.WithRangeLen(option.RangeVal{Min: 4, Max: 6}), + click.WithRangeVerifyLen(option.RangeVal{Min: 2, Max: 4}), + //click.WithRangeLen(option.RangeVal{Min: 2, Max: 4}), + //click.WithDisabledRangeVerifyLen(true), + click.WithRangeThumbColors([]string{ + "#1f55c4", + "#780592", + "#2f6b00", + "#910000", + "#864401", + "#675901", + "#016e5c", + }), + click.WithRangeColors([]string{ + "#fde98e", + "#60c1ff", + "#fcb08e", + "#fb88ff", + "#b4fed4", + "#cbfaa9", + "#78d6f8", + }), + ) + + // fonts + fonts, err := fzshengsksjw.GetFont() + if err != nil { + log.Fatalln(err) + } + + // background images + imgs, err := images.GetImages() + if err != nil { + log.Fatalln(err) + } + + // thumb images + //thumbImages, err := thumbs.GetThumbs() + //if err != nil { + // log.Fatalln(err) + //} + + // set resources + builder.SetResources( + click.WithChars(chars.GetChineseChars()), + //click.WithChars([]string{ + // "1A", + // "5E", + // "3d", + // "0p", + // "78", + // "DL", + // "CB", + // "9M", + //}), + //click.WithChars(chars.GetAlphaChars()), + click.WithFonts([]*truetype.Font{fonts}), + click.WithBackgrounds(imgs), + //click.WithThumbBackgrounds(thumbImages), + ) + s.textCapt = builder.Make() + + // ============================ + + builder.Clear() + builder.SetOptions( + click.WithRangeLen(option.RangeVal{Min: 4, Max: 6}), + click.WithRangeVerifyLen(option.RangeVal{Min: 2, Max: 4}), + click.WithRangeThumbColors([]string{ + "#4a85fb", + "#d93ffb", + "#56be01", + "#ee2b2b", + "#cd6904", + "#b49b03", + "#01ad90", + }), + ) + builder.SetResources( + click.WithChars(chars.GetChineseChars()), + click.WithFonts([]*truetype.Font{fonts}), + click.WithBackgrounds(imgs), + ) + s.lightTextCapt = builder.Make() +} + +// GetService 获取验证码服务实例 +func GetService() *CaptchaService { + if captchaService == nil { + zlog.Warn("验证码服务未初始化,请先调用 InitCaptchaService") + // 返回一个空服务,避免空指针异常 + return &CaptchaService{} + } + return captchaService +} + +// HandleCaptchaRequest 处理验证码请求 +func (s *CaptchaService) HandleCaptchaRequest(w http.ResponseWriter, r *http.Request, expireTime int, weblog innerbean.WebLog) { + // 从路径中提取验证码相关部分 + path := r.URL.Path + // 记录访问日志 + zlog.Debug("验证码请求", zap.String("path", path), zap.String("method", r.Method), zap.String("remote_addr", r.RemoteAddr)) + + if strings.HasPrefix(path, "/samwaf_captcha/click_basic") { + s.GetClickBasicCaptData(w, r) + } else if strings.HasPrefix(path, "/samwaf_captcha/verify") { + // 根据请求参数确定验证码类型 + captchaType := r.URL.Query().Get("type") + s.VerifyCaptcha(w, r, captchaType, expireTime, weblog) + } else if strings.HasPrefix(path, "/samwaf_captcha/") { + cleanPath := strings.TrimPrefix(path, "/samwaf_captcha/") + s.ServeStaticFile(w, r, cleanPath) + } else { + // 记录日志信息 + weblog.ACTION = "禁止" + weblog.RULE = "显示图形验证码" + global.GQEQUE_LOG_DB.Enqueue(weblog) + // 默认显示验证码选择页面 + s.ShowCaptchaHomePage(w, r) + } +} + +// ServeStaticFile 提供静态文件服务 +func (s *CaptchaService) ServeStaticFile(w http.ResponseWriter, r *http.Request, filePath string) { + // 安全检查:防止路径遍历攻击 + if containsPathTraversal(filePath) { + zlog.Warn("检测到路径遍历尝试", zap.String("path", filePath), zap.String("remote_addr", r.RemoteAddr)) + http.Error(w, "403 Forbidden", http.StatusForbidden) + return + } + + // 规范化文件路径,移除所有 ".." 和多余的斜杠 + cleanPath := path.Clean(filePath) + + // 确保路径不以 "/" 或 "\" 开头,防止访问根目录 + if strings.HasPrefix(cleanPath, "/") || strings.HasPrefix(cleanPath, "\\") { + cleanPath = cleanPath[1:] + } + + // 根据文件扩展名设置Content-Type + if strings.HasSuffix(cleanPath, ".css") { + w.Header().Set("Content-Type", "text/css") + } else if strings.HasSuffix(cleanPath, ".js") { + w.Header().Set("Content-Type", "application/javascript") + } else if strings.HasSuffix(cleanPath, ".html") { + w.Header().Set("Content-Type", "text/html") + } else if strings.HasSuffix(cleanPath, ".png") { + w.Header().Set("Content-Type", "image/png") + } else if strings.HasSuffix(cleanPath, ".jpg") || strings.HasSuffix(cleanPath, ".jpeg") { + w.Header().Set("Content-Type", "image/jpeg") + } + + // 构建安全的完整路径 + basePath := utils.GetCurrentDir() + "/data/captcha/" + fullPath := filepath.Join(basePath, cleanPath) + + // 再次验证路径是否在允许的目录内 + absBasePath, _ := filepath.Abs(basePath) + absFullPath, _ := filepath.Abs(fullPath) + + if !strings.HasPrefix(absFullPath, absBasePath) { + zlog.Warn("检测到目录遍历尝试", zap.String("path", filePath), zap.String("fullPath", fullPath), zap.String("remote_addr", r.RemoteAddr)) + http.Error(w, "403 Forbidden", http.StatusForbidden) + return + } + + // 检查文件是否存在 + if _, err := os.Stat(fullPath); os.IsNotExist(err) { + http.NotFound(w, r) + return + } + + // 提供文件服务 + http.ServeFile(w, r, fullPath) +} + +// containsPathTraversal 检查路径中是否包含路径遍历尝试 +func containsPathTraversal(filePath string) bool { + // 检查常见的路径遍历模式 + return strings.Contains(filePath, "../") || + strings.Contains(filePath, "..\\") || + strings.Contains(filePath, "%2e%2e%2f") || // ../ + strings.Contains(filePath, "%2e%2e/") || // ../ + strings.Contains(filePath, "..%2f") || // ../ + strings.Contains(filePath, "%2e%2e%5c") || // ..\ + strings.Contains(filePath, "..%5c") || // ..\ + strings.Contains(filePath, "\\\\") || // 双反斜杠 + strings.Contains(filePath, "//") // 双正斜杠 +} + +// GetClickBasicCaptData 获取基础点击验证码数据 +func (s *CaptchaService) GetClickBasicCaptData(w http.ResponseWriter, r *http.Request) { + var capt click.Captcha + if r.URL.Query().Get("type") == "light" { + capt = s.lightTextCapt + } else { + capt = s.textCapt + } + + captData, err := capt.Generate() + if err != nil { + log.Fatalln(err) + } + + dotData := captData.GetData() + if dotData == nil { + bt, _ := json.Marshal(map[string]interface{}{ + "code": 1, + "message": "gen captcha data failed", + }) + _, _ = fmt.Fprintf(w, string(bt)) + return + } + + var masterImageBase64, thumbImageBase64 string + masterImageBase64, err = captData.GetMasterImage().ToBase64() + if err != nil { + bt, _ := json.Marshal(map[string]interface{}{ + "code": 1, + "message": "base64 data failed", + }) + _, _ = fmt.Fprintf(w, string(bt)) + return + } + + thumbImageBase64, err = captData.GetThumbImage().ToBase64() + if err != nil { + bt, _ := json.Marshal(map[string]interface{}{ + "code": 1, + "message": "base64 data failed", + }) + _, _ = fmt.Fprintf(w, string(bt)) + return + } + + dotsByte, _ := json.Marshal(dotData) + key := uuid.NewV4().String() + //key := helper.StringToMD5(string(dotsByte)) + s.cache.SetWithTTl(enums.CACHE_CAPTCHA_TRY+key, dotsByte, 1*time.Minute) + + bt, _ := json.Marshal(map[string]interface{}{ + "code": 0, + "captcha_key": key, + "image_base64": masterImageBase64, + "thumb_base64": thumbImageBase64, + }) + + _, _ = fmt.Fprintf(w, string(bt)) +} + +// VerifyCaptcha 验证验证码 +func (s *CaptchaService) VerifyCaptcha(w http.ResponseWriter, r *http.Request, captchaType string, expireTime int, webLog innerbean.WebLog) { + code := 1 + _ = r.ParseForm() + dots := r.Form.Get("dots") + key := r.Form.Get("key") + if dots == "" || key == "" { + bt, _ := json.Marshal(map[string]interface{}{ + "code": code, + "message": "dots or key param is empty", + }) + _, _ = fmt.Fprintf(w, string(bt)) + return + } + + cacheDataByte, err := s.cache.GetBytes(enums.CACHE_CAPTCHA_TRY + key) + if err != nil { + bt, _ := json.Marshal(map[string]interface{}{ + "code": code, + "message": "illegal key", + }) + _, _ = fmt.Fprintf(w, string(bt)) + return + } + s.cache.Remove(enums.CACHE_CAPTCHA_TRY + key) + if len(cacheDataByte) == 0 { + bt, _ := json.Marshal(map[string]interface{}{ + "code": code, + "message": "illegal key", + }) + _, _ = fmt.Fprintf(w, string(bt)) + return + } + src := strings.Split(dots, ",") + + var dct map[int]*click.Dot + if err := json.Unmarshal(cacheDataByte, &dct); err != nil { + bt, _ := json.Marshal(map[string]interface{}{ + "code": code, + "message": "illegal key", + }) + _, _ = fmt.Fprintf(w, string(bt)) + return + } + + chkRet := false + if (len(dct) * 2) == len(src) { + for i := 0; i < len(dct); i++ { + dot := dct[i] + j := i * 2 + k := i*2 + 1 + sx, _ := strconv.ParseFloat(fmt.Sprintf("%v", src[j]), 64) + sy, _ := strconv.ParseFloat(fmt.Sprintf("%v", src[k]), 64) + + chkRet = click.CheckPoint(int64(sx), int64(sy), int64(dot.X), int64(dot.Y), int64(dot.Width), int64(dot.Height), 0) + if !chkRet { + break + } + } + } + + if chkRet { + code = 0 + // 生成验证通过的标识 + captchaPassToken := uuid.NewV4().String() + // 将标识存入缓存 + s.cache.SetWithTTl(enums.CACHE_CAPTCHA_PASS+captchaPassToken, "ok", time.Duration(expireTime)*time.Hour) + + // 设置Cookie + cookie := &http.Cookie{ + Name: "samwaf_captcha_token", + Value: captchaPassToken, + Path: "/", + HttpOnly: true, + Secure: r.TLS != nil, // 如果是HTTPS请求则设置Secure + MaxAge: expireTime * 3600, + } + http.SetCookie(w, cookie) + + // 同时在响应头中也设置验证标识 + w.Header().Set("X-SamWaf-Captcha-Token", captchaPassToken) + webLog.ACTION = "放行" + webLog.RULE = "图形验证码验证通过" + global.GQEQUE_LOG_DB.Enqueue(webLog) + } else { + webLog.ACTION = "禁止" + webLog.RULE = "图形验证码验证失败" + global.GQEQUE_LOG_DB.Enqueue(webLog) + } + + bt, _ := json.Marshal(map[string]interface{}{ + "code": code, + }) + _, _ = fmt.Fprintf(w, string(bt)) + return +} + +// ShowCaptchaHomePage 显示验证码首页 +func (s *CaptchaService) ShowCaptchaHomePage(w http.ResponseWriter, r *http.Request) { + // 设置内容类型 + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + // 从指定目录加载index.html + http.ServeFile(w, r, utils.GetCurrentDir()+"/data/captcha/index.html") +} + +// 辅助函数 + +// writeJSONResponse 写入JSON响应 +func writeJSONResponse(w http.ResponseWriter, data interface{}) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(data) +} + +// generateRandomKey 生成随机密钥 +func generateRandomKey() string { + // 实际实现中应该使用更安全的随机数生成方法 + return "random_key_123456" +} + +// generateImageBase64 生成图片的Base64编码 +func generateImageBase64() string { + // 实际实现中应该生成真实的验证码图片 + return "base64_encoded_image_data" +} + +// generateThumbBase64 生成缩略图的Base64编码 +func generateThumbBase64() string { + // 实际实现中应该生成真实的缩略图 + return "base64_encoded_thumb_data" +} diff --git a/wafenginecore/wafengine.go b/wafenginecore/wafengine.go index 312e6bc..64f9bba 100644 --- a/wafenginecore/wafengine.go +++ b/wafenginecore/wafengine.go @@ -356,6 +356,42 @@ func (waf *WafEngine) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // 验证码检测 + captchaConfig := model.CaptchaConfig{ + IsEnableCaptcha: 0, + ExcludeURLs: "", + ExpireTime: 24, + } + + err = json.Unmarshal([]byte(hostTarget.Host.CaptchaJSON), &captchaConfig) + if err != nil { + zlog.Debug("解析captcha json失败") + } + + if captchaConfig.IsEnableCaptcha == 1 { + if !waf.checkCaptchaToken(r, weblogbean) { + // 检查当前URL是否在排除列表中 + currentURL := strings.ToLower(r.URL.Path) + isExcluded := false + + if len(captchaConfig.ExcludeURLs) > 0 { + // 将换行分隔的URL列表拆分为数组 + excludeURLs := strings.Split(captchaConfig.ExcludeURLs, "\n") + for _, excludeURL := range excludeURLs { + // 去除可能的空白字符并转为小写 + excludeURL = strings.TrimSpace(strings.ToLower(excludeURL)) + if excludeURL != "" && strings.HasPrefix(currentURL, excludeURL) { + isExcluded = true + break + } + } + } + if !isExcluded { + waf.handleCaptchaRequest(w, r, captchaConfig.ExpireTime, weblogbean) + return + } + } + } } } diff --git a/waftask/task_config.go b/waftask/task_config.go index 152ba8f..b3925ab 100644 --- a/waftask/task_config.go +++ b/waftask/task_config.go @@ -79,6 +79,9 @@ func setConfigIntValue(name string, value int64, change int) { case "force_bind_2fa": global.GCONFIG_RECORD_FORCE_BIND_2FA = value break + case "fake_spider_captcha": + global.GCONFIG_RECORD_FAKE_SPIDER_CAPTCHA = value + break default: zlog.Warn("Unknown config item:", name) } @@ -218,4 +221,6 @@ func TaskLoadSetting(initLoad bool) { updateConfigStringItem(initLoad, "gpt", "gpt_model", global.GCONFIG_RECORD_GPT_MODEL, "GPT模型名称", "string", "") updateConfigIntItem(initLoad, "security", "hide_server_header", global.GCONFIG_RECORD_HIDE_SERVER_HEADER, "是否隐藏Server响应头(1隐藏 0不隐藏)", "int", "") updateConfigIntItem(initLoad, "security", "force_bind_2fa", global.GCONFIG_RECORD_FORCE_BIND_2FA, "是否强制绑定双因素认证(1强制 0不强制)", "options", "0|不强制,1|强制") + updateConfigIntItem(initLoad, "system", "fake_spider_captcha", global.GCONFIG_RECORD_FAKE_SPIDER_CAPTCHA, "伪爬虫进行图形挑战开关 0 放过 1 显示图形验证码", "int", "") + }