feat: support temporary client IP bans through custom rules

#IKHZMG
This commit is contained in:
samwaf
2026-10-21 11:20:44 +08:00
parent 28f4cec80e
commit ae1d61eb32
24 changed files with 868 additions and 18 deletions
+2
View File
@@ -15,6 +15,7 @@ type APIGroup struct {
WafAntiCCApi
WafAntiCCRuleApi
WafIPFailureApi
WafRuleBanApi
WafBlockIpApi
WafIPGroupApi
WafAccessAccountApi
@@ -96,6 +97,7 @@ var (
wafLdpUrlService = waf_service.WafLdpUrlServiceApp
wafAntiCCService = waf_service.WafAntiCCServiceApp
wafAntiCCRuleService = waf_service.WafAntiCCRuleServiceApp
wafRuleBanService = waf_service.WafRuleBanServiceApp
wafCCEmergencyService = waf_service.WafCCEmergencyServiceApp
wafCCThresholdService = waf_service.WafCCThresholdServiceApp
+5 -3
View File
@@ -46,7 +46,7 @@ rule R<唯一标识> "规则中文描述" salience <优先级数字> {
}
说明:
- R<唯一标识>:规则名,以大写 R 开头,只能由字母和数字组成(不含横线)。
- salience:优先级,数值越大越优先。站点规则和全局规则一起按 salience 仲裁,同优先级时按 拦截 > 放行 > 仅记录 取。默认写 10;放行类要压过全局拦截规则时写 100。
- salience:优先级,数值越大越优先。站点规则和全局规则一起按 salience 仲裁,同优先级时按 拦截/封禁 > 放行 > 仅记录 取。默认写 10;放行类要压过全局拦截规则时写 100。
- when:条件,为 true 时命中。then:命中后的动作,必须写且只能写一个。
# 可用请求字段(MF 开头,代表当前请求)
@@ -61,8 +61,9 @@ MF.HOST 请求域名 | MF.URL 请求地址 | MF.REFERER 来源页 | MF.USER_AGEN
RF.IPMatch(MF.SRC_IP,"10.10.*.*")==true(单IP/CIDR/通配符/区间统一匹配,推荐优先用它;IPv4通配按八位组、*可在任意位置,IPv6通配须写满8段且不能用::,区间写"起-止") | RF.IPInGroup(MF.SRC_IP,"办公室出口")==true(IP组,可跨站点复用的IP集合,在 网站防护-IP组 维护,可传组名或组短码) | RF.IPInRange(MF.SRC_IP,"起","止")==true | RF.IPInRanges(MF.SRC_IP,"起-止","CIDR","10.10.*.*",...)==true | RF.IPInCIDR(MF.SRC_IP,"192.168.1.0/24")==true | RF.IPEquals(MF.SRC_IP,"1.2.3.4")==true | RF.In(MF.METHOD,"GET","POST")==true | RF.InIgnoreCase(值,列表...)==true | RF.ContainsAny(MF.URL,"a","b")==true | RF.ContainsAnyIgnoreCase(MF.USER_AGENT,...)==true | RF.ContainsAll(MF.URL,"a","b")==true | RF.StartsWithAny(MF.URL,"/admin")==true | RF.EndsWithAny(MF.URL,".php")==true | RF.IntInRange(MF.PORT,8000,9000)==true | RF.IntIn(MF.PORT,80,443)==true | RF.Not(表达式)==true | RF.IsEmpty(值)==true | RF.IsNotEmpty(值)==true | RF.LengthBetween(MF.URL,0,512)==true
条件之间用 &&(且) 或 ||(或) 连接。
# 命中动作(then 里,四选一)
# 命中动作(then 里,五选一)
RF.Deny(); 拦截(默认)
RF.Ban(10); 拦截并将该IP在当前站点临时封禁10分钟(参数为正整数分钟,已有封禁不续期)
RF.Log(); 仅记录不拦截(灰度观察)
RF.Allow(); 放行(后续检测照常)
RF.Allow("CC","AI"); 放行并跳过指定检测模块
@@ -105,7 +106,7 @@ rule R<uniqueId> "rule description" salience <priority> {
}
Notes:
- R<uniqueId>: rule name, starts with uppercase R, letters and digits only (no dash).
- salience: priority, higher wins. Site rules and global rules are arbitrated together by salience; on a tie the order is deny > allow > log. Use 10 by default; use 100 for allow rules that must override a global deny rule.
- salience: priority, higher wins. Site rules and global rules are arbitrated together by salience; on a tie the order is deny/ban > allow > log. Use 10 by default; use 100 for allow rules that must override a global deny rule.
- when: condition; matches when true. then: exactly one action.
# Request fields (MF = current request)
@@ -122,6 +123,7 @@ Join conditions with && (and) or || (or).
# Actions (in then, pick one)
RF.Deny(); block (default)
RF.Ban(10); block and temporarily ban the IP on this host for 10 minutes (a positive integer in minutes; an existing ban is not extended)
RF.Log(); log only, no block (canary observation)
RF.Allow(); allow (later checks still run)
RF.Allow("CC","AI"); allow and skip given detection modules
+109
View File
@@ -0,0 +1,109 @@
package api
import (
"SamWaf/model/common/response"
"SamWaf/model/request"
"net"
"regexp"
"strings"
"github.com/gin-gonic/gin"
)
type WafRuleBanApi struct {
}
var ruleBanHostCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
// GetConfigApi 获取规则封禁配置
// @Summary 获取规则封禁配置
// @Description 获取规则封禁执行动作配置
// @Tags 网站防护-规则封禁
// @Produce json
// @Success 200 {object} response.Response "获取成功"
// @Security ApiKeyAuth
// @Router /wafhost/ruleban/config [get]
func (w *WafRuleBanApi) GetConfigApi(c *gin.Context) {
var req request.WafRuleBanConfigReq
err := c.ShouldBind(&req)
if err == nil {
response.OkWithDetailed(wafRuleBanService.GetConfigApi(), "获取成功", c)
} else {
response.FailWithMessage("解析失败", c)
}
}
// SetConfigApi 设置规则封禁配置
// @Summary 设置规则封禁配置
// @Description 更新规则封禁全局配置
// @Tags 网站防护-规则封禁
// @Accept json
// @Produce json
// @Param data body request.WafRuleBanSetConfigReq true "配置参数"
// @Success 200 {object} response.Response "设置成功"
// @Security ApiKeyAuth
// @Router /wafhost/ruleban/config [post]
func (w *WafRuleBanApi) SetConfigApi(c *gin.Context) {
var req request.WafRuleBanSetConfigReq
err := c.ShouldBindJSON(&req)
if err == nil {
if req.Mode != 0 && req.Mode != 1 {
response.FailWithMessage("封禁执行动作取值不合法", c)
return
}
if err := wafRuleBanService.SetConfigApi(req); err != nil {
response.FailWithMessage("设置失败", c)
return
}
response.OkWithMessage("设置成功", c)
} else {
response.FailWithMessage("解析失败", c)
}
}
// GetBanIpListApi 获取规则封禁列表
// @Summary 获取规则封禁列表
// @Description 获取当前因自定义规则 RF.Ban 命中而被临时封禁的IP列表
// @Tags 网站防护-规则封禁
// @Produce json
// @Success 200 {object} response.Response{data=response.PageResult} "获取成功"
// @Security ApiKeyAuth
// @Router /wafhost/ruleban/baniplist [get]
func (w *WafRuleBanApi) GetBanIpListApi(c *gin.Context) {
beans, err := wafRuleBanService.GetBanIpListApi()
if err != nil {
response.FailWithMessage("获取失败", c)
return
}
total := len(beans)
response.OkWithDetailed(response.PageResult{
List: beans,
Total: int64(total),
PageIndex: 1,
PageSize: 999999,
}, "获取成功", c)
}
// RemoveBanIpApi 移除规则封禁IP
func (w *WafRuleBanApi) RemoveBanIpApi(c *gin.Context) {
var req request.WafRuleBanRemoveBanIpReq
err := c.ShouldBindJSON(&req)
if err == nil {
req.Ip = strings.TrimSpace(req.Ip)
if net.ParseIP(req.Ip) == nil {
response.FailWithMessage("IP格式不合法", c)
return
}
req.HostCode = strings.TrimSpace(req.HostCode)
if req.HostCode != "" && !ruleBanHostCodePattern.MatchString(req.HostCode) {
response.FailWithMessage("站点编码格式不合法", c)
return
}
wafRuleBanService.RemoveBanIpApi(req)
response.OkWithMessage(req.Ip+" 移除成功", c)
} else {
response.FailWithMessage("解析失败", c)
}
}
+1
View File
@@ -5,6 +5,7 @@ const (
CACHE_OTP_ERROR = "CACHE_OTP_ERROR" //二次验证(OTP)错误,按账户名计数,防 TOTP 爆破
CACHE_NOTICE_PRE = "CACHE_NOTICE_PRE" //通知前缀
CACHE_CCVISITBAN_PRE = "CACHE_CCVISITBAN_PRE_" //CC封禁前缀
CACHE_RULE_BAN_PRE = "CACHE_RULE_BAN_PRE_" //规则封禁前缀(键后缀:hostCode|IP)
CACHE_TOKEN = "CACHE_TOKEN" //鉴权信息
CACHE_DNS_BOT_IP = "CACHE_DNS_BOT_IP" //IP反向域名解析
CACHE_DNS_NORMAL_IP = "CACHE_DNS_NORMAL_IP" //正常IP
+3
View File
@@ -143,6 +143,9 @@ var (
GCONFIG_IP_FAILURE_BAN_ENABLED int64 = 0 //是否启用IP失败封禁 1启用 0禁用
GCONFIG_IP_FAILURE_BAN_LOCK_TIME int64 = 10 //IP失败封禁锁定时间(分钟)默认10分钟
// 规则封禁相关配置(自定义规则 RF.Ban(分钟) 命中后临时封禁客户端IP)
GCONFIG_RULE_BAN_MODE int64 = 0 //规则封禁执行动作 0返回拦截页 1直接断开连接
GCONFIG_CHECK_BETA_VERSION int64 = 1 //是否检测beta版本更新 1启用 0禁用(默认检测最新版本)
// ZeroSSL 相关配置
+5
View File
@@ -313,3 +313,8 @@ func (rf *RuleFunc) Allow(modules ...string) {}
// AllowAll 命中后放行并跳过后续所有检测,直通后端(等价于 RF.Allow("ALL"))
// 使用示例: then RF.AllowAll();
func (rf *RuleFunc) AllowAll() {}
// Ban 命中后拦截当前请求,并将客户端IP在该站点封禁指定分钟数
// minutes: 封禁时长(分钟),必须是正整数
// 使用示例: then RF.Ban(10);
func (rf *RuleFunc) Ban(minutes int64) {}
+16
View File
@@ -0,0 +1,16 @@
package request
// WafRuleBanConfigReq 获取规则封禁配置请求
type WafRuleBanConfigReq struct {
}
// WafRuleBanSetConfigReq 设置规则封禁配置请求
type WafRuleBanSetConfigReq struct {
Mode int64 `json:"mode" form:"mode"` // 封禁执行动作 0返回拦截页 1直接断开
}
// WafRuleBanRemoveBanIpReq 移除规则封禁IP请求
type WafRuleBanRemoveBanIpReq struct {
HostCode string `json:"host_code" form:"host_code"` // 站点编码
Ip string `json:"ip" form:"ip"` // 移除封禁IP
}
+16
View File
@@ -0,0 +1,16 @@
package response
// RuleBanConfigResp 规则封禁配置响应
type RuleBanConfigResp struct {
Mode int64 `json:"mode"` // 封禁执行动作 0返回拦截页 1直接断开
}
// RuleBanIpResp 规则封禁IP响应
type RuleBanIpResp struct {
IP string `json:"ip"` // IP地址
HostCode string `json:"host_code"` // 站点编码
HostName string `json:"host_name"` // 站点名称(解析失败时为空)
BanMinutes int64 `json:"ban_minutes"` // 封禁时长(分钟)
RemainTime string `json:"remain_time"` // 剩余封禁时间
Region string `json:"region"` // IP归属地
}
+24
View File
@@ -0,0 +1,24 @@
package model
import (
"SamWaf/enums"
"strings"
)
// BuildRuleBanKey 规则封禁键:站点作用域(hostCode|IP),值与 TTL 均为封禁分钟数
func BuildRuleBanKey(hostCode, ip string) string {
return enums.CACHE_RULE_BAN_PRE + hostCode + "|" + ip
}
// ParseRuleBanKey 解析规则封禁键,返回 hostCode、IP、是否解析成功
func ParseRuleBanKey(key string) (string, string, bool) {
if !strings.HasPrefix(key, enums.CACHE_RULE_BAN_PRE) {
return "", "", false
}
rest := strings.TrimPrefix(key, enums.CACHE_RULE_BAN_PRE)
idx := strings.LastIndex(rest, "|")
if idx <= 0 {
return "", "", false
}
return rest[:idx], rest[idx+1:], true
}
+36
View File
@@ -0,0 +1,36 @@
package model
import "testing"
func TestRuleBanKeyRoundTrip(t *testing.T) {
key := BuildRuleBanKey("host-code-1", "1.2.3.4")
hostCode, ip, ok := ParseRuleBanKey(key)
if !ok || hostCode != "host-code-1" || ip != "1.2.3.4" {
t.Fatalf("回环解析失败: hostCode=%q ip=%q ok=%v", hostCode, ip, ok)
}
}
func TestRuleBanKeyIPv6(t *testing.T) {
key := BuildRuleBanKey("host-code-1", "2001:db8::1")
hostCode, ip, ok := ParseRuleBanKey(key)
if !ok || hostCode != "host-code-1" || ip != "2001:db8::1" {
t.Fatalf("IPv6 回环解析失败: hostCode=%q ip=%q ok=%v", hostCode, ip, ok)
}
}
func TestRuleBanKeyRejectForeignPrefix(t *testing.T) {
if _, _, ok := ParseRuleBanKey("CACHE_CCVISITBAN_PRE_global|host|1.2.3.4"); ok {
t.Fatalf("非规则封禁前缀被误解析")
}
}
func TestRuleBanKeyRejectMalformed(t *testing.T) {
// 缺少分隔符
if _, _, ok := ParseRuleBanKey("CACHE_RULE_BAN_PRE_1.2.3.4"); ok {
t.Fatalf("缺少站点分隔符被误解析")
}
// 站点码为空
if _, _, ok := ParseRuleBanKey("CACHE_RULE_BAN_PRE_|1.2.3.4"); ok {
t.Fatalf("空站点码被误解析")
}
}
+1
View File
@@ -12,6 +12,7 @@ type ApiGroup struct {
AntiCCRouter
AntiCCRuleRouter
IPFailureRouter
RuleBanRouter
BlockIpRouter
HostGroupRouter
IPGroupRouter
+19
View File
@@ -0,0 +1,19 @@
package router
import (
"SamWaf/api"
"github.com/gin-gonic/gin"
)
type RuleBanRouter struct {
}
func (receiver *RuleBanRouter) InitRuleBanRouter(group *gin.RouterGroup) {
api := api.APIGroupAPP.WafRuleBanApi
router := group.Group("")
router.GET("/api/v1/wafhost/ruleban/config", api.GetConfigApi)
router.POST("/api/v1/wafhost/ruleban/config", api.SetConfigApi)
router.GET("/api/v1/wafhost/ruleban/baniplist", api.GetBanIpListApi)
router.POST("/api/v1/wafhost/ruleban/removebanip", api.RemoveBanIpApi)
}
+41 -1
View File
@@ -91,6 +91,7 @@ const (
srcThreatExclude = "threat_exclude"
srcIPFailure = "ip_failure"
srcCCBan = "cc_ban"
srcRuleBan = "rule_ban"
srcFirewall = "firewall"
srcCDN = "cdn"
)
@@ -149,7 +150,7 @@ func (r *WafIPLookupService) Lookup(ipStr string, sources []string) (*response2.
}
for _, src := range []string{
srcIPWhite, srcIPBlack, srcIPGroup, srcLogExclude, srcThreatIP,
srcIPFailure, srcCCBan, srcFirewall, srcCDN,
srcIPFailure, srcCCBan, srcRuleBan, srcFirewall, srcCDN,
} {
if pick(src) {
resp.Sources = append(resp.Sources, src)
@@ -190,6 +191,9 @@ func (r *WafIPLookupService) Lookup(ipStr string, sources []string) (*response2.
if pick(srcCCBan) {
r.matchCCBan(ipStr, resp)
}
if pick(srcRuleBan) {
r.matchRuleBan(ipStr, resp)
}
if pick(srcFirewall) {
r.matchFirewall(ipStr, resp)
}
@@ -587,6 +591,42 @@ func (r *WafIPLookupService) matchCCBan(ip string, resp *response2.IPLookupResp)
})
}
// matchRuleBan 规则封禁命中(自定义规则 RF.Ban 写入,站点作用域)。
func (r *WafIPLookupService) matchRuleBan(ip string, resp *response2.IPLookupResp) {
if global.GCACHE_WAFCACHE == nil {
resp.Degraded = append(resp.Degraded, srcRuleBan)
return
}
hitKey := ""
hitHostCode := ""
for cacheKey := range global.GCACHE_WAFCACHE.ListAvailableKeysWithPrefix(enums.CACHE_RULE_BAN_PRE) {
if hostCode, banIp, ok := model.ParseRuleBanKey(cacheKey); ok && banIp == ip {
hitKey, hitHostCode = cacheKey, hostCode
break
}
}
if hitKey == "" {
return
}
detail := "自定义规则 RF.Ban 触发的临时封禁"
if remain := r.remainTextByKey(enums.CACHE_RULE_BAN_PRE, hitKey); remain != "" {
detail += ",剩余" + remain
}
if hitHostCode != "" {
names := r.hostNameMap()
if label := r.hostLabel(hitHostCode, names); label != "" {
detail += ",站点 " + label
}
}
resp.Hits = append(resp.Hits, response2.IPLookupHit{
Source: srcRuleBan,
SourceName: "规则封禁",
Scope: "本站点",
Effect: "block",
Detail: detail,
})
}
// remainText 取缓存剩余时间。缓存接口只给了「按前缀列出可用键」,没有单键 TTL,
// 所以这里列一次再挑出目标键。
// remainTextByKey 与 remainText 同义,区别是直接给完整缓存键——
@@ -0,0 +1,93 @@
package waf_service
import (
"SamWaf/cache"
"SamWaf/enums"
"SamWaf/global"
"SamWaf/model"
"SamWaf/model/request"
response2 "SamWaf/model/response"
"SamWaf/utils"
"errors"
"fmt"
"strconv"
)
type WafRuleBanService struct{}
var WafRuleBanServiceApp = new(WafRuleBanService)
func (s *WafRuleBanService) GetConfigApi() response2.RuleBanConfigResp {
return response2.RuleBanConfigResp{Mode: global.GCONFIG_RULE_BAN_MODE}
}
func (s *WafRuleBanService) SetConfigApi(req request.WafRuleBanSetConfigReq) error {
if err := WafSystemConfigServiceApp.ModifyByItemApi(request.WafSystemConfigEditByItemReq{
Item: "rule_ban_mode",
Value: strconv.FormatInt(req.Mode, 10),
}); err != nil {
return err
}
global.GCONFIG_RULE_BAN_MODE = req.Mode
return nil
}
func (s *WafRuleBanService) GetBanIpListApi() ([]response2.RuleBanIpResp, error) {
return ruleBanIPList(global.GCACHE_WAFCACHE, func(hostCode string) string {
return WafHostServiceApp.GetDetailByCodeApi(hostCode).Host
})
}
func ruleBanIPList(store cache.CacheStore, hostName func(string) string) ([]response2.RuleBanIpResp, error) {
keys := store.ListAvailableKeysWithPrefix(enums.CACHE_RULE_BAN_PRE)
beans := make([]response2.RuleBanIpResp, 0, len(keys))
for key, duration := range keys {
hostCode, ip, ok := model.ParseRuleBanKey(key)
if !ok || ip == "" {
continue
}
var minutes int64
if err := store.GetAs(key, &minutes); err != nil {
if errors.Is(err, cache.ErrCacheMiss) {
continue
}
return nil, err
}
var remaining string
totalMinutes := int(duration.Minutes())
if totalMinutes < 1 {
remaining = fmt.Sprintf("%d秒", int(duration.Seconds()))
} else if hours := int(duration.Hours()); hours > 0 {
remaining = fmt.Sprintf("%d时%02d分", hours, totalMinutes%60)
} else {
remaining = fmt.Sprintf("%d分", totalMinutes)
}
beans = append(beans, response2.RuleBanIpResp{
IP: ip,
HostCode: hostCode,
HostName: hostName(hostCode),
BanMinutes: minutes,
RemainTime: remaining,
Region: fmt.Sprintf("%v", utils.GetCountry(ip)),
})
}
return beans, nil
}
func (s *WafRuleBanService) RemoveBanIpApi(req request.WafRuleBanRemoveBanIpReq) {
removeRuleBanIP(global.GCACHE_WAFCACHE, req.HostCode, req.Ip)
}
func removeRuleBanIP(store cache.CacheStore, hostCode, ip string) {
if hostCode != "" {
store.Remove(model.BuildRuleBanKey(hostCode, ip))
return
}
for key := range store.ListAvailableKeysWithPrefix(enums.CACHE_RULE_BAN_PRE) {
if _, banIP, ok := model.ParseRuleBanKey(key); ok && banIP == ip {
store.Remove(key)
}
}
}
@@ -0,0 +1,103 @@
package waf_service
import (
"SamWaf/cache"
"SamWaf/cache/cachetest"
"SamWaf/enums"
"SamWaf/global"
"SamWaf/model"
"SamWaf/model/request"
"path/filepath"
"testing"
"time"
sqlite "github.com/samwafgo/sqlitedriver"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestRuleBanListAndRemove(t *testing.T) {
for name, store := range map[string]cache.CacheStore{
"memory": cache.InitWafCache(),
"json": cachetest.NewJSONRoundTrip(),
} {
t.Run(name, func(t *testing.T) {
const ip = "192.0.2.10"
keyA := model.BuildRuleBanKey("host-a", ip)
keyB := model.BuildRuleBanKey("host-b", ip)
other := model.BuildRuleBanKey("host-a", "192.0.2.11")
store.SetWithTTl(keyA, int64(17), 17*time.Minute)
store.SetWithTTl(keyB, int64(23), 23*time.Minute)
store.SetWithTTl(other, int64(31), 31*time.Minute)
store.SetWithTTl(enums.CACHE_RULE_BAN_PRE+"invalid", int64(5), time.Minute)
store.SetWithTTl("unrelated", int64(9), time.Minute)
rows, err := ruleBanIPList(store, func(code string) string { return code + ".example" })
if err != nil || len(rows) != 3 {
t.Fatalf("list: rows=%+v err=%v", rows, err)
}
expected := map[string]int64{keyA: 17, keyB: 23, other: 31}
for _, row := range rows {
if row.BanMinutes != expected[model.BuildRuleBanKey(row.HostCode, row.IP)] {
t.Fatalf("minutes not preserved: %+v", row)
}
if row.HostName != row.HostCode+".example" || row.RemainTime == "" {
t.Fatalf("incomplete row: %+v", row)
}
}
removeRuleBanIP(store, "host-a", ip)
if store.IsKeyExist(keyA) || !store.IsKeyExist(keyB) {
t.Fatal("site removal must only remove the selected site")
}
store.SetWithTTl(keyA, int64(17), 17*time.Minute)
removeRuleBanIP(store, "", ip)
if store.IsKeyExist(keyA) || store.IsKeyExist(keyB) || !store.IsKeyExist(other) || !store.IsKeyExist("unrelated") {
t.Fatal("all-site removal must only remove rule bans for the selected IP")
}
})
}
}
func TestRuleBanConfigSave(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "ruleban.db")), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatal(err)
}
oldDB, oldMode := global.GWAF_LOCAL_DB, global.GCONFIG_RULE_BAN_MODE
global.GWAF_LOCAL_DB, global.GCONFIG_RULE_BAN_MODE = db, 0
t.Cleanup(func() {
global.GWAF_LOCAL_DB, global.GCONFIG_RULE_BAN_MODE = oldDB, oldMode
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
if err := db.AutoMigrate(&model.SystemConfig{}); err != nil {
t.Fatal(err)
}
req := request.WafRuleBanSetConfigReq{Mode: 1}
if err := WafRuleBanServiceApp.SetConfigApi(req); err == nil || global.GCONFIG_RULE_BAN_MODE != 0 {
t.Fatal("missing config must fail without changing the runtime mode")
}
if err := db.Create(&model.SystemConfig{Item: "rule_ban_mode", Value: "0"}).Error; err != nil {
t.Fatal(err)
}
if err := WafRuleBanServiceApp.SetConfigApi(req); err != nil {
t.Fatal(err)
}
var saved model.SystemConfig
if err := db.Where("item = ?", "rule_ban_mode").First(&saved).Error; err != nil || saved.Value != "1" || global.GCONFIG_RULE_BAN_MODE != 1 {
t.Fatalf("save: value=%q mode=%d err=%v", saved.Value, global.GCONFIG_RULE_BAN_MODE, err)
}
if err := db.Callback().Update().Before("gorm:update").Register("ruleban_test_error", func(tx *gorm.DB) {
tx.AddError(gorm.ErrInvalidTransaction)
}); err != nil {
t.Fatal(err)
}
if err := WafRuleBanServiceApp.SetConfigApi(request.WafRuleBanSetConfigReq{Mode: 0}); err == nil || global.GCONFIG_RULE_BAN_MODE != 1 {
t.Fatal("failed update must not change the runtime mode")
}
}
+30 -12
View File
@@ -6,6 +6,7 @@ import (
"sort"
"strconv"
"strings"
"time"
)
// 规则动作
@@ -13,6 +14,7 @@ const (
RuleActionDeny = "deny" // 拦截(默认)
RuleActionAllow = "allow" // 放行
RuleActionLog = "log" // 仅记录
RuleActionBan = "ban" // 拦截并临时封禁(RF.Ban(分钟))
)
// 可跳过的检测模块名(Allow 的参数只接受这些值,大小写不敏感)
@@ -59,8 +61,9 @@ func RuleSkipModules() []string {
// RuleActionInfo 一条规则的动作信息
type RuleActionInfo struct {
Action string // deny / allow / log
Action string // deny / allow / log / ban
SkipModules []string // 仅 allow 有效,元素为大写模块名;含 "ALL" 表示跳过全部
BanMinutes int64 // 仅 ban 有效,封禁时长(分钟)
}
// SkipAll 是否跳过后续所有检测
@@ -78,8 +81,8 @@ var (
ruleBlockRegex = regexp.MustCompile(`(?m)\brule\s+([A-Za-z0-9_]+)`)
// then 关键字
ruleThenRegex = regexp.MustCompile(`\bthen\b`)
// 动作标记:RF.Allow(...) / RF.AllowAll() / RF.Deny() / RF.Log()
ruleActionRegex = regexp.MustCompile(`RF\s*\.\s*(AllowAll|Allow|Deny|Log)\s*\(([^)]*)\)`)
// 动作标记:RF.Allow(...) / RF.AllowAll() / RF.Deny() / RF.Log() / RF.Ban(分钟)
ruleActionRegex = regexp.MustCompile(`RF\s*\.\s*(AllowAll|Allow|Deny|Log|Ban)\s*\(([^)]*)\)`)
// 优先级:rule Rxxx "描述" salience 10 {
ruleSalienceRegex = regexp.MustCompile(`\bsalience\s+(\d+)`)
)
@@ -256,22 +259,23 @@ func parseActionArgs(argsRaw string) (modules []string, invalid []string) {
// extractBlockAction 解析单个规则块的动作
// 只扫描 then 之后的部分,避免 when 里的内容干扰
func extractBlockAction(skeletonBlock, rawBlock string) (info RuleActionInfo, count int, invalid []string) {
// banErr:Ban 的分钟数非法(非正整数)时为 true,由调用方决定宽容还是报错
func extractBlockAction(skeletonBlock, rawBlock string) (info RuleActionInfo, count int, invalid []string, banErr bool) {
info = RuleActionInfo{Action: RuleActionDeny}
thenLoc := ruleThenRegex.FindStringIndex(skeletonBlock)
if thenLoc == nil {
return info, 0, nil
return info, 0, nil, false
}
offset := thenLoc[1]
matches := ruleActionRegex.FindAllStringSubmatchIndex(skeletonBlock[offset:], -1)
if len(matches) == 0 {
return info, 0, nil
return info, 0, nil, false
}
for i, m := range matches {
fnName := skeletonBlock[offset+m[2] : offset+m[3]]
// 参数从原文同位置取(骨架里字符串内容已被抹空)
// 参数从原文同位置取(骨架里字符串内容已被抹空;Ban 的分钟数是数字,骨架与原文一致)
argsRaw := ""
if m[4] >= 0 && offset+m[5] <= len(rawBlock) {
argsRaw = rawBlock[offset+m[4] : offset+m[5]]
@@ -289,26 +293,36 @@ func extractBlockAction(skeletonBlock, rawBlock string) (info RuleActionInfo, co
mods, bad := parseActionArgs(argsRaw)
invalid = append(invalid, bad...)
cur = RuleActionInfo{Action: RuleActionAllow, SkipModules: mods}
case "Ban":
minutes, err := strconv.ParseInt(strings.TrimSpace(argsRaw), 10, 64)
if err != nil || minutes <= 0 || minutes > int64((1<<63-1)/time.Minute) {
banErr = true
}
cur = RuleActionInfo{Action: RuleActionBan, BanMinutes: minutes}
}
// 多个标记时以第一个为准
if i == 0 {
info = cur
}
}
return info, len(matches), invalid
return info, len(matches), invalid, banErr
}
// ExtractRuleActions 从规则文本中解析出每条规则的动作
// 支持一段文本包含多条规则。没有声明动作的规则不会出现在结果里(查不到即默认拦截)。
// 运行期使用:宽容处理,多个动作标记取第一个,非法模块名忽略。
// 运行期使用:宽容处理,多个动作标记取第一个,非法模块名忽略;
// Ban 分钟数非法时降级为普通拦截(不封禁),保证行为偏安全
func ExtractRuleActions(ruleText string) map[string]RuleActionInfo {
result := make(map[string]RuleActionInfo)
skeleton := BuildGrlSkeleton(ruleText)
for _, block := range splitRuleBlocks(skeleton) {
info, count, _ := extractBlockAction(skeleton[block.Start:block.End], ruleText[block.Start:block.End])
info, count, _, banErr := extractBlockAction(skeleton[block.Start:block.End], ruleText[block.Start:block.End])
if count == 0 {
continue
}
if info.Action == RuleActionBan && (banErr || info.BanMinutes <= 0) {
info = RuleActionInfo{Action: RuleActionDeny}
}
result[block.Name] = info
}
return result
@@ -318,6 +332,7 @@ func ExtractRuleActions(ruleText string) map[string]RuleActionInfo {
// 返回该规则的动作;发现下列情况直接报错:
// - 声明了多个不同的动作标记
// - Allow 的参数里有不认识的模块名
// - Ban 的分钟数不是正整数
func ExtractRuleActionForCheck(ruleText string) (RuleActionInfo, error) {
skeleton := BuildGrlSkeleton(ruleText)
blocks := splitRuleBlocks(skeleton)
@@ -328,15 +343,18 @@ func ExtractRuleActionForCheck(ruleText string) (RuleActionInfo, error) {
info := RuleActionInfo{Action: RuleActionDeny}
for _, block := range blocks {
skeletonBlock := skeleton[block.Start:block.End]
blockInfo, count, invalid := extractBlockAction(skeletonBlock, ruleText[block.Start:block.End])
blockInfo, count, invalid, banErr := extractBlockAction(skeletonBlock, ruleText[block.Start:block.End])
if len(invalid) > 0 {
return info, fmt.Errorf("规则动作 RF.Allow 参数中存在无法识别的检测模块: %s,可用模块: %s",
strings.Join(invalid, ", "), strings.Join(ruleSkipModules, ", "))
}
if blockInfo.Action == RuleActionBan && banErr {
return info, fmt.Errorf("规则 %s 中 RF.Ban 的封禁时长必须是正整数分钟且不超过 %d,例如 RF.Ban(10)", block.Name, int64((1<<63-1)/time.Minute))
}
if count > 1 {
// 多个标记:只要动作语义不完全一致就报错
if !hasSingleAction(skeletonBlock) {
return info, fmt.Errorf("规则 %s 中声明了多个不同的动作,一条规则只能有一个动作(RF.Deny/RF.Allow/RF.Log)", block.Name)
return info, fmt.Errorf("规则 %s 中声明了多个不同的动作,一条规则只能有一个动作(RF.Deny/RF.Allow/RF.Log/RF.Ban)", block.Name)
}
}
info = blockInfo
+135
View File
@@ -0,0 +1,135 @@
package utils
import (
"strings"
"testing"
)
// ---------- RF.Ban 动作解析 ----------
func TestExtractRuleActions_Ban(t *testing.T) {
ruleText := `rule Rban123 "命中封禁10分钟" salience 10 {
when
MF.URL.Contains("/wp-admin") == true
then
RF.Ban(10);
}`
actions := ExtractRuleActions(ruleText)
info, ok := actions["Rban123"]
if !ok {
t.Fatalf("未解析到规则动作: %v", actions)
}
if info.Action != RuleActionBan {
t.Fatalf("动作=%s,期望 %s", info.Action, RuleActionBan)
}
if info.BanMinutes != 10 {
t.Fatalf("BanMinutes=%d,期望 10", info.BanMinutes)
}
}
// 字符串字面量里的 RF.Ban 不得被当成动作(骨架机制)
func TestExtractRuleActions_BanInStringLiteralIgnored(t *testing.T) {
ruleText := `rule Rfake456 "示例" salience 10 {
when
MF.URL.Contains("RF.Ban(10)") == true
then
Retract("Rfake456");
}`
actions := ExtractRuleActions(ruleText)
info, ok := actions["Rfake456"]
if ok && info.Action == RuleActionBan {
t.Fatalf("字符串字面量里的 RF.Ban 被误解析为动作: %+v", info)
}
}
// 运行期宽容模式:Ban 分钟数非法时降级为普通拦截
func TestExtractRuleActions_BanInvalidMinutesFallsBackToDeny(t *testing.T) {
cases := []struct {
name string
args string
}{
{"零分钟", "0"},
{"负数", "-5"},
{"非数字", `"abc"`},
{"空参", ""},
{"超出时长范围", "153722868"},
}
for _, tc := range cases {
ruleText := `rule Rbad789 "非法" salience 10 {
when
MF.URL.Contains("/x") == true
then
RF.Ban(` + tc.args + `);
}`
actions := ExtractRuleActions(ruleText)
info, ok := actions["Rbad789"]
if !ok {
t.Fatalf("%s: 未解析到规则动作", tc.name)
}
if info.Action != RuleActionDeny {
t.Fatalf("%s: 动作=%s BanMinutes=%d,期望降级为 deny", tc.name, info.Action, info.BanMinutes)
}
}
}
// 保存校验严格模式:Ban 分钟数非法必须报错
func TestExtractRuleActionForCheck_BanInvalidMinutesRejected(t *testing.T) {
cases := []struct {
name string
args string
}{
{"零分钟", "0"},
{"负数", "-5"},
{"非数字", `"abc"`},
{"空参", ""},
{"超出时长范围", "153722868"},
}
for _, tc := range cases {
ruleText := `rule Rchk111 "非法" salience 10 {
when
MF.URL.Contains("/x") == true
then
RF.Ban(` + tc.args + `);
}`
_, err := ExtractRuleActionForCheck(ruleText)
if err == nil {
t.Fatalf("%s: 期望报错但通过", tc.name)
}
if !strings.Contains(err.Error(), "RF.Ban") {
t.Fatalf("%s: 报错信息未提及 RF.Ban: %v", tc.name, err)
}
}
}
func TestExtractRuleActionForCheck_BanValid(t *testing.T) {
ruleText := `rule Rok222 "封禁半小时" salience 10 {
when
MF.URL.Contains("/wp-login") == true
then
RF.Ban(30);
}`
info, err := ExtractRuleActionForCheck(ruleText)
if err != nil {
t.Fatalf("合法 RF.Ban(30) 校验失败: %v", err)
}
if info.Action != RuleActionBan || info.BanMinutes != 30 {
t.Fatalf("动作=%s BanMinutes=%d,期望 ban/30", info.Action, info.BanMinutes)
}
}
// 字符串字面量里的 RF.Ban 在严格模式下同样不得报错也不得识别为动作
func TestExtractRuleActionForCheck_BanInStringLiteral(t *testing.T) {
ruleText := `rule Rlit333 "示例" salience 10 {
when
MF.URL.Contains("then RF.Ban(99);") == true
then
RF.Deny();
}`
info, err := ExtractRuleActionForCheck(ruleText)
if err != nil {
t.Fatalf("字符串字面量内容导致校验误报: %v", err)
}
if info.Action != RuleActionDeny || info.BanMinutes != 0 {
t.Fatalf("动作=%s BanMinutes=%d,期望 deny/0", info.Action, info.BanMinutes)
}
}
+32 -2
View File
@@ -4,6 +4,7 @@ import (
"SamWaf/common/zlog"
"SamWaf/global"
"SamWaf/innerbean"
"SamWaf/model"
"SamWaf/model/detection"
"SamWaf/model/wafenginmodel"
"SamWaf/utils"
@@ -12,6 +13,7 @@ import (
"regexp"
"sort"
"strconv"
"time"
"github.com/hyperjumptech/grule-rule-engine/ast"
)
@@ -50,6 +52,7 @@ func pickRuleAction(ruleHelper *utils.RuleHelper, ruleMatchs []*ast.RuleEntry) u
rank := map[string]int{
utils.RuleActionDeny: 3,
utils.RuleActionBan: 3, // 封禁与拦截同档:都是最强动作,差别只在额外写封禁
utils.RuleActionAllow: 2,
utils.RuleActionLog: 1,
}
@@ -65,13 +68,19 @@ func pickRuleAction(ruleHelper *utils.RuleHelper, ruleMatchs []*ast.RuleEntry) u
}
}
if final.Action == "" || rank[info.Action] > rank[final.Action] {
final.Action = info.Action
final = info
} else if rank[info.Action] == rank[final.Action] && info.Action == utils.RuleActionBan {
final.Action = utils.RuleActionBan
if info.BanMinutes > final.BanMinutes {
final.BanMinutes = info.BanMinutes
}
}
}
if final.Action == "" {
final.Action = utils.RuleActionDeny
}
if final.Action == utils.RuleActionAllow {
final.SkipModules = nil
for m := range skipSet {
final.SkipModules = append(final.SkipModules, m)
}
@@ -187,6 +196,7 @@ func arbitrate(local, globalR ruleMatchResult) ruleMatchResult {
// 同优先级:按动作强弱兜底
rank := map[string]int{
utils.RuleActionDeny: 3,
utils.RuleActionBan: 3, // 封禁与拦截同档:都是最强动作,差别只在额外写封禁
utils.RuleActionAllow: 2,
utils.RuleActionLog: 1,
}
@@ -204,6 +214,13 @@ func arbitrate(local, globalR ruleMatchResult) ruleMatchResult {
Title: local.Title + globalR.Title,
TopSalience: local.TopSalience,
}
if local.Action.Action == utils.RuleActionBan || globalR.Action.Action == utils.RuleActionBan {
merged.Action.Action = utils.RuleActionBan
merged.Action.BanMinutes = local.Action.BanMinutes
if globalR.Action.BanMinutes > merged.Action.BanMinutes {
merged.Action.BanMinutes = globalR.Action.BanMinutes
}
}
if merged.Action.Action == utils.RuleActionAllow {
skipSet := make(map[string]bool)
for _, m := range local.Action.SkipModules {
@@ -252,11 +269,24 @@ func (waf *WafEngine) CheckRule(r *http.Request, weblogbean *innerbean.WebLog, f
}
switch final.Action.Action {
case utils.RuleActionDeny:
case utils.RuleActionDeny, utils.RuleActionBan:
weblogbean.RISK_LEVEL = 1
result.IsBlock = true
result.Title = final.Title
result.Content = "您的访问被阻止触发规则"
if final.Action.Action == utils.RuleActionBan {
// 已封禁不刷新 TTL;仅记录模式不写入封禁。
if global.GCACHE_WAFCACHE != nil && hostTarget != nil && hostTarget.Host.LogOnlyMode != 1 {
banIP := model.GetClientIPByMode(hostTarget.Host.IPMode, weblogbean.NetSrcIp, weblogbean.SRC_IP)
if banIP != "" && final.Action.BanMinutes > 0 {
banKey := model.BuildRuleBanKey(hostTarget.Host.Code, banIP)
if !global.GCACHE_WAFCACHE.IsKeyExist(banKey) {
global.GCACHE_WAFCACHE.SetWithTTl(banKey, final.Action.BanMinutes,
time.Duration(final.Action.BanMinutes)*time.Minute)
}
}
}
}
case utils.RuleActionAllow:
result.IsRuleAllow = true
result.Title = final.Title
+90
View File
@@ -0,0 +1,90 @@
package wafenginecore
import (
"SamWaf/cache"
"SamWaf/cache/cachetest"
"SamWaf/global"
"SamWaf/innerbean"
"SamWaf/model"
"SamWaf/utils"
"testing"
"time"
)
func TestCheckRuleBanWithoutSeparateSwitch(t *testing.T) {
oldCache := global.GCACHE_WAFCACHE
t.Cleanup(func() { global.GCACHE_WAFCACHE = oldCache })
for name, store := range map[string]cache.CacheStore{
"memory": cache.InitWafCache(),
"json": cachetest.NewJSONRoundTrip(),
} {
t.Run(name, func(t *testing.T) {
global.GCACHE_WAFCACHE = store
checkRuleBanWithoutSeparateSwitch(t)
})
}
}
func checkRuleBanWithoutSeparateSwitch(t *testing.T) {
waf := newTestEngineWithGlobalRule(t, "")
host := newTestSiteHost(t, `rule Rtest001 "ban" salience 10 {
when MF.URL == "/ban-test"
then RF.Ban(10);
}`)
host.Host.Code = "ban-site"
log := &innerbean.WebLog{URL: "/ban-test", SRC_IP: "198.51.100.10", NetSrcIp: "198.51.100.10"}
result := waf.CheckRule(nil, log, nil, host, nil)
if !result.IsBlock {
t.Fatal("Ban must block the matching request")
}
key := model.BuildRuleBanKey(host.Host.Code, log.SRC_IP)
var minutes int64
err := global.GCACHE_WAFCACHE.GetAs(key, &minutes)
if err != nil || minutes != 10 {
t.Fatalf("ban minutes = %d, err = %v", minutes, err)
}
expires, err := global.GCACHE_WAFCACHE.GetExpireTime(key)
if err != nil || time.Until(expires) < 9*time.Minute || time.Until(expires) > 10*time.Minute {
t.Fatalf("unexpected ban expiry: %v, err = %v", expires, err)
}
waf.CheckRule(nil, log, nil, host, nil)
after, _ := global.GCACHE_WAFCACHE.GetExpireTime(key)
if !expires.Equal(after) {
t.Fatal("repeated matches must not renew the ban")
}
if hit, _ := matchRuleBan("other-site", log.NetSrcIp, log.SRC_IP, host.Host.IPMode); hit != "" {
t.Fatal("ban must be scoped to the triggering host")
}
global.GCACHE_WAFCACHE.Remove(key)
host.Host.LogOnlyMode = 1
waf.CheckRule(nil, log, nil, host, nil)
if global.GCACHE_WAFCACHE.IsKeyExist(key) {
t.Fatal("log-only mode must not create bans")
}
}
func TestRuleBanArbitrationPreservesMinutes(t *testing.T) {
rh := buildRuleHelper(t, `
rule Rdeny "deny" salience 10 { when MF.URL == "/x" then RF.Deny(); }
rule Rban10 "ban10" salience 10 { when MF.URL == "/x" then RF.Ban(10); }
rule Rban30 "ban30" salience 10 { when MF.URL == "/x" then RF.Ban(30); }
`)
matches, err := rh.Match("MF", &innerbean.WebLog{URL: "/x"})
if err != nil || len(matches) != 3 {
t.Fatalf("matches = %d, err = %v", len(matches), err)
}
info := pickRuleAction(rh, matches)
if info.Action != utils.RuleActionBan || info.BanMinutes != 30 {
t.Fatalf("unexpected action: %+v", info)
}
ban := ruleMatchResult{Matched: true, TopSalience: 10, Action: info}
deny := ruleMatchResult{Matched: true, TopSalience: 10, Action: utils.RuleActionInfo{Action: utils.RuleActionDeny}}
for _, result := range []ruleMatchResult{arbitrate(ban, deny), arbitrate(deny, ban), arbitrate(ban, ban)} {
if result.Action.Action != utils.RuleActionBan || result.Action.BanMinutes != 30 {
t.Fatalf("cross-scope arbitration lost ban duration: %+v", result)
}
}
}
+72
View File
@@ -0,0 +1,72 @@
package wafenginecore
import (
"SamWaf/global"
"SamWaf/innerbean"
"SamWaf/model"
"SamWaf/model/wafenginmodel"
"net/http"
"time"
)
// matchRuleBan 判断该站点的这个客户端当前是否处于规则封禁期(自定义规则 RF.Ban 写入)。
func matchRuleBan(hostCode, netSrcIP, srcIP, ipMode string) (hitKey string, clientIP string) {
if global.GCACHE_WAFCACHE == nil {
return "", ""
}
clientIP = model.GetClientIPByMode(ipMode, netSrcIP, srcIP)
if clientIP == "" {
return "", ""
}
key := model.BuildRuleBanKey(hostCode, clientIP)
if global.GCACHE_WAFCACHE.IsKeyExist(key) {
return key, clientIP
}
return "", clientIP
}
// echoRuleBanBlock 按全局配置执行规则封禁拦截:
// rule_ban_mode=1 直接断开连接(444),0 返回拦截页;无法劫持连接时降级为拦截页。
func echoRuleBanBlock(w http.ResponseWriter, r *http.Request, weblogbean *innerbean.WebLog, hostTarget *wafenginmodel.HostSafe, globalHostSafe *wafenginmodel.HostSafe) int {
if global.GCONFIG_RULE_BAN_MODE == 1 {
if hj, ok := w.(http.Hijacker); ok {
if conn, _, err := hj.Hijack(); err == nil {
conn.Close()
return 444
}
}
}
return EchoErrorInfo(w, r, weblogbean, "规则封禁", "当前IP已被规则临时封禁", hostTarget, globalHostSafe, false, "")
}
// ruleBanLog 规则封禁拦截的窄行日志(仿 ccBanLog:不含报文与请求体)
func ruleBanLog(log *innerbean.WebLog, responseCode int) *innerbean.WebLog {
recorded := *log
recorded.SkipPayloadStore = true
recorded.HEADER = ""
recorded.BODY = ""
recorded.RES_BODY = ""
recorded.COOKIES = ""
recorded.POST_FORM = ""
recorded.ResHeader = ""
recorded.SrcByteBody = nil
recorded.SrcByteResBody = nil
recorded.SrcURL = nil
recorded.BodyDecoded = ""
recorded.CookiesDecoded = ""
recorded.BodyValues = nil
recorded.BodyFields = nil
recorded.BodyHash = ""
recorded.RULE = "规则封禁"
recorded.ACTION = "阻止"
recorded.STATUS = "阻止访问"
if responseCode == 444 {
recorded.STATUS = "连接关闭"
}
recorded.STATUS_CODE = responseCode
recorded.TASK_FLAG = 1
recorded.RISK_LEVEL = 1
recorded.GUEST_IDENTIFICATION = "可疑用户"
recorded.TimeSpent = time.Now().UnixMilli() - recorded.UNIX_ADD_TIME
return &recorded
}
+12
View File
@@ -605,6 +605,18 @@ func (waf *WafEngine) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
// 规则封禁期拦截。位置与 CC 封禁同理:白名单之后(白名单 IP 可自救)、站点防护之内。
if hitKey, _ := matchRuleBan(hostTarget.Host.Code, weblogbean.NetSrcIp, weblogbean.SRC_IP, hostTarget.Host.IPMode); hitKey != "" {
if hostTarget.Host.LogOnlyMode == 1 {
weblogbean.LogOnlyMode = 1
weblogbean.RULE = "规则封禁"
} else {
responseCode := echoRuleBanBlock(w, r, &weblogbean, hostTarget, waf.rt().HostTarget[waf.rt().HostCode[global.GWAF_GLOBAL_HOST_CODE]])
global.GQEQUE_LOG_DB.Enqueue(ruleBanLog(&weblogbean, responseCode))
return
}
}
if handleBlock(waf.CheckDenyIP) {
return
}
+1
View File
@@ -196,6 +196,7 @@ func (web *WafWebManager) initRouter(r *gin.Engine) {
router.ApiGroupApp.InitAntiCCRouter(securityAdminGroup)
router.ApiGroupApp.InitAntiCCRuleRouter(securityAdminGroup)
router.ApiGroupApp.InitIPFailureRouter(securityAdminGroup)
router.ApiGroupApp.InitRuleBanRouter(securityAdminGroup)
router.ApiGroupApp.InitBlockIpRouter(securityAdminGroup)
// IP组是黑/白名单与自定义规则共用的防护策略资源,与它们同属安全管理员域
router.ApiGroupApp.InitIPGroupRouter(securityAdminGroup)
+6
View File
@@ -249,6 +249,9 @@ func setConfigIntValue(name string, value int64, change int) {
case "ip_failure_ban_lock_time":
global.GCONFIG_IP_FAILURE_BAN_LOCK_TIME = value
break
case "rule_ban_mode":
global.GCONFIG_RULE_BAN_MODE = value
break
case "host_guard_enabled":
global.GCONFIG_HOST_GUARD_ENABLED = value
// 开关切换不需要重启进程:0→1 启动日志采集,1→0 停止采集并释放 fd/子进程/系统句柄。
@@ -857,6 +860,9 @@ func TaskLoadSetting(initLoad bool) {
updateConfigIntItem(initLoad, "security", "ip_failure_ban_enabled", global.GCONFIG_IP_FAILURE_BAN_ENABLED, "是否启用IP失败封禁(1启用 0禁用)", "options", "0|禁用,1|启用", configMap)
updateConfigIntItem(initLoad, "security", "ip_failure_ban_lock_time", global.GCONFIG_IP_FAILURE_BAN_LOCK_TIME, "IP失败封禁锁定时间(单位:分钟,默认10分钟)", "int", "", configMap)
// 规则封禁相关配置(自定义规则 RF.Ban(分钟) 命中后临时封禁客户端IP)
updateConfigIntItem(initLoad, "security", "rule_ban_mode", global.GCONFIG_RULE_BAN_MODE, "规则封禁执行动作(0返回拦截页 1直接断开连接)", "options", "0|拦截页,1|直接断开", configMap)
// 主机远程登录爆破防护(SSH/RDP)。封禁时长不在这里配,由「封禁阶梯」表接管(5分→15分→60分→1天→永久)
updateConfigIntItem(initLoad, "hostguard", "host_guard_enabled", global.GCONFIG_HOST_GUARD_ENABLED, "主机远程登录爆破防护总开关(保护SamWaf所在机器自身的SSH/RDP。启用前请先确认白名单已包含你的管理IP,否则可能把自己锁在门外)", "options", "0|禁用,1|启用", configMap)
updateConfigStringItem(initLoad, "hostguard", "host_guard_mode", global.GCONFIG_HOST_GUARD_MODE, "工作模式:observe=只记录不封禁(建议先跑一周确认无误封),block=达到阈值即调用系统防火墙封禁", "options", "observe|观察模式,block|封禁模式", configMap)
+16
View File
@@ -12,6 +12,22 @@
# 5. fresh_install: true 的条目只在"全新安装"时生成,不参与版本区间计算
notes:
- id: v1_3_25_rule_ban
version: v1.3.25
kind: check
level: normal
page: /waf/wafRuleBan
apply:
type: navigate
zh:
title: 自定义规则支持临时封禁 IP
detail: 在已启用规则的动作中使用 RF.Ban(10),可拦截当前请求并将客户端 IP 在当前站点封禁 10 分钟,无需单独开启。封禁列表支持查询和解封,配置可选返回拦截页或直接断开;该配置用于后续封禁命中。已有封禁不续期,仅记录模式不写入新封禁。使用失败次数作为条件时,计数保留时间与封禁分钟数分别设置。
revert: 将规则动作改回 RF.Deny() 或停用该规则,并在规则封禁列表解除已有记录;仅停用规则不会立即解除已有封禁。
en:
title: Temporarily ban IPs with custom rules
detail: Use RF.Ban(10) in an enabled rule to block the current request and ban the client IP on the current website for 10 minutes. No separate switch is required. The ban list supports inspection and removal. Choose a blocking page or connection closure for subsequent banned requests. Existing bans are not renewed, and log-only mode does not create bans. When using failure counts as a condition, configure count retention and ban duration separately.
revert: Change the rule action back to RF.Deny() or disable the rule, then remove existing entries from the rule ban list. Disabling a rule alone does not immediately remove existing bans.
- id: v1_3_25_log_url_keyword
version: v1.3.25
kind: check