diff --git a/.gitignore b/.gitignore
index 2e1605c..f834e08 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
/vue/dist/
/conf/*
/data/*
+/wafenginecore/data/
/data/local_log.db
/data/local_stats.db
/data/local.db
diff --git a/conf/plugins.yml b/conf/plugins.yml
index be8bc74..7b2f868 100644
--- a/conf/plugins.yml
+++ b/conf/plugins.yml
@@ -1,9 +1,14 @@
# SamWaf 插件系统配置文件
# 详细说明请参考: SamWafDoc/plugin_dev/plugin-system-design.md
+#
+# ============================ 状态:已暂停 ============================
+# 当前代码中 manager.PendingVerification = true,是硬编码总闸:
+# 把下面的 enabled 改成 true 也不会加载任何插件,本文件目前只起占位与文档作用。
+# ===========================================================================
plugins:
- # 插件系统总开关
- enabled: true
+ # 插件系统总开关(当前被 manager.PendingVerification 总闸覆盖,恒不生效)
+ enabled: false
# 插件二进制文件存放目录
binary_dir: "./data/plugins/binaries"
@@ -41,7 +46,8 @@ plugins:
description: "屏蔽指定的IP地址(示例:8.8.8.8)"
type: "ip_filter"
version: "1.0.0"
- enabled: true # 默认关闭,编译插件后可启用
+ enabled: false # 示例条目,保持关闭。注意:这里的 binary_path 是固定相对路径,
+ # 一旦总闸放开又写成 true,谁能往该路径写文件谁就能以 WAF 进程身份执行
binary_path: "./data/plugins/binaries/simple_ip_blocker.exe"
priority: 100
diff --git a/global/config.go b/global/config.go
index 697d9ae..f72fc9e 100644
--- a/global/config.go
+++ b/global/config.go
@@ -58,6 +58,17 @@ var (
GCONFIG_AI_ENABLE int64 = 0 //AI智能检测总开关 1启用 0关闭(需先在AI模型管理上传模型包)
GCONFIG_AI_MODE string = "observe" //AI检测工作模式: observe(仅记录/观察) / block(达到拦截阈值则拦截)
+ //请求体深度检测模式(D3/S1 新增的 formValue/JSON 逐值 XSS/SQLi/RCE 检测):
+ //observe(默认,命中只在日志标记不拦截) / block(命中即拦) / off(不做请求体深检)。
+ //默认 observe:请求体逐值检测对正常 JSON/表单流量误报较高(libinjection 本为单值设计),
+ //先观察、用日志确认无误后再切 block;查询串检测不受此开关影响(始终拦截)。
+ GCONFIG_BODY_DETECT_MODE string = "observe"
+
+ //请求体深检字段排除清单(逗号分隔的字段名,不区分大小写,默认空)。这些字段名下的表单值/
+ //JSON 值(匹配直接父键名)跳过 XSS/SQLi/RCE 逐值检测——用于放行已知携带富文本/HTML/代码的
+ //合法字段(如 content,html,markdown),压 block 模式误报(E)。观察日志里看是哪些字段误报再填。
+ GCONFIG_BODY_DETECT_FIELD_EXCLUDE string = ""
+
//自定义规则在检测链里的位置 0:默认(排在CC之后) 1:规则优先(排在黑名单之后、Bot/SQLI/XSS等之前)
//规则优先模式下,规则的放行动作(RF.Allow)才能跳过 Bot/SQLI/XSS/扫描/RCE/目录穿越/CC 这些检测
GCONFIG_RULE_CHAIN_MODE int64 = 0
diff --git a/innerbean/web_log.go b/innerbean/web_log.go
index 6fe3ec1..d88cdbf 100644
--- a/innerbean/web_log.go
+++ b/innerbean/web_log.go
@@ -3,56 +3,61 @@ package innerbean
import "strings"
type WebLog struct {
- WafInnerDFlag string `gorm:"size:10" json:"waf_inner_dflag"` //日志队列处理方式
- HOST string `gorm:"size:255" json:"host"`
- URL string `gorm:"type:text" json:"url"`
- RawQuery string `gorm:"type:text" json:"raw_query"` //原始URL查询
- REFERER string `gorm:"type:text" json:"referer"`
- USER_AGENT string `gorm:"size:500" json:"user_agent"`
- METHOD string `gorm:"size:20" json:"method"`
- HEADER string `gorm:"type:text" json:"header"`
- SRC_IP string `gorm:"size:64" json:"src_ip"`
- SRC_PORT string `gorm:"size:10" json:"src_port"`
- COUNTRY string `gorm:"size:100" json:"country"`
- PROVINCE string `gorm:"size:100" json:"province"`
- CITY string `gorm:"size:100" json:"city"`
- CREATE_TIME string `gorm:"size:32;index:idx_weblog_time" json:"create_time"`
- CONTENT_LENGTH int64 `json:"content_length"`
- RES_CONTENT_LENGTH int64 `json:"res_content_length"` //响应内容大小(字节)
- COOKIES string `gorm:"type:text" json:"cookies"`
- BODY string `gorm:"type:text" json:"body"`
- REQ_UUID string `gorm:"size:64" json:"req_uuid"`
- USER_CODE string `gorm:"size:64;index" json:"user_code"`
- TenantId string `gorm:"size:64;index" json:"tenant_id"` //租户ID(主要键)
- HOST_CODE string `gorm:"size:64" json:"host_code"` //主机ID (主要键)
- Day int `json:"day"` //日 (主要键)
- ACTION string `gorm:"size:100" json:"action"`
- RULE string `gorm:"type:text" json:"rule"`
- STATUS string `gorm:"size:50" json:"status"` //状态
- STATUS_CODE int `json:"status_code"` //状态编码
- RES_BODY string `gorm:"type:text" json:"res_body"` //返回信息
- POST_FORM string `gorm:"type:text" json:"post_form"` //提交的表单数据
- TASK_FLAG int `json:"task_flag" gorm:"default:-1;index"` //任务处理标记 -1 等待处理;1 可以进行处理;2 处理完毕
- 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 `gorm:"column:guest_id_entification;size:191" json:"guest_identification"` //访客身份识别
- IsBot int `json:"is_bot"` //是否是机器人 0 不是机器人 1 机器人
- TimeSpent int64 `json:"time_spent"` //用时
- NetSrcIp string `gorm:"size:64" json:"net_src_ip"` //获取的原始IP
- SrcByteBody []byte `json:"src_byte_body"` //原始body信息
- SrcByteResBody []byte `json:"src_byte_res_body"` //返回body bytes信息
- WebLogVersion int `json:"web_log_version"` //日志版本信息早期的是空和0,后期实时增加
- Scheme string `gorm:"size:20" json:"scheme"` //HTTP 协议
- SrcURL []byte `json:"src_url"` //原始url信息
- PreCheckCost int64 `json:"pre_check_cost"` // 前置检查耗时(ms)
- ForwardCost int64 `json:"forward_cost"` // 转发耗时(ms)
- BackendCheckCost int64 `json:"backend_check_cost"` // 后端处理耗时(ms)
- ResHeader string `gorm:"type:text" json:"res_header"` // 返回header情况
- BodyHash string `gorm:"size:100" json:"body_hash"` // body hash值
- LogOnlyMode int `json:"log_only_mode"` //是否只记录日志 1 是 0 不是
- IsBalance int `json:"is_balance"` //是否是负载均衡 1 是 0 不是
- BalanceInfo string `gorm:"size:255" json:"balance_info"` //负载均衡IP端口信息
- AI_SCORE float64 `json:"ai_score"` //AI检测得分[0,1],0表示未经AI检测或未命中;命中(观察/拦截)时记录实际分数
+ WafInnerDFlag string `gorm:"size:10" json:"waf_inner_dflag"` //日志队列处理方式
+ HOST string `gorm:"size:255" json:"host"`
+ URL string `gorm:"type:text" json:"url"`
+ RawQuery string `gorm:"type:text" json:"raw_query"` //原始URL查询
+ REFERER string `gorm:"type:text" json:"referer"`
+ USER_AGENT string `gorm:"size:500" json:"user_agent"`
+ METHOD string `gorm:"size:20" json:"method"`
+ HEADER string `gorm:"type:text" json:"header"`
+ SRC_IP string `gorm:"size:64" json:"src_ip"`
+ SRC_PORT string `gorm:"size:10" json:"src_port"`
+ COUNTRY string `gorm:"size:100" json:"country"`
+ PROVINCE string `gorm:"size:100" json:"province"`
+ CITY string `gorm:"size:100" json:"city"`
+ CREATE_TIME string `gorm:"size:32;index:idx_weblog_time" json:"create_time"`
+ CONTENT_LENGTH int64 `json:"content_length"`
+ RES_CONTENT_LENGTH int64 `json:"res_content_length"` //响应内容大小(字节)
+ COOKIES string `gorm:"type:text" json:"cookies"`
+ BODY string `gorm:"type:text" json:"body"`
+ REQ_UUID string `gorm:"size:64" json:"req_uuid"`
+ USER_CODE string `gorm:"size:64;index" json:"user_code"`
+ TenantId string `gorm:"size:64;index" json:"tenant_id"` //租户ID(主要键)
+ HOST_CODE string `gorm:"size:64" json:"host_code"` //主机ID (主要键)
+ Day int `json:"day"` //日 (主要键)
+ ACTION string `gorm:"size:100" json:"action"`
+ RULE string `gorm:"type:text" json:"rule"`
+ STATUS string `gorm:"size:50" json:"status"` //状态
+ STATUS_CODE int `json:"status_code"` //状态编码
+ RES_BODY string `gorm:"type:text" json:"res_body"` //返回信息
+ POST_FORM string `gorm:"type:text" json:"post_form"` //提交的表单数据
+ // ── S1 检测归一化副本(gorm:"-" 不落库、json:"-" 不外传,仅供各 check 使用)──
+ BodyDecoded string `gorm:"-" json:"-"` //body 多轮URL解码(RCE 等按整串匹配用)
+ CookiesDecoded string `gorm:"-" json:"-"` //cookie 多轮URL解码
+ BodyValues []string `gorm:"-" json:"-"` //JSON 请求体逐值提取(已解码),供 XSS/SQLi/RCE 逐值检测降误报
+ BodyFields []string `gorm:"-" json:"-"` //与 BodyValues 平行的直接父键名,供“按字段排除”(E)
+ TASK_FLAG int `json:"task_flag" gorm:"default:-1;index"` //任务处理标记 -1 等待处理;1 可以进行处理;2 处理完毕
+ 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 `gorm:"column:guest_id_entification;size:191" json:"guest_identification"` //访客身份识别
+ IsBot int `json:"is_bot"` //是否是机器人 0 不是机器人 1 机器人
+ TimeSpent int64 `json:"time_spent"` //用时
+ NetSrcIp string `gorm:"size:64" json:"net_src_ip"` //获取的原始IP
+ SrcByteBody []byte `json:"src_byte_body"` //原始body信息
+ SrcByteResBody []byte `json:"src_byte_res_body"` //返回body bytes信息
+ WebLogVersion int `json:"web_log_version"` //日志版本信息早期的是空和0,后期实时增加
+ Scheme string `gorm:"size:20" json:"scheme"` //HTTP 协议
+ SrcURL []byte `json:"src_url"` //原始url信息
+ PreCheckCost int64 `json:"pre_check_cost"` // 前置检查耗时(ms)
+ ForwardCost int64 `json:"forward_cost"` // 转发耗时(ms)
+ BackendCheckCost int64 `json:"backend_check_cost"` // 后端处理耗时(ms)
+ ResHeader string `gorm:"type:text" json:"res_header"` // 返回header情况
+ BodyHash string `gorm:"size:100" json:"body_hash"` // body hash值
+ LogOnlyMode int `json:"log_only_mode"` //是否只记录日志 1 是 0 不是
+ IsBalance int `json:"is_balance"` //是否是负载均衡 1 是 0 不是
+ BalanceInfo string `gorm:"size:255" json:"balance_info"` //负载均衡IP端口信息
+ AI_SCORE float64 `json:"ai_score"` //AI检测得分[0,1],0表示未经AI检测或未命中;命中(观察/拦截)时记录实际分数
// GeoUnresolved 本次请求的地区无法判定(没有可用的地区库,或查询失败),
// 区别于"查出来是未知"。为 true 时规则引擎会跳过引用了 COUNTRY/PROVINCE/CITY 的规则,
diff --git a/libinjection-go/scantool.go b/libinjection-go/scantool.go
index 49b9d3d..c5aec84 100644
--- a/libinjection-go/scantool.go
+++ b/libinjection-go/scantool.go
@@ -16,20 +16,49 @@ var scannerSigsAny = []string{
// 像普通英文/科学词的工具名(nuclei/nmap/arachni):只在 UA 里判,避免正常 URL/内容误伤。
var scannerSigsUA = []string{"nuclei", "nmap", "arachni"}
-// IsScan 检测已知扫描/攻击工具。URL+UA 一起查、大小写不敏感。
-// 未纳入 curl/python-requests/Go-http-client 等常见客户端,避免误伤。
+// D2-6:扫描器注入的特定头名/标记(对标 CRS 913110 scanners-headers)。均为扫描器特有、正常
+// 流量不出现,命中即判。acunetix/netsparker 等厂商名已由 scannerSigsAny 覆盖(查全部头)。
+var scannerHeaderMarkers = []string{
+ "x-scan-memo", "x-scanner", "x-wipp", "x-ratproxy-loop", "wvstest", "x-wf-scanner",
+}
+
+// D2-7:扫描器探测路径/文件名指纹(对标 CRS 913120 scanners-urls)。不含工具名、但为已知扫描
+// 探测串。含工具名的探测路径(acunetix-wvs-test 等)已由 scannerSigsAny 覆盖。
+var scannerUrlProbes = []string{
+ "w00tw00t", "muieblackcat", "thereisnowaythat-you-canbe-a-search-engine", "cybercop",
+}
+
+// IsScan 检测已知扫描/攻击工具。工具名查 URL+全部请求头(含 UA、自定义头)、大小写不敏感;
+// 另叠扫描器头名标记(D2-6)与探测路径指纹(D2-7)。未纳入 curl/python-requests/Go-http-client
+// 等常见客户端,避免误伤。
func IsScan(log *innerbean.WebLog) bool {
- urlAndUA := strings.ToLower(log.URL + "\n" + log.USER_AGENT)
+ url := strings.ToLower(log.URL)
+ hdr := strings.ToLower(log.HEADER) // joinHeader 结果,含 UA 与自定义头(扫描器工具名常塞头里)
+ // 工具名:URL + 全部请求头(D2-6 闭合 header 位缺口)
+ hay := url + "\n" + hdr + "\n" + strings.ToLower(log.USER_AGENT)
for _, s := range scannerSigsAny {
- if strings.Contains(urlAndUA, s) {
+ if strings.Contains(hay, s) {
return true
}
}
+ // 易误伤的短工具名(nmap/nuclei/arachni):仅 UA
ua := strings.ToLower(log.USER_AGENT)
for _, s := range scannerSigsUA {
if strings.Contains(ua, s) {
return true
}
}
+ // D2-6 扫描器头名标记
+ for _, s := range scannerHeaderMarkers {
+ if strings.Contains(hdr, s) {
+ return true
+ }
+ }
+ // D2-7 扫描器探测路径指纹
+ for _, s := range scannerUrlProbes {
+ if strings.Contains(url, s) {
+ return true
+ }
+ }
return false
}
diff --git a/libinjection-go/scantool_test.go b/libinjection-go/scantool_test.go
index d359b72..ce398aa 100644
--- a/libinjection-go/scantool_test.go
+++ b/libinjection-go/scantool_test.go
@@ -7,6 +7,38 @@ import (
func mkUA(s string) *innerbean.WebLog { return &innerbean.WebLog{USER_AGENT: s} }
func mkURL(s string) *innerbean.WebLog { return &innerbean.WebLog{URL: s} }
+func mkHdr(s string) *innerbean.WebLog { return &innerbean.WebLog{HEADER: s} }
+
+// D2-6/7:工具名塞头里、扫描器头名标记、探测路径指纹
+func TestIsScan_HeaderAndProbes(t *testing.T) {
+ cases := []*innerbean.WebLog{
+ mkHdr("X-SamWafPoc-Test: sqlmap/1.8.3#stable\r\n"), // D2-6 工具名在自定义头
+ mkHdr("Accept: */*\r\nX-Probe: Mozilla/5.00 (Nikto/2.5.0)\r\n"), // 工具名在任意头
+ mkHdr("X-Scan-Memo: 12345\r\n"), // D2-6 头名标记
+ mkHdr("X-Scanner: probe\r\n"), // D2-6 头名标记
+ mkURL("/w00tw00t.at.blackhats.romanian.anti-sec"), // D2-7 探测路径
+ mkURL("/muieblackcat"), // D2-7 探测路径
+ mkURL("/thereisnowaythat-you-canbe-a-search-engine-really"), // D2-7
+ }
+ for _, c := range cases {
+ if !IsScan(c) {
+ t.Errorf("应判定为扫描器但漏过: URL=%q HEADER=%q", c.URL, c.HEADER)
+ }
+ }
+}
+
+func TestIsScan_HeaderNoFP(t *testing.T) {
+ cases := []*innerbean.WebLog{
+ mkHdr("Referer: https://site.com/s?q=hello\r\nAccept-Language: zh-CN\r\n"),
+ mkHdr("X-Requested-With: XMLHttpRequest\r\nX-Custom-Id: user-123\r\n"),
+ mkURL("/products/w00t-brand-shoes"), // w00t 不是 w00tw00t,不误判
+ }
+ for _, c := range cases {
+ if IsScan(c) {
+ t.Errorf("正常请求被误判为扫描器: URL=%q HEADER=%q", c.URL, c.HEADER)
+ }
+ }
+}
func TestIsScan_Detects(t *testing.T) {
cases := []*innerbean.WebLog{
diff --git a/plugin/init.go b/plugin/init.go
index 5455279..76a0dec 100644
--- a/plugin/init.go
+++ b/plugin/init.go
@@ -33,13 +33,27 @@ func InitPluginSystem() error {
pluginManager := manager.NewPluginManager(systemConfig)
globalobj.GWAF_RUNTIME_OBJ_PLUGIN_MANAGER = pluginManager
- // 3. 如果插件系统未启用,直接返回
+ // 3. 待验证总闸:不加载任何插件二进制,只保留管理器空壳让上层判空逻辑保持一致
+ if manager.PendingVerification {
+ configuredCount := 0
+ for _, pluginConfig := range systemConfig.List {
+ if pluginConfig.Enabled {
+ configuredCount++
+ }
+ }
+ zlog.Warn(manager.PendingVerificationReason,
+ "配置文件中已启用但被跳过的插件数", configuredCount,
+ "恢复条件", "完成准备后,将 manager.PendingVerification 置为 false")
+ return nil
+ }
+
+ // 4. 如果插件系统未启用,直接返回
if !systemConfig.Enabled {
zlog.Info("插件系统未启用,跳过插件加载")
return nil
}
- // 4. 加载每个启用的插件
+ // 5. 加载每个启用的插件
loadedCount := 0
for _, pluginConfig := range systemConfig.List {
if !pluginConfig.Enabled {
diff --git a/plugin/manager/manager.go b/plugin/manager/manager.go
index e87448e..a59cc05 100644
--- a/plugin/manager/manager.go
+++ b/plugin/manager/manager.go
@@ -47,22 +47,34 @@ type PluginManager struct {
healthTicker *time.Ticker // 健康检查定时器
}
+// PendingVerification 插件系统 总闸。
+const PendingVerification = true
+
+// PendingVerificationReason 拒绝加载时对外统一的说明
+const PendingVerificationReason = "插件系统处于待验证阶段:签名与准入控制未完成,已停止加载任何插件"
+
// NewPluginManager 创建插件管理器
func NewPluginManager(config *pluginconfig.PluginSystemConfig) *PluginManager {
if config == nil {
config = pluginconfig.DefaultPluginSystemConfig()
}
+ enabled := config.Enabled
+ if PendingVerification {
+ // 强制关闭:即便配置文件写了 enabled: true 也不生效
+ enabled = false
+ }
+
pm := &PluginManager{
- enabled: config.Enabled,
+ enabled: enabled,
plugins: make(map[string]*PluginInstance),
registry: registry.NewRegistry(),
config: config,
stopChan: make(chan struct{}),
}
- // 如果启用自动重启,启动健康检查
- if config.AutoRestart && config.HealthCheckInterval > 0 {
+ // 如果启用自动重启,启动健康检查(待验证期间没有插件可检,直接不起协程)
+ if !PendingVerification && config.AutoRestart && config.HealthCheckInterval > 0 {
pm.startHealthCheck()
}
@@ -78,6 +90,10 @@ func (pm *PluginManager) IsEnabled() bool {
// SetEnabled 设置插件系统启用状态
func (pm *PluginManager) SetEnabled(enabled bool) {
+ // 待验证期间只允许关,不允许开
+ if PendingVerification && enabled {
+ return
+ }
pm.mu.Lock()
defer pm.mu.Unlock()
pm.enabled = enabled
@@ -85,6 +101,11 @@ func (pm *PluginManager) SetEnabled(enabled bool) {
// LoadPlugin 加载插件
func (pm *PluginManager) LoadPlugin(pluginConfig *pluginconfig.PluginConfig) error {
+ // 总闸:待验证期间拒绝一切加载请求(含配置文件、API、健康检查重启三条入口)
+ if PendingVerification {
+ return fmt.Errorf("%s (plugin_id=%s)", PendingVerificationReason, pluginConfig.ID)
+ }
+
pm.mu.Lock()
defer pm.mu.Unlock()
diff --git a/plugin/manager/pending_verification_test.go b/plugin/manager/pending_verification_test.go
new file mode 100644
index 0000000..318956b
--- /dev/null
+++ b/plugin/manager/pending_verification_test.go
@@ -0,0 +1,68 @@
+package manager
+
+import (
+ "strings"
+ "testing"
+
+ pluginconfig "SamWaf/plugin/config"
+)
+
+// 待验证总闸的护栏用例:只在闸门开启(PendingVerification=true)时生效。
+// 将来 L0+L1 落地把常量置为 false 后,这些用例自动跳过,不会成为绊脚石。
+
+func TestPendingVerification_LoadPluginRefused(t *testing.T) {
+ if !PendingVerification {
+ t.Skip("总闸已放开,跳过")
+ }
+
+ pm := NewPluginManager(&pluginconfig.PluginSystemConfig{Enabled: true})
+ err := pm.LoadPlugin(&pluginconfig.PluginConfig{
+ ID: "any_plugin",
+ BinaryPath: "./data/plugins/binaries/whatever.exe",
+ Enabled: true,
+ })
+ if err == nil {
+ t.Fatal("待验证期间 LoadPlugin 必须拒绝,实际放行了")
+ }
+ if !strings.Contains(err.Error(), "待验证") {
+ t.Fatalf("拒绝原因应指明待验证,实际: %v", err)
+ }
+ if len(pm.GetAllPlugins()) != 0 {
+ t.Fatal("拒绝后不应留下任何插件实例")
+ }
+}
+
+func TestPendingVerification_ConfigEnabledIsOverridden(t *testing.T) {
+ if !PendingVerification {
+ t.Skip("总闸已放开,跳过")
+ }
+
+ // 配置文件写 enabled: true 也必须被强制关掉
+ pm := NewPluginManager(&pluginconfig.PluginSystemConfig{
+ Enabled: true,
+ AutoRestart: true,
+ HealthCheckInterval: 1,
+ })
+ if pm.IsEnabled() {
+ t.Fatal("待验证期间即便配置 enabled:true,管理器也必须处于关闭状态")
+ }
+
+ // SetEnabled 只允许关,不允许开
+ pm.SetEnabled(true)
+ if pm.IsEnabled() {
+ t.Fatal("待验证期间 SetEnabled(true) 不应生效")
+ }
+}
+
+func TestPendingVerification_CheckRequestPassThrough(t *testing.T) {
+ if !PendingVerification {
+ t.Skip("总闸已放开,跳过")
+ }
+
+ // 引擎热路径调用点:必须直接放行,不得拦截、不得 panic
+ pm := NewPluginManager(&pluginconfig.PluginSystemConfig{Enabled: true})
+ isBlock, reason := pm.CheckRequest(nil, "pre_check", "1.2.3.4", "/", "ua", "GET", "example.com")
+ if isBlock {
+ t.Fatalf("待验证期间插件检查必须放行,实际拦截: %s", reason)
+ }
+}
diff --git a/router/waf_plugin_router.go b/router/waf_plugin_router.go
index 755dc18..243df7e 100644
--- a/router/waf_plugin_router.go
+++ b/router/waf_plugin_router.go
@@ -8,6 +8,17 @@ import (
type PluginRouter struct {
}
+// InitPluginRouter 插件管理路由。
+//
+// 【当前状态:故意不注册】wafmangeweb/localserver.go 没有调用本函数,这些接口不可达。
+// 原因:add/modify 会把 JSON 里的 binary_path 直接落库并交给 LoadPlugin 执行,
+// 在签名与准入控制(L0 路径收口 + L1 验签)完成之前接上等于给管理端开一个任意路径 RCE。
+//
+// 接上之前必须满足:
+// 1. manager.PendingVerification 已置为 false(即 L0+L1 已落地);
+// 2. binary_path 改为只能从 binary_dir 已有文件中选取,不允许前端自由填写路径。
+//
+// 详见 SamWafTechDoc/Plan/2026-08-23-插件系统暂停加载-清单与恢复计划.md
func (receiver *PluginRouter) InitPluginRouter(group *gin.RouterGroup) {
apiInstance := api.APIGroupAPP.WafPluginApi
router := group.Group("")
diff --git a/wafdefenserce/defense_rce_tool.go b/wafdefenserce/defense_rce_tool.go
index a101a14..04eacab 100644
--- a/wafdefenserce/defense_rce_tool.go
+++ b/wafdefenserce/defense_rce_tool.go
@@ -23,6 +23,12 @@ var (
reWinSub = regexp.MustCompile(`(?i)(?:\bnet\s+(?:user|localgroup|group|share|use|accounts|view|stop|start|pause|continue)\b|\breg\s+(?:add|query|delete|export|import|save)\b|\bsc\s+(?:create|config|query|start|stop|delete)\b|\bcmd(?:\.exe)?\s*/[ckq]|\bshutdown\s+[/-][a-z])`) // Win 常见词+子命令
reWinDestructive = regexp.MustCompile(`(?i)(?:\b(?:del|erase|rd|rmdir|ren|rename|move|copy|attrib|cipher)\b\s+(?:/[a-z]|[a-z]:\\|\*|[+-][a-z])|\bformat\s+[a-z]:)`) // Win 破坏性命令+参数上下文
rePwsh = regexp.MustCompile(`(?i)(?:powershell(?:\.exe)?\s+-e(?:nc|ncodedcommand)?\b|\binvoke-expression\b|\biex\b\s*\(|\bdownloadstring\b|\bnet\.webclient\b|\binvoke-webrequest\b|\biwr\b\s+https?|\bremove-item\b|\bclear-content\b)`) // PowerShell 执行/下载/删除
+
+ // D1-6 反混淆(对标 OWASP CRS t:cmdLine):$IFS 空格绕过 + 删 \ " ' ^ 后再判,打掉 who^ami / c'a't / ca\t / cat$IFS/etc 变体
+ reIFS = regexp.MustCompile(`(?i)\$\{?ifs\}?(?:\$\d+)?`) // $IFS / ${IFS} / $IFS$9
+ cmdObfCharsDelete = strings.NewReplacer("\\", "", "\"", "", "'", "", "^", "") // 删混淆插入字符
+ reMultiSpace = regexp.MustCompile(`\s+`) // 压缩空白
+ reDistinctCmdWord = regexp.MustCompile(`(?i)\b(?:whoami|uname|hostname|ifconfig|ipconfig|systeminfo|netstat|tasklist|taskkill|powershell|netcat|nslookup|certutil|bitsadmin|wmic|schtasks|vssadmin|bcdedit|diskpart)\b`) // 去混淆后现形的独特命令(非日常英文,误报低)
)
func DetermineRCE(args ...string) (bool, string) {
@@ -40,15 +46,43 @@ func osCmdInjection(args ...string) (bool, string) {
if strings.TrimSpace(arg) == "" {
continue
}
- if reSubstDollar.MatchString(arg) || reSubstBacktick.MatchString(arg) ||
- reMetaCmd.MatchString(arg) || reArgCmd.MatchString(arg) || reBareId.MatchString(arg) ||
- reWinSub.MatchString(arg) || reWinDestructive.MatchString(arg) || rePwsh.MatchString(arg) {
+ if matchRceRegexes(arg) {
return true, "存在OS命令注入"
}
+ // D1-6:仅当含混淆字符时才反混淆后复判,缩小误报面
+ if deobf, hadObf := cmdLineNormalize(arg); hadObf {
+ if matchRceRegexes(deobf) {
+ return true, "存在OS命令注入(反混淆)"
+ }
+ // 独特命令“去混淆后有、原串无” → 混淆凑出的命令名(who^ami),避开 prose 里的裸命令词
+ if reDistinctCmdWord.MatchString(deobf) && !reDistinctCmdWord.MatchString(strings.ToLower(arg)) {
+ return true, "存在OS命令注入(反混淆)"
+ }
+ }
}
return false, "未知"
}
+// matchRceRegexes 跑全部 OS 命令注入正则(原串/反混淆串共用)
+func matchRceRegexes(arg string) bool {
+ return reSubstDollar.MatchString(arg) || reSubstBacktick.MatchString(arg) ||
+ reMetaCmd.MatchString(arg) || reArgCmd.MatchString(arg) || reBareId.MatchString(arg) ||
+ reWinSub.MatchString(arg) || reWinDestructive.MatchString(arg) || rePwsh.MatchString(arg)
+}
+
+// cmdLineNormalize 反混淆:$IFS→空格、删 \ " ' ^、压缩空白、小写。返回(反混淆串, 原串是否含混淆字符)。
+// 不动 ; & | 等分隔符,保留操作符上下文供既有正则复判。
+func cmdLineNormalize(s string) (string, bool) {
+ hadObf := strings.ContainsAny(s, "\\\"'^") || reIFS.MatchString(s)
+ if !hadObf {
+ return s, false
+ }
+ out := reIFS.ReplaceAllString(s, " ")
+ out = cmdObfCharsDelete.Replace(out)
+ out = reMultiSpace.ReplaceAllString(out, " ")
+ return strings.ToLower(out), true
+}
+
func phpRCE(args ...string) (bool, string) {
for _, arg := range args {
if strings.Contains(arg, "phpinfo()") {
diff --git a/wafdefenserce/defense_rce_tool_test.go b/wafdefenserce/defense_rce_tool_test.go
index e0da939..186852f 100644
--- a/wafdefenserce/defense_rce_tool_test.go
+++ b/wafdefenserce/defense_rce_tool_test.go
@@ -52,6 +52,46 @@ func TestOsCmdInjection_NoFalsePositive(t *testing.T) {
}
}
+func TestOsCmdInjection_Deobfuscation(t *testing.T) {
+ // D1-6:混淆变体去混淆后应命中
+ attacks := []string{
+ "who^ami", // 插入 ^(独特命令现形)
+ `who""ami`, // 插入 ""
+ "w'h'o'a'm'i", // 插入 '
+ "c'a't /etc/passwd", // 引号切分 cat + 路径
+ `ca\t /etc/passwd`, // 反斜杠切分
+ "cat$IFS/etc/passwd", // $IFS 空格绕过
+ "cat${IFS}/etc/passwd", // ${IFS}
+ ";u'n'a'm'e -a", // 分隔符 + 混淆命令 + flag
+ "un^ame -a", // caret 切分 uname
+ }
+ for _, a := range attacks {
+ if ok, _ := DetermineRCE(a); !ok {
+ t.Errorf("混淆命令注入应判定但漏过: %q", a)
+ }
+ }
+}
+
+func TestOsCmdInjection_DeobfuscationNoFP(t *testing.T) {
+ // 含 \ " ' ^ / $ 但去混淆后不构成命令注入
+ benign := []string{
+ `C:\Users\test\file.txt`, // Windows 路径含反斜杠
+ `path=C:\Program Files\app`, // 反斜杠路径
+ "O'Reilly's book", // 撇号
+ "use powershell's new api", // powershell 本就是完整词,非混淆凑出
+ "it's a test case", // 撇号
+ "a^2 + b^2 = c^2", // caret 数学式
+ `she said "hello world"`, // 双引号
+ "price is $5 and $9 each", // $ 非 $IFS
+ "won't don't can't", // 撇号缩写
+ }
+ for _, b := range benign {
+ if ok, name := DetermineRCE(b); ok {
+ t.Errorf("正常输入被误判(反混淆): %q (%s)", b, name)
+ }
+ }
+}
+
func TestPhpRCE_StillWorks(t *testing.T) {
if ok, _ := DetermineRCE("a=phpinfo()"); !ok {
t.Error("phpinfo() 应仍被检测")
diff --git a/wafenginecore/body_detect_mode.go b/wafenginecore/body_detect_mode.go
new file mode 100644
index 0000000..2531ac8
--- /dev/null
+++ b/wafenginecore/body_detect_mode.go
@@ -0,0 +1,72 @@
+package wafenginecore
+
+import (
+ "SamWaf/global"
+ "SamWaf/innerbean"
+ "strings"
+ "sync/atomic"
+)
+
+// 请求体深度检测(D3/S1 新增的 formValue/JSON 逐值 XSS/SQLi/RCE)受 GCONFIG_BODY_DETECT_MODE 门控:
+// observe(默认) 命中只在日志标记不拦截;block 命中即拦;off 不做请求体深检。
+// 查询串(RawQuery/POST_FORM)等既有检测不受此开关影响,始终拦截。
+
+// bodyDetectEnabled 请求体深检是否启用(off 时整体跳过 body 逐值检测)
+func bodyDetectEnabled() bool {
+ return global.GCONFIG_BODY_DETECT_MODE != "off"
+}
+
+// bodyDetectBlocking 是否为拦截模式(block);否则为 observe 观察
+func bodyDetectBlocking() bool {
+ return global.GCONFIG_BODY_DETECT_MODE == "block"
+}
+
+// markBodyObserve 观察模式命中:只在日志打观察标记(不置 IsBlock),abnormal 日志模式下会因
+// RULE 非空被落库,便于事后筛查(RULE 以“观察模式:”开头,status 非 403)确认无误后再切 block。
+func markBodyObserve(weblogbean *innerbean.WebLog, title string) {
+ weblogbean.RULE = "观察模式:疑似" + title
+}
+
+// ── 按字段排除(E):已知携带富文本/HTML/代码的字段名,其请求体值跳过深检 ──
+
+const maxBodyDetectFieldExclude = 200 // 排除项数量上限,防误配超长清单
+
+var bodyFieldExclude atomic.Pointer[map[string]struct{}] // RCU:构建完只读,读侧无锁
+
+// SetBodyDetectFieldExclude 解析逗号分隔的字段名(小写去空、去重、封顶)为排除集并原子发布。
+// 由配置加载(task_config)调用。
+func SetBodyDetectFieldExclude(csv string) {
+ m := make(map[string]struct{})
+ for _, f := range strings.Split(csv, ",") {
+ f = strings.ToLower(strings.TrimSpace(f))
+ if f == "" {
+ continue
+ }
+ m[f] = struct{}{}
+ if len(m) >= maxBodyDetectFieldExclude {
+ break
+ }
+ }
+ bodyFieldExclude.Store(&m)
+}
+
+// fieldAt 安全取 fields[i],越界(如测试只填了 BodyValues 未填 BodyFields)返回 ""。
+func fieldAt(fields []string, i int) string {
+ if i >= 0 && i < len(fields) {
+ return fields[i]
+ }
+ return ""
+}
+
+// isBodyFieldExcluded 字段名是否在排除集(不区分大小写)。空字段名(顶层数组元素)不排除。
+func isBodyFieldExcluded(field string) bool {
+ if field == "" {
+ return false
+ }
+ m := bodyFieldExclude.Load()
+ if m == nil || len(*m) == 0 {
+ return false
+ }
+ _, ok := (*m)[strings.ToLower(field)]
+ return ok
+}
diff --git a/wafenginecore/check_dir_traversal.go b/wafenginecore/check_dir_traversal.go
index 087c7a1..211da88 100644
--- a/wafenginecore/check_dir_traversal.go
+++ b/wafenginecore/check_dir_traversal.go
@@ -2,14 +2,15 @@ package wafenginecore
import (
"SamWaf/innerbean"
- "SamWaf/libinjection-go"
"SamWaf/model/detection"
"SamWaf/model/wafenginmodel"
+ "SamWaf/wafenginecore/wafhttpcore"
"net/http"
"net/url"
)
-// CheckDirTraversal 穿越漏洞检测
+// CheckDirTraversal 目录穿越 / 绝对路径 LFI 检测(D4+D6)。
+// 归一化后判"逃出根"而非字面 ../,并叠敏感文件表接住裸绝对路径;站内相对路径不误拦。
func (waf *WafEngine) CheckDirTraversal(r *http.Request, weblogbean *innerbean.WebLog, formValue url.Values, hostTarget *wafenginmodel.HostSafe, globalHostTarget *wafenginmodel.HostSafe) detection.Result {
result := detection.Result{
JumpGuardResult: false,
@@ -17,27 +18,63 @@ func (waf *WafEngine) CheckDirTraversal(r *http.Request, weblogbean *innerbean.W
Title: "",
Content: "",
}
- var flag = false
- //检测sql注入
- if libinjection.HasDirTraversal(weblogbean.RawQuery) ||
- libinjection.HasDirTraversal(weblogbean.BODY) {
- flag = true
- }
- if flag == false {
- for _, value := range formValue {
- for _, v := range value {
- if libinjection.HasDirTraversal(v) {
- flag = true
- }
- }
- }
- }
- if flag == true {
+ block := func() detection.Result {
weblogbean.RISK_LEVEL = 2
result.IsBlock = true
result.Title = "目录穿越漏洞"
result.Content = "请正确访问"
return result
}
+
+ // 1) 始终检测:URL 路径 + 查询值(逃根判定+敏感文件,低误报,非请求体不受开关影响)
+ if wafhttpcore.HasTraversalOrLFI(r.URL.EscapedPath()) {
+ return block()
+ }
+ for _, values := range r.URL.Query() {
+ for _, v := range values {
+ if wafhttpcore.HasTraversalOrLFI(v) {
+ return block()
+ }
+ }
+ }
+
+ // 2) 请求体深检(表单值/JSON 体/整串):受 body_detect_mode 门控(observe 默认只观察不拦),与 CheckXss 一致
+ if bodyDetectEnabled() {
+ hit := wafhttpcore.HasTraversalOrLFI(weblogbean.BodyDecoded)
+ if !hit {
+ for key, values := range formValue {
+ if isBodyFieldExcluded(key) { // E:已知富文本字段整体跳过
+ continue
+ }
+ for _, v := range values {
+ if wafhttpcore.HasTraversalOrLFI(v) {
+ hit = true
+ break
+ }
+ }
+ if hit {
+ break
+ }
+ }
+ }
+ if !hit {
+ for i, v := range weblogbean.BodyValues {
+ if isBodyFieldExcluded(fieldAt(weblogbean.BodyFields, i)) { // E
+ continue
+ }
+ if wafhttpcore.HasTraversalOrLFI(v) {
+ hit = true
+ break
+ }
+ }
+ }
+ if hit {
+ if bodyDetectBlocking() {
+ return block()
+ }
+ markBodyObserve(weblogbean, "目录穿越/LFI")
+ }
+ }
+
return result
}
diff --git a/wafenginecore/checkrce.go b/wafenginecore/checkrce.go
index 6997d1c..790d511 100644
--- a/wafenginecore/checkrce.go
+++ b/wafenginecore/checkrce.go
@@ -20,10 +20,14 @@ func (waf *WafEngine) CheckRce(r *http.Request, weblogbean *innerbean.WebLog, fo
Title: "",
Content: "",
}
- // 查询串已多轮解码;再逐个查已解码的表单值
- isRce, RceName := wafdefenserce.DetermineRCE(weblogbean.RawQuery, weblogbean.URL, weblogbean.COOKIES, weblogbean.BODY)
+ // 既有检测(查询串多轮解码 + 原始 cookie/body + 逐个表单值):始终强制拦截
+ isRce, RceName := wafdefenserce.DetermineRCE(weblogbean.RawQuery, weblogbean.URL,
+ weblogbean.COOKIES, weblogbean.BODY)
if isRce == false {
- for _, values := range formValue {
+ for key, values := range formValue {
+ if isBodyFieldExcluded(key) { // E
+ continue
+ }
for _, v := range values {
if ok, name := wafdefenserce.DetermineRCE(v); ok {
isRce, RceName = ok, name
@@ -42,5 +46,33 @@ func (waf *WafEngine) CheckRce(r *http.Request, weblogbean *innerbean.WebLog, fo
result.Content = "请正确访问"
return result
}
+ // 请求体深度检测(S1 新增:cookie/body 解码副本 + JSON 逐值):受 body_detect_mode 门控
+ if bodyDetectEnabled() {
+ hit, name := wafdefenserce.DetermineRCE(weblogbean.CookiesDecoded, weblogbean.BodyDecoded)
+ if hit == false {
+ for i, v := range weblogbean.BodyValues {
+ if isBodyFieldExcluded(fieldAt(weblogbean.BodyFields, i)) { // E
+ continue
+ }
+ if ok, n := wafdefenserce.DetermineRCE(v); ok {
+ hit, name = ok, n
+ break
+ }
+ }
+ }
+ if hit == false { // 头位命令注入(S1-4)
+ hit, name = headerRCEHit(r)
+ }
+ if hit {
+ if bodyDetectBlocking() {
+ weblogbean.RISK_LEVEL = 3
+ result.IsBlock = true
+ result.Title = "RCE:" + name
+ result.Content = "请正确访问"
+ return result
+ }
+ markBodyObserve(weblogbean, "RCE:"+name)
+ }
+ }
return result
}
diff --git a/wafenginecore/checksql.go b/wafenginecore/checksql.go
index 08273bd..d46621d 100644
--- a/wafenginecore/checksql.go
+++ b/wafenginecore/checksql.go
@@ -5,6 +5,7 @@ import (
"SamWaf/libinjection-go"
"SamWaf/model/detection"
"SamWaf/model/wafenginmodel"
+ "SamWaf/wafenginecore/wafhttpcore"
"net/http"
"net/url"
)
@@ -21,15 +22,33 @@ func (waf *WafEngine) CheckSql(r *http.Request, weblogbean *innerbean.WebLog, fo
Content: "",
}
var sqlFlag = false
- //检测sql注入
+ //检测sql注入(RawQuery 已多轮解码)。JSON 体不整块扫,改走下方逐值——整块含大量结构
+ //字符会把正常数字/结构 JSON 误判为注入。
+ scanBody := !wafhttpcore.IsJSONBody(weblogbean.BODY)
+ // D5-1:libinjection 之外补 MSSQL/DB 高危关键字兜底(xp_cmdshell/sp_configure/
+ // bcp queryout/INSERT VALUES(0x..) 等,接住纯 DDL/配置语句这类无注入指纹的漏检)
if libinjection.IsSQLiNotReturnPrint(weblogbean.RawQuery) ||
- libinjection.IsSQLiNotReturnPrint(weblogbean.BODY) ||
- libinjection.IsSQLiNotReturnPrint(weblogbean.POST_FORM) {
+ (scanBody && libinjection.IsSQLiNotReturnPrint(weblogbean.BODY)) ||
+ libinjection.IsSQLiNotReturnPrint(weblogbean.POST_FORM) ||
+ wafhttpcore.HasHighRiskSQLKeyword(weblogbean.RawQuery) ||
+ (scanBody && wafhttpcore.HasHighRiskSQLKeyword(weblogbean.BODY)) ||
+ wafhttpcore.HasHighRiskSQLKeyword(weblogbean.POST_FORM) {
sqlFlag = true
}
if sqlFlag == false {
- for _, value := range formValue {
+ for key, value := range formValue {
+ if isBodyFieldExcluded(key) { // E:已知富文本字段整体跳过
+ continue
+ }
for _, v := range value {
+ if wafhttpcore.HasHighRiskSQLKeyword(v) { // 高危关键字:不跳过结构化
+ sqlFlag = true
+ continue
+ }
+ // 跳过结构化数据值(嵌套 JSON/回调 URL):正常埋点/日志会误报,且不构成 SQLi
+ if wafhttpcore.IsStructuredDataValue(v) {
+ continue
+ }
if libinjection.IsSQLiNotReturnPrint(v) {
sqlFlag = true
}
@@ -37,11 +56,45 @@ func (waf *WafEngine) CheckSql(r *http.Request, weblogbean *innerbean.WebLog, fo
}
}
if sqlFlag == true {
+ // 查询串/表单串等既有检测:始终强制拦截
weblogbean.RISK_LEVEL = 2
result.IsBlock = true
result.Title = "SQL注入"
result.Content = "请正确访问"
return result
}
+ // JSON 请求体逐值检测(S1 新增):受 body_detect_mode 门控 + 字段排除(E) + 跳过结构化数据值
+ if bodyDetectEnabled() {
+ hit := false
+ for i, v := range weblogbean.BodyValues {
+ if isBodyFieldExcluded(fieldAt(weblogbean.BodyFields, i)) {
+ continue
+ }
+ if wafhttpcore.HasHighRiskSQLKeyword(v) { // 高危关键字:不跳过结构化
+ hit = true
+ break
+ }
+ if wafhttpcore.IsStructuredDataValue(v) {
+ continue
+ }
+ if libinjection.IsSQLiNotReturnPrint(v) {
+ hit = true
+ break
+ }
+ }
+ if !hit { // 头位 SQLi(S1-4)
+ hit = headerSQLHit(r)
+ }
+ if hit {
+ if bodyDetectBlocking() {
+ weblogbean.RISK_LEVEL = 2
+ result.IsBlock = true
+ result.Title = "SQL注入"
+ result.Content = "请正确访问"
+ return result
+ }
+ markBodyObserve(weblogbean, "SQL注入")
+ }
+ }
return result
}
diff --git a/wafenginecore/checksql_test.go b/wafenginecore/checksql_test.go
new file mode 100644
index 0000000..1b9e767
--- /dev/null
+++ b/wafenginecore/checksql_test.go
@@ -0,0 +1,77 @@
+package wafenginecore
+
+import (
+ "SamWaf/common/zlog"
+ "SamWaf/global"
+ "SamWaf/innerbean"
+ "SamWaf/model"
+ "SamWaf/model/wafenginmodel"
+ "SamWaf/wafenginecore/wafhttpcore"
+ "net/http"
+ "net/url"
+ "testing"
+)
+
+func newTestWafForSQL() (*WafEngine, *wafenginmodel.HostSafe, *wafenginmodel.HostSafe) {
+ zlog.InitZLog(global.GWAF_LOG_DEBUG_ENABLE, "json")
+ waf := &WafEngine{}
+ waf.InitRouting()
+ global.GWAF_GLOBAL_HOST_NAME = "全局网站"
+ waf.rt().HostTarget[global.GWAF_GLOBAL_HOST_NAME] = &wafenginmodel.HostSafe{Host: model.Hosts{GUARD_STATUS: 1}}
+ hostSafe := &wafenginmodel.HostSafe{Host: model.Hosts{GUARD_STATUS: 1}}
+ return waf, hostSafe, waf.rt().HostTarget[global.GWAF_GLOBAL_HOST_NAME]
+}
+
+// TestCheckSqlBodyFalsePositives 用 blaze 全量实测抓到的 7 条正常流量误报样本,
+// 确认收敛后(JSON 体走逐值不整块扫 + 跳过结构化数据值 + JSON 值不再叠 URL 解码)不再误拦。
+func TestCheckSqlBodyFalsePositives(t *testing.T) {
+ waf, hostSafe, globalHost := newTestWafForSQL()
+ global.GCONFIG_BODY_DETECT_MODE = "block" // 测请求体拦截逻辑(默认 observe 只记录不拦)
+ defer func() { global.GCONFIG_BODY_DETECT_MODE = "observe" }()
+
+ // 均为真实正常业务体(遥测/日志上报/埋点/URL编码JSON),不应被 SQLi 拦
+ benignBodies := []struct {
+ name string
+ body string
+ }{
+ {"数字遥测JSON", `{"Seq":9,"When":162400,"Evts":[{"Kind":9,"Args":[162417,1573,1553,-20,-20,163,174,11,11,132]}]}`},
+ {"日志上报嵌套JSON", `{"level":["2","2"],"msg":["[\"[Capi Success]\",{\"body\":{\"serviceType\":\"ba\",\"regionId\":1}}]"]}`},
+ {"埋点含回调URL", `{"Seq":5,"Evts":[{"Kind":63,"Args":["POST","https://rs.fullstory.com/rec/bundle/v2?OrgId=B9193&UserId=4b2b7c84-aa5f-44aa-b927-6f55e6eb3095"]}]}`},
+ {"URL编码的JSON数组", `["%7B%22val_nm%22%3A%226%22%2C%22val_act%22%3A%22exposure_yw%22%7D"]`},
+ }
+ for _, b := range benignBodies {
+ t.Run("benign/"+b.name, func(t *testing.T) {
+ req, _ := http.NewRequest("POST", "http://example.com/api", nil)
+ weblog := &innerbean.WebLog{
+ BODY: b.body,
+ BodyValues: wafhttpcore.ExtractJSONStringValues(b.body),
+ }
+ res := waf.CheckSql(req, weblog, url.Values{}, hostSafe, globalHost)
+ if res.IsBlock {
+ t.Errorf("正常体被误拦为 SQLi: %s", b.body)
+ }
+ })
+ }
+
+ // 真实 SQLi 仍必须拦(防止收敛过度)
+ attacks := []struct {
+ name string
+ body string
+ }{
+ {"JSON值内SQLi", `{"q":"1' UNION SELECT username,password FROM users--"}`},
+ {"JSON值内OR注入", `{"user":"admin' or '1'='1"}`},
+ }
+ for _, a := range attacks {
+ t.Run("attack/"+a.name, func(t *testing.T) {
+ req, _ := http.NewRequest("POST", "http://example.com/api", nil)
+ weblog := &innerbean.WebLog{
+ BODY: a.body,
+ BodyValues: wafhttpcore.ExtractJSONStringValues(a.body),
+ }
+ res := waf.CheckSql(req, weblog, url.Values{}, hostSafe, globalHost)
+ if !res.IsBlock {
+ t.Errorf("真实 SQLi 漏拦: %s", a.body)
+ }
+ })
+ }
+}
diff --git a/wafenginecore/checkxss.go b/wafenginecore/checkxss.go
index 97611b8..d8fba18 100644
--- a/wafenginecore/checkxss.go
+++ b/wafenginecore/checkxss.go
@@ -5,10 +5,17 @@ import (
"SamWaf/libinjection-go"
"SamWaf/model/detection"
"SamWaf/model/wafenginmodel"
+ "SamWaf/wafenginecore/wafhttpcore"
"net/http"
"net/url"
+ "regexp"
)
+// reXSSExecSignal 可执行 XSS 上下文信号:真标签开(<字母/!//)、事件处理器 on\w+=、js/data 伪协议。
+// 用于给请求体逐值 XSS 加“第二信号”(D):libinjection 命中 + 含执行信号才判,避免散文里孤立的
+// < 或引号(如 JS 错误上报 token '<')被 libinjection 单信号误判。
+var reXSSExecSignal = regexp.MustCompile(`(?i)<[a-z!/]|on\w+\s*=|javascript:|srcdoc\s*=|data:text/html`)
+
/*
*
检测xss
@@ -20,27 +27,72 @@ func (waf *WafEngine) CheckXss(r *http.Request, weblogbean *innerbean.WebLog, fo
Title: "",
Content: "",
}
- var xssFlag = false
- // 使用逐参数值检测,避免参数名(如 style、href、filter 等)被 libinjection 误当 HTML 属性导致误报
- if libinjection.IsXSSInQueryValues(weblogbean.RawQuery) ||
- libinjection.IsXSSInQueryValues(weblogbean.POST_FORM) {
- xssFlag = true
- }
- if xssFlag == false {
- for _, value := range formValue {
- for _, v := range value {
- // 注意:此处暂时不启用,formValue 中的值在某些业务场景下存在误报风险。
- // 若需启用,使用 libinjection.IsXSSSingleValue(v) 以降低误报率(预过滤+逐值检测)。
- _ = v
+ // 查询串/表单串检测:始终强制拦截(非本次请求体误报来源)
+ xssHit := libinjection.IsXSSInQueryValues(weblogbean.RawQuery) ||
+ libinjection.IsXSSInQueryValues(weblogbean.POST_FORM)
+ // S1 二期:查询 GET 值叠 HTML 实体/unicode 解码后"多信号"判,补 htmlent(<)/unicode_js(<)
+ // 编码的 XSS。多信号(真标签/事件)+结构化跳过保证零新增误报(blaze 33k 白样本净新增 0)。
+ if !xssHit {
+ for _, values := range r.URL.Query() {
+ for _, v := range values {
+ if bodyValueIsXSS(v) {
+ xssHit = true
+ break
+ }
+ }
+ if xssHit {
+ break
}
}
}
- if xssFlag == true {
+ if xssHit {
weblogbean.RISK_LEVEL = 2
result.IsBlock = true
result.Title = "XSS跨站注入"
result.Content = "请正确访问"
return result
}
+ // 请求体+头部深度检测(formValue/JSON 逐值 + 自定义头 S1-4):受 body_detect_mode 门控 + 结构化跳过(C) + 多信号(D) + 字段排除(E)
+ if bodyDetectEnabled() && (waf.bodyXSSHit(formValue, weblogbean.BodyFields, weblogbean.BodyValues) || headerXSSHit(r)) {
+ if bodyDetectBlocking() {
+ weblogbean.RISK_LEVEL = 2
+ result.IsBlock = true
+ result.Title = "XSS跨站注入"
+ result.Content = "请正确访问"
+ return result
+ }
+ markBodyObserve(weblogbean, "XSS跨站注入")
+ }
return result
}
+
+// bodyXSSHit 请求体逐值 XSS:按字段排除(E) + 跳过结构化数据值(C) + libinjection 命中且含执行信号(D)
+func (waf *WafEngine) bodyXSSHit(formValue url.Values, bodyFields, bodyValues []string) bool {
+ for key, vals := range formValue {
+ if isBodyFieldExcluded(key) { // E:已知富文本字段整体跳过
+ continue
+ }
+ for _, v := range vals {
+ if bodyValueIsXSS(v) {
+ return true
+ }
+ }
+ }
+ for i, v := range bodyValues {
+ if isBodyFieldExcluded(fieldAt(bodyFields, i)) { // E
+ continue
+ }
+ if bodyValueIsXSS(v) {
+ return true
+ }
+ }
+ return false
+}
+
+func bodyValueIsXSS(v string) bool {
+ dv := wafhttpcore.NormalizeForXSSDetection(v) // S1 二期:叠 HTML 实体/unicode 解码
+ if wafhttpcore.IsStructuredDataValue(dv) { // C:结构化数据(嵌套JSON/URL)不判 XSS
+ return false
+ }
+ return libinjection.IsXSSSingleValue(dv) && reXSSExecSignal.MatchString(dv) // D:多信号
+}
diff --git a/wafenginecore/checkxss_test.go b/wafenginecore/checkxss_test.go
index e4fa137..0e1fb8d 100644
--- a/wafenginecore/checkxss_test.go
+++ b/wafenginecore/checkxss_test.go
@@ -5,6 +5,7 @@ import (
"SamWaf/global"
"SamWaf/innerbean"
"SamWaf/model"
+ "SamWaf/model/detection"
"SamWaf/model/wafenginmodel"
"net/http"
"net/url"
@@ -28,11 +29,14 @@ func newTestWafForXSS() (*WafEngine, *wafenginmodel.HostSafe) {
func TestCheckXss(t *testing.T) {
waf, hostSafe := newTestWafForXSS()
globalHost := waf.rt().HostTarget[global.GWAF_GLOBAL_HOST_NAME]
+ global.GCONFIG_BODY_DETECT_MODE = "block" // 测请求体拦截逻辑(默认 observe 只记录不拦)
+ defer func() { global.GCONFIG_BODY_DETECT_MODE = "observe" }()
tests := []struct {
name string
rawQuery string
postForm string
+ bodyValues []string
formValues url.Values
expectBlock bool
desc string
@@ -91,13 +95,35 @@ func TestCheckXss(t *testing.T) {
desc: "POST 表单参数值含 "},
},
+ expectBlock: true,
+ desc: "formValue 逐值检测已启用,值含 "},
+ expectBlock: true,
+ desc: "JSON 请求体字符串值含 "}
+
+ if r := run("observe", xss); r.IsBlock {
+ t.Error("observe 模式不应拦截 body XSS")
+ }
+ if r := run("block", xss); !r.IsBlock {
+ t.Error("block 模式应拦截 body XSS")
+ }
+ if r := run("off", xss); r.IsBlock {
+ t.Error("off 模式不应做 body 检测")
+ }
+ // observe 模式命中要在 RULE 打观察标记(供 abnormal 日志落库复查)
+ global.GCONFIG_BODY_DETECT_MODE = "observe"
+ req, _ := http.NewRequest("POST", "http://example.com/api", nil)
+ wl := &innerbean.WebLog{BodyValues: xss}
+ waf.CheckXss(req, wl, url.Values{}, hostSafe, globalHost)
+ if wl.RULE == "" {
+ t.Error("observe 命中应在 RULE 打观察标记")
+ }
+}
+
+// TestCheckXssBodyMultiSignal 验证多信号(D):block 模式下,散文里孤立的 < / 引号(无真标签/事件)
+// 不被误拦,而真标签/事件属性的 XSS 仍拦。
+func TestCheckXssBodyMultiSignal(t *testing.T) {
+ waf, hostSafe := newTestWafForXSS()
+ globalHost := waf.rt().HostTarget[global.GWAF_GLOBAL_HOST_NAME]
+ global.GCONFIG_BODY_DETECT_MODE = "block"
+ defer func() { global.GCONFIG_BODY_DETECT_MODE = "observe" }()
+
+ // 正常散文/表达式:无真标签、无事件 → 不拦
+ benign := []string{
+ "Uncaught SyntaxError: Unexpected token '<'",
+ "a < b && c > d",
+ "价格 < 100 元",
+ }
+ for _, v := range benign {
+ req, _ := http.NewRequest("POST", "http://example.com/api", nil)
+ wl := &innerbean.WebLog{BodyValues: []string{v}}
+ if r := waf.CheckXss(req, wl, url.Values{}, hostSafe, globalHost); r.IsBlock {
+ t.Errorf("散文不应被误拦: %q", v)
+ }
+ }
+ // 真 XSS:真标签 / 事件属性 / 伪协议 → 拦
+ attacks := []string{
+ "",
+ `">
`,
+ "