feat: strengthen WAF detection coverage and gate the new inspection paths

Detection engine:
- Normalize request data (body, cookies, form and JSON values, request headers)
  before inspection instead of only the URL query string, so encoded variants
  are evaluated consistently.
- Extend XSS / SQLi / command-injection inspection to request bodies and to
  custom request headers; bodies and headers are scanned per value rather than
  as one blob to keep false positives low.
- Add a configurable body inspection mode (body_detect_mode: observe/block/off,
  default observe) so the new deep checks record rather than block until an
  operator confirms them, plus body_detect_field_exclude to skip fields that
  legitimately carry rich text.
- Keep false positives down on the new paths: skip structured values, require a
  corroborating signal before flagging body/header XSS, and leave dual-use
  keywords out of the fallback lists.
- Path traversal is now normalized and judged on whether it escapes the site
  root instead of matching literally; this also clears a long-standing false
  positive on legitimate in-site relative paths. Adds a sensitive-file list.
- Scanner detection now covers all request headers and known probe paths.

Plugins:
- Suspend plugin loading pending signing and admission work. The code path is
  retained but gated off, and shipped disabled by default.

Validated against a local test corpus and a 33k real-traffic benchmark:
detection improved across every category while the real-traffic false-positive
count stayed flat.
This commit is contained in:
samwaf
2026-08-23 11:29:22 +08:00
parent 8c9e2c12c4
commit 24227923e8
29 changed files with 1609 additions and 107 deletions
+1
View File
@@ -3,6 +3,7 @@
/vue/dist/
/conf/*
/data/*
/wafenginecore/data/
/data/local_log.db
/data/local_stats.db
/data/local.db
+9 -3
View File
@@ -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
+11
View File
@@ -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
+55 -50
View File
@@ -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 的规则,
+33 -4
View File
@@ -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
}
+32
View File
@@ -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{
+16 -2
View File
@@ -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 {
+24 -3
View File
@@ -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()
@@ -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)
}
}
+11
View File
@@ -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("")
+37 -3
View File
@@ -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()") {
+40
View File
@@ -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() 应仍被检测")
+72
View File
@@ -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
}
+55 -18
View File
@@ -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
}
+35 -3
View File
@@ -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
}
+57 -4
View File
@@ -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
}
+77
View File
@@ -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)
}
})
}
}
+65 -13
View File
@@ -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(&lt;)/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:多信号
}
+141 -4
View File
@@ -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 表单参数值含 <script> 应被拦截",
},
{
name: "formValues检测暂未启用-不拦截",
name: "D3-formValue中的XSS-现已启用检测",
rawQuery: "page=1",
formValues: url.Values{
"input": []string{"<script>alert(1)</script>"},
},
expectBlock: true,
desc: "formValue 逐值检测已启用,值含 <script> 应被拦截",
},
{
name: "D3-formValue正常值-不误拦",
rawQuery: "page=1",
formValues: url.Values{
"nickname": []string{"hello world"},
"remark": []string{"价格 100 元"},
},
expectBlock: false,
desc: "formValue 检测当前暂时禁用(业务存在误报风险),不触发拦截",
desc: "普通表单值不应被误拦",
},
{
name: "D3-JSON请求体值含XSS",
bodyValues: []string{"<script>alert(document.cookie)</script>"},
expectBlock: true,
desc: "JSON 请求体字符串值含 <script> 应被拦截(逐值 BodyValues)",
},
{
name: "D3-JSON请求体正常字符串值-不误拦",
bodyValues: []string{"weekly report", "订单已发货"},
expectBlock: false,
desc: "正常 JSON 字符串值不应被误拦",
},
{
name: "真实XSS-svg在值中",
@@ -111,8 +137,9 @@ func TestCheckXss(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
req, _ := http.NewRequest("GET", "http://example.com/path?"+tt.rawQuery, nil)
weblog := &innerbean.WebLog{
RawQuery: tt.rawQuery,
POST_FORM: tt.postForm,
RawQuery: tt.rawQuery,
POST_FORM: tt.postForm,
BodyValues: tt.bodyValues,
}
formValues := tt.formValues
if formValues == nil {
@@ -136,3 +163,113 @@ func TestCheckXss(t *testing.T) {
})
}
}
// TestCheckXssBodyMode 验证请求体 XSS 的三档模式:observe 只记录不拦、block 拦、off 不检测。
func TestCheckXssBodyMode(t *testing.T) {
waf, hostSafe := newTestWafForXSS()
globalHost := waf.rt().HostTarget[global.GWAF_GLOBAL_HOST_NAME]
defer func() { global.GCONFIG_BODY_DETECT_MODE = "observe" }()
run := func(mode string, bodyValues []string) detection.Result {
global.GCONFIG_BODY_DETECT_MODE = mode
req, _ := http.NewRequest("POST", "http://example.com/api", nil)
weblog := &innerbean.WebLog{BodyValues: bodyValues}
return waf.CheckXss(req, weblog, url.Values{}, hostSafe, globalHost)
}
xss := []string{"<script>alert(1)</script>"}
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{
"<script>alert(1)</script>",
`"><img src=x onerror=alert(1)>`,
"<svg/onload=alert(1)>",
}
for _, v := range attacks {
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("真 XSS 漏拦: %q", v)
}
}
}
// TestCheckXssBodyFieldExclude 验证按字段排除(E):排除的字段(如 html/content)里的 XSS 不拦,
// 非排除字段的 XSS 仍拦;JSON(按父键名)与表单(按字段名)都生效。
func TestCheckXssBodyFieldExclude(t *testing.T) {
waf, hostSafe := newTestWafForXSS()
globalHost := waf.rt().HostTarget[global.GWAF_GLOBAL_HOST_NAME]
global.GCONFIG_BODY_DETECT_MODE = "block"
SetBodyDetectFieldExclude("html, content") // 大小写不敏感、去空
defer func() {
global.GCONFIG_BODY_DETECT_MODE = "observe"
SetBodyDetectFieldExclude("")
}()
xss := "<script>alert(1)</script>"
check := func(fields, values []string, fv url.Values) bool {
req, _ := http.NewRequest("POST", "http://example.com/api", nil)
wl := &innerbean.WebLog{BodyFields: fields, BodyValues: values}
if fv == nil {
fv = url.Values{}
}
return waf.CheckXss(req, wl, fv, hostSafe, globalHost).IsBlock
}
// JSON:排除字段 html → 不拦;非排除字段 comment → 拦
if check([]string{"html"}, []string{xss}, nil) {
t.Error("排除字段 html 的 JSON XSS 不应拦")
}
if check([]string{"HTML"}, []string{xss}, nil) { // 大小写不敏感
t.Error("排除字段大小写不敏感应生效")
}
if !check([]string{"comment"}, []string{xss}, nil) {
t.Error("非排除字段 comment 的 JSON XSS 应拦")
}
// 表单:排除字段 content → 不拦;非排除字段 msg → 拦
if check(nil, nil, url.Values{"content": []string{xss}}) {
t.Error("排除的表单字段 content 的 XSS 不应拦")
}
if !check(nil, nil, url.Values{"msg": []string{xss}}) {
t.Error("非排除的表单字段 msg 的 XSS 应拦")
}
}
+98
View File
@@ -0,0 +1,98 @@
package wafenginecore
import (
"SamWaf/libinjection-go"
"SamWaf/wafdefenserce"
"SamWaf/wafenginecore/wafhttpcore"
"net/http"
"strings"
)
// 头位注入检测(S1-4):自定义/非标准请求头也可能藏 XSS/SQLi/命令注入(如 X-Forwarded-For、
// 各类 X-* 自定义头)。受 body_detect_mode 门控(observe 默认只观察)。为压误报,跳过所有
// 标准/浏览器头——这些头合法值复杂(Referer 带 URL、Accept 带通配、UA 带括号等),经 blaze
// 33k 白样本实测:跳过下列头 + sec-*/waf_ 前缀后,剩余自定义头值命中检测 = 0 条。
var headerDetectSkip = map[string]struct{}{}
func init() {
for _, h := range []string{
"host", "user-agent", "accept", "accept-encoding", "accept-language",
"accept-charset", "accept-datetime", "referer", "origin", "connection",
"content-type", "content-length", "content-disposition", "content-encoding",
"cache-control", "pragma", "upgrade-insecure-requests", "dnt", "cookie",
"authorization", "proxy-authorization", "if-none-match", "if-modified-since",
"if-match", "if-unmodified-since", "if-range", "range", "te", "trailer",
"transfer-encoding", "expect", "date", "keep-alive",
"x-requested-with", "x-client-data", "x-forwarded-proto",
"x-forwarded-port", "cf-ray", "cf-ipcountry", "cf-visitor",
"priority", "purpose", "x-purpose", "save-data", "device-memory",
"downlink", "ect", "rtt", "viewport-width", "width", "content-md5",
"max-forwards", "from", "warning", "cookie2", "x-csrf-token", "x-xsrf-token",
} {
headerDetectSkip[h] = struct{}{}
}
}
// skipDetectHeader 报告该头是否跳过注入检测(标准/浏览器头 + sec-*/waf_ 前缀)。
func skipDetectHeader(name string) bool {
n := strings.ToLower(name)
if _, ok := headerDetectSkip[n]; ok {
return true
}
return strings.HasPrefix(n, "sec-") || strings.HasPrefix(n, "waf_")
}
// detectableHeaderValues 返回需做注入检测的头的(多轮 URL 解码后)值。UA 已由 D2 扫描器覆盖、
// Cookie 由各 check 单独走 COOKIES,故不在此重复。
func detectableHeaderValues(r *http.Request) []string {
if r == nil {
return nil
}
var out []string
for name, vals := range r.Header {
if skipDetectHeader(name) {
continue
}
for _, v := range vals {
out = append(out, wafhttpcore.NormalizeForDetection(v))
}
}
return out
}
// headerXSSHit 头值逐个判 XSS(结构化跳过 C + 多信号 D,同请求体口径)
func headerXSSHit(r *http.Request) bool {
for _, v := range detectableHeaderValues(r) {
if bodyValueIsXSS(v) {
return true
}
}
return false
}
// headerSQLHit 头值逐个判 SQLi(高危关键字兜底 + 结构化跳过 + libinjection)
func headerSQLHit(r *http.Request) bool {
for _, v := range detectableHeaderValues(r) {
if wafhttpcore.HasHighRiskSQLKeyword(v) {
return true
}
if wafhttpcore.IsStructuredDataValue(v) {
continue
}
if libinjection.IsSQLiNotReturnPrint(v) {
return true
}
}
return false
}
// headerRCEHit 头值逐个判命令注入(含 D1-6 反混淆)
func headerRCEHit(r *http.Request) (bool, string) {
for _, v := range detectableHeaderValues(r) {
if ok, name := wafdefenserce.DetermineRCE(v); ok {
return true, name
}
}
return false, ""
}
+65
View File
@@ -0,0 +1,65 @@
package wafenginecore
import (
"net/http"
"testing"
)
func reqWithHeader(name, val string) *http.Request {
r, _ := http.NewRequest("GET", "http://x/", nil)
r.Header.Set(name, val)
return r
}
// S1-4:自定义头里的注入应被对应 check 命中
func TestHeaderDetect_Hits(t *testing.T) {
if !headerXSSHit(reqWithHeader("X-Custom", "<script>alert(1)</script>")) {
t.Error("头位 XSS 应命中")
}
if !headerSQLHit(reqWithHeader("X-Api", "1;exec sp_configure 'xp_cmdshell',1;reconfigure;--")) {
t.Error("头位 SQLi(关键字) 应命中")
}
if !headerSQLHit(reqWithHeader("X-Forwarded-Host", "1' or '1'='1")) {
t.Error("头位 SQLi(libinjection) 应命中")
}
if ok, _ := headerRCEHit(reqWithHeader("X-Cmd", "who^ami")); !ok {
t.Error("头位命令注入(反混淆) 应命中")
}
if ok, _ := headerRCEHit(reqWithHeader("X-Debug", ";id")); !ok {
t.Error("头位命令注入 应命中")
}
}
// 标准/浏览器头及正常自定义头不应误报
func TestHeaderDetect_NoFP(t *testing.T) {
r, _ := http.NewRequest("GET", "http://x/", nil)
r.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
r.Header.Set("Referer", "https://site.com/search?q=select+all&cat=news")
r.Header.Set("Accept", "text/html,application/xhtml+xml,*/*;q=0.8")
r.Header.Set("Sec-Ch-Ua", `"Chromium";v="120", "Not?A_Brand";v="8"`)
r.Header.Set("X-Requested-With", "XMLHttpRequest")
r.Header.Set("X-Custom-Id", "user-12345")
if headerXSSHit(r) {
t.Error("正常头不应判 XSS")
}
if headerSQLHit(r) {
t.Error("正常头不应判 SQLi")
}
if ok, _ := headerRCEHit(r); ok {
t.Error("正常头不应判命令注入")
}
}
func TestSkipDetectHeader(t *testing.T) {
skip := []string{"Host", "User-Agent", "Referer", "Accept", "Cookie", "Sec-Fetch-Site", "waf_req_uuid"}
for _, h := range skip {
if !skipDetectHeader(h) {
t.Errorf("应跳过标准头: %s", h)
}
}
for _, h := range []string{"X-Custom", "X-Forwarded-Host", "X-Cmd"} {
if skipDetectHeader(h) {
t.Errorf("自定义头不应跳过: %s", h)
}
}
}
+13
View File
@@ -439,6 +439,19 @@ func (waf *WafEngine) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
// S1 归一化:为检测生成解码副本(原文保留用于落库/展示),覆盖 body、cookie
// 及多层编码的表单值绕过面;各 check 改吃这些解码副本。
weblogbean.BodyDecoded = wafhttpcore.NormalizeForDetection(weblogbean.BODY)
weblogbean.CookiesDecoded = wafhttpcore.NormalizeForDetection(weblogbean.COOKIES)
// JSON 体逐值提取(json 解析天然解 \uXXXX)+ 直接父键名,供 XSS/SQLi 逐值检测(避免整块喂
// libinjection 造成误报)与“按字段排除”(E)
weblogbean.BodyFields, weblogbean.BodyValues = wafhttpcore.ExtractJSONFieldValues(weblogbean.BODY)
for k, vals := range formValues {
for i, v := range vals {
formValues[k][i] = wafhttpcore.NormalizeForDetection(v)
}
}
// 检测是否已经被CC封禁(使用与CC检测相同的IP模式)
ccCheckIP := model.GetClientIPByMode(hostTarget.Host.IPMode, weblogbean.NetSrcIp, weblogbean.SRC_IP)
ccCacheKey := enums.CACHE_CCVISITBAN_PRE + ccCheckIP
+132
View File
@@ -0,0 +1,132 @@
package wafhttpcore
import (
"encoding/json"
"html"
"regexp"
"strconv"
"strings"
)
// NormalizeForDetection 供检测使用的归一化:多轮 URL 解码(含 PHP 兜底)。
// 原文另存用于落库/展示,这里只产出喂给各 check 的解码副本,覆盖 body/cookie/
// 表单值里的(多层)URL 编码绕过(S1)。
func NormalizeForDetection(raw string) string {
if raw == "" {
return ""
}
return WafHttpCoreUrlEncode(raw, 10)
}
const (
maxJSONBodyForDetection = 512 * 1024 // 超过则不做 JSON 逐值提取,避免大体积开销
maxJSONValuesForDetection = 2000 // 值数量上限,防深/宽 JSON 撑爆
)
// reJSEscape 匹配 JS/JSON unicode/hex 转义:\uXXXX、\u{XX..}、\xXX。
var reJSEscape = regexp.MustCompile(`\\u\{([0-9a-fA-F]{1,6})\}|\\u([0-9a-fA-F]{4})|\\x([0-9a-fA-F]{2})`)
// decodeJSEscapes 把 \uXXXX/\u{..}/\xXX 还原为字符(覆盖 unicode_js 编码变体)。
func decodeJSEscapes(s string) string {
if !strings.Contains(s, `\u`) && !strings.Contains(s, `\x`) {
return s
}
return reJSEscape.ReplaceAllStringFunc(s, func(m string) string {
hex := ""
for _, g := range reJSEscape.FindStringSubmatch(m)[1:] {
if g != "" {
hex = g
break
}
}
n, err := strconv.ParseInt(hex, 16, 32)
if err != nil || n < 0 || n > 0x10FFFF {
return m
}
return string(rune(n))
})
}
// NormalizeForXSSDetection 在 URL 解码基础上再叠 HTML 实体解码 + JS unicode/hex 转义解码(S1 二期)。
// 仅用于 XSS 检测路径——htmlent(&lt;/&#40;)、unicode_js(<) 编码的 XSS 解开后才能被 libinjection
// 认出。因解码会放大误报面,仅配合"多信号(真标签/事件)"使用,且不作用于 SQLi/RCE。
func NormalizeForXSSDetection(raw string) string {
if raw == "" {
return ""
}
s := NormalizeForDetection(raw) // 多轮 URL 解码
s = html.UnescapeString(s) // HTML 实体:&lt; &gt; &quot; &#39; &#40; &#x3c;
s = decodeJSEscapes(s) // JS 转义:< \x3c
return s
}
// ExtractJSONFieldValues 把 JSON 请求体里的所有字符串**值**逐个取出,并返回每个值的**直接父键名**
// (fields[i] 对应 values[i];顶层数组元素父键为 "")。相比“整块 body 喂 libinjection”,逐值能大幅
// 降误报(数字/布尔/结构字符不参与),且 json 解析天然解掉 \uXXXX 转义(覆盖 {"x":"<script>"} 绕过)。
// 携带字段名是为了支持“按字段排除”(E):已知装富文本/HTML 的字段可整体跳过深检。
// 注意:不再对取出的值额外做 URL 解码——URL 编码的 JSON 值解开后会变回 JSON 结构被误判。
// 非 JSON / 空 / 超限返回 nil,nil。
func ExtractJSONFieldValues(body string) (fields, values []string) {
if body == "" || len(body) > maxJSONBodyForDetection {
return nil, nil
}
var v interface{}
if err := json.Unmarshal([]byte(body), &v); err != nil {
return nil, nil // 非合法 JSON:正常非 JSON 体不参与,避免误报
}
var walk func(field string, x interface{})
walk = func(field string, x interface{}) {
if len(values) >= maxJSONValuesForDetection {
return
}
switch t := x.(type) {
case string:
fields = append(fields, field)
values = append(values, t)
case []interface{}:
for _, e := range t {
walk(field, e) // 数组元素继承父键名
}
case map[string]interface{}:
for k, e := range t { // 进入 map,键名成为子值的 field(只取值不取键名)
walk(k, e)
}
}
}
walk("", v)
return fields, values
}
// ExtractJSONStringValues 仅取值(向后兼容)。
func ExtractJSONStringValues(body string) []string {
_, values := ExtractJSONFieldValues(body)
return values
}
// IsJSONBody 报告 body 是否为合法 JSON 对象/数组。用于 SQLi:JSON 体走逐值检测即可,
// 不再整块喂 libinjection(整块含大量 , : " { } 结构字符,正常数字/结构 JSON 会被误判)。
func IsJSONBody(body string) bool {
s := strings.TrimSpace(body)
if len(s) == 0 || len(s) > maxJSONBodyForDetection {
return false
}
if s[0] != '{' && s[0] != '[' {
return false
}
return json.Valid([]byte(s))
}
// IsStructuredDataValue 报告值更像“结构化数据”而非 SQL 注入串:JSON 对象/数组、或 http(s)
// URL。对这类值跳过 SQLi 逐值检测——正常埋点/日志/回调常把嵌套 JSON、回调 URL 塞进字段值,
// libinjection 会把其中的引号/逗号/短横线误判为 SQLi。仅作用于 SQLi,不影响 XSS/RCE。
func IsStructuredDataValue(v string) bool {
s := strings.TrimSpace(v)
if s == "" {
return false
}
if s[0] == '{' || s[0] == '[' {
return true
}
ls := strings.ToLower(s)
return strings.HasPrefix(ls, "http://") || strings.HasPrefix(ls, "https://")
}
+163
View File
@@ -0,0 +1,163 @@
package wafhttpcore
import (
"strings"
"testing"
)
func TestExtractJSONStringValues(t *testing.T) {
cases := []struct {
name string
body string
want []string // 期望包含的(子串)值
wantNil bool
}{
{
name: "普通JSON只取字符串值不取键名",
body: `{"name":"tom","age":18,"ok":true,"tags":["a","b"]}`,
want: []string{"tom", "a", "b"}, // age/ok 非字符串跳过;键名不取
},
{
name: "JSON字符串值含script标签",
body: `{"x":"<script>alert(1)</script>"}`,
want: []string{"<script>alert(1)</script>"},
},
{
name: "值里的URL编码不再叠解码(避免正常埋点误报)",
body: `{"x":"%3Cimg%20src=x%20onerror=1%3E"}`,
want: []string{"%3Cimg%20src=x%20onerror=1%3E"}, // 保持原样,不 URL 解码
},
{
name: "嵌套对象/数组递归取值",
body: `{"a":{"b":["c",{"d":"e"}]}}`,
want: []string{"c", "e"},
},
{
name: "非JSON返回nil(正常表单/文本不参与)",
body: `a=1&b=2`,
wantNil: true,
},
{
name: "空串返回nil",
body: "",
wantNil: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := ExtractJSONStringValues(c.body)
if c.wantNil {
if got != nil {
t.Errorf("期望 nil,得到 %v", got)
}
return
}
joined := strings.Join(got, "\x00")
for _, w := range c.want {
if !strings.Contains(joined, w) {
t.Errorf("期望包含值 %q,实际提取=%v", w, got)
}
}
})
}
}
// TestExtractJSONStringValues_UnicodeEscape 验证 JSON \uXXXX 转义会被 json 解析还原,
// 从而覆盖 {"x":"<script>..."} 这类绕过(body 用字节构造反斜杠,避免源码转义歧义)。
func TestExtractJSONStringValues_UnicodeEscape(t *testing.T) {
bs := string([]byte{0x5c}) // 反斜杠
body := `{"x":"` + bs + `u003cscript` + bs + `u003ealert(1)` + bs + `u003c/script` + bs + `u003e"}`
got := ExtractJSONStringValues(body)
joined := strings.Join(got, "\x00")
if !strings.Contains(joined, "<script>alert(1)</script>") {
t.Errorf("期望 JSON 转义被解成 <script>...,实际提取=%v", got)
}
}
func TestIsJSONBody(t *testing.T) {
yes := []string{`{"a":1}`, ` [1,2,3] `, `{"x":{"y":"z"}}`}
no := []string{"", "a=1&b=2", "hello", "123", `{"a":1`}
for _, s := range yes {
if !IsJSONBody(s) {
t.Errorf("IsJSONBody(%q)=false,期望 true", s)
}
}
for _, s := range no {
if IsJSONBody(s) {
t.Errorf("IsJSONBody(%q)=true,期望 false", s)
}
}
}
func TestIsStructuredDataValue(t *testing.T) {
// 结构化数据(跳过 SQLi)
structured := []string{
`["[Capi Success]",{"body":{}}]`,
`{"serviceType":"ba"}`,
"https://rs.fullstory.com/rec/bundle/v2?OrgId=B9193&UserId=4b2b7c84-aa5f",
"HTTP://Example.com/x",
}
// 仍需检测的普通值(不跳过)
normal := []string{
"1' or '1'='1",
"admin",
"union select password from users",
"",
}
for _, s := range structured {
if !IsStructuredDataValue(s) {
t.Errorf("IsStructuredDataValue(%q)=false,期望 true(应跳过SQLi)", s)
}
}
for _, s := range normal {
if IsStructuredDataValue(s) {
t.Errorf("IsStructuredDataValue(%q)=true,期望 false(应检测)", s)
}
}
}
func TestExtractJSONFieldValues(t *testing.T) {
fields, values := ExtractJSONFieldValues(`{"html":"<b>hi</b>","n":1,"a":{"content":"x"},"arr":["p","q"]}`)
if len(fields) != len(values) {
t.Fatalf("fields/values 长度不一致: %d vs %d", len(fields), len(values))
}
got := map[string]string{}
for i := range values {
got[values[i]] = fields[i]
}
// 值 → 直接父键名
want := map[string]string{"<b>hi</b>": "html", "x": "content", "p": "arr", "q": "arr"}
for v, f := range want {
if got[v] != f {
t.Errorf("值 %q 的父键=%q,期望 %q", v, got[v], f)
}
}
// 数字值不提取
if _, ok := got["1"]; ok {
t.Error("数字值不应被提取")
}
}
// S1 二期:HTML 实体 + JS unicode 解码
func TestNormalizeForXSSDetection(t *testing.T) {
cases := []struct{ in, wantContains string }{
{"&lt;script&gt;alert(1)&lt;/script&gt;", "<script>"}, // htmlent 命名实体
{"&#60;script&#62;", "<script>"}, // 十进制实体
{"&#x3c;svg onload&#x3d;alert(1)&#x3e;", "<svg onload=alert"}, // 十六进制实体
{`<script>alert(1)</script>`, "<script>"}, // unicode_js \u00XX
{`\x3cimg src=x onerror=alert(1)\x3e`, "<img src=x onerror="}, // \xXX
{"img&#40;1&#41;", "img(1)"}, // &#40; &#41; → ( )
}
for _, c := range cases {
got := NormalizeForXSSDetection(c.in)
if !strings.Contains(got, c.wantContains) {
t.Errorf("NormalizeForXSSDetection(%q)=%q, 应含 %q", c.in, got, c.wantContains)
}
}
// 正常文本不应被破坏(无实体/转义则原样)
for _, s := range []string{"hello world", "price 100", "a=1&b=2"} {
if got := NormalizeForXSSDetection(s); got != s {
t.Errorf("正常文本被改动: %q -> %q", s, got)
}
}
}
@@ -0,0 +1,42 @@
package wafhttpcore
import (
"regexp"
"strings"
)
// ── D5-1:MSSQL/DB 高危关键字兜底(对标 OWASP CRS 942 专项规则)──
// libinjection 以低误报为目标,纯 DDL/配置语句缺注入指纹会漏:sp_configure 单独开
// xp_cmdshell 开关、CREATE TABLE + INSERT ... VALUES(0x..) 暂存 hex webshell、bcp
// queryout 导出等。这些词/形态正常输入几乎不出现,命中即判 SQLi(大小写不敏感)。
// 单关键字:均为 MSSQL/DB 特有、非日常英文词。误报核验:逐条跑遍 blaze 33k 白样本命中 0。
// 注意:MySQL 文件原语 into outfile/into dumpfile/load_file 是双刃词——真注入上下文由 libinjection
// 拦,裸关键字会误伤合法 DB 管理流量(blaze 白样本实测 into outfile 命中 20+、load_file 1),故不入表。
var sqlHighRiskKeywords = []string{
"xp_cmdshell", "sp_configure", "xp_regwrite", "xp_regread", "xp_regdeletevalue",
"xp_dirtree", "xp_fileexist", "sp_oacreate", "sp_oamethod",
"openrowset", "openquery", "opendatasource",
}
// bcp ... queryout(导出/落地 webshell)
var reSqlBcpQueryout = regexp.MustCompile(`(?i)\bbcp\b[^;]*\bqueryout\b`)
// hex blob 写入:VALUES(0x..) 或 INSERT INTO .. 0x..(暂存 hex webshell,libinjection 漏)
var reSqlHexBlob = regexp.MustCompile(`(?i)(values\s*\(\s*0x[0-9a-f]{16,}|insert\s+into\b[^;]{0,300}\b0x[0-9a-f]{16,})`)
// HasHighRiskSQLKeyword 报告串是否含 MSSQL/DB 高危关键字或落地形态。供 CheckSql 逐值调用。
// 先叠一层 URL 解码:JSON 逐值(BodyValues)按 S1 设计不额外解码,url 编码的 VALUES(0x..) 会漏;
// 关键字匹配是精确子串,解码只暴露更多明文、对正常内容不会误命中,故此处安全。
func HasHighRiskSQLKeyword(raw string) bool {
if raw == "" {
return false
}
s := strings.ToLower(NormalizeForDetection(raw))
for _, kw := range sqlHighRiskKeywords {
if strings.Contains(s, kw) {
return true
}
}
return reSqlBcpQueryout.MatchString(s) || reSqlHexBlob.MatchString(s)
}
@@ -0,0 +1,43 @@
package wafhttpcore
import "testing"
// D5-1:MSSQL/DB 高危关键字兜底 —— libinjection 漏的纯 DDL/配置语句必须命中,正常输入不误报。
func TestHasHighRiskSQLKeyword(t *testing.T) {
hit := map[string]string{
// SamWafPoc sqli 语料 5 条 MSSQL 提权链(含 libinjection 漏的 spconfig-only / webshell-stage)
"xpcmdshell": `1;exec master..xp_cmdshell 'whoami';--`,
"enable-xp": `1;exec sp_configure 'show advanced options',1;reconfigure;exec sp_configure 'xp_cmdshell',1;reconfigure;--`,
"spconfig-only": `1;exec sp_configure 'xp_cmdshell',1;reconfigure;--`,
"webshell-stage": `1;CREATE TABLE g3(a varchar(8000));INSERT INTO g3 VALUES(0x3c25402070616765);--`,
"bcp-export": `1;exec xp_cmdshell 'bcp "select a from g3" queryout /tmp/probe.txt -c -T';--`,
// 其它高危形态
"openrowset": `select * from openrowset('sqloledb','...')`,
"bcp-queryout": `bcp "select x" queryout c:\inetpub\x.aspx -c`,
"insert-hex": `insert into t select 0x3c25402070616765abcd1234`,
"case-mixed": `1;EXEC Xp_CmdShell 'whoami';--`, // 大小写变体
"url-encoded": `insert%20into%20g%20values%280x3c25402070616765%29`, // url 编码(叠 URL 解码后现形)
}
for name, p := range hit {
if !HasHighRiskSQLKeyword(p) {
t.Errorf("应命中但漏过 [%s]: %q", name, p)
}
}
miss := map[string]string{
"normal-name": "John Smith",
"normal-query": "keyword=价格100元&page=2",
"select-word": "please select all items", // select 不在关键字表
"reconfigure": "reconfigure your dashboard", // reconfigure 单独不判(常见英文)
"bcp-noqueryout": "bcpackage download", // bcp 无 queryout 不判
"short-hex": "color=0xff0000&file=a.png", // 短 hex 非 blob
"open-word": "openreport&opendashboard", // 非 openrowset/openquery
"into-outfile": "export data into outfile report.csv", // MySQL 文件原语不入表(改前误伤 blaze 白样本 20+)
"load-file": "please load_file the template", // 同上,双刃词不入表
}
for name, p := range miss {
if HasHighRiskSQLKeyword(p) {
t.Errorf("应放行但误判 [%s]: %q", name, p)
}
}
}
+116
View File
@@ -0,0 +1,116 @@
package wafhttpcore
import "strings"
// ── D4/D6:目录穿越 + 敏感文件(LFI)检测 ──
// 先归一化(多轮 URL 解码 → overlong UTF-8 → 反斜杠归一 → ....// 折叠),再判"是否逃出根"
// 而非见 ../ 就拦:站内相对路径 /assets/theme/../css/app.css 规范化后仍在根内,放行(消除
// path-css 误报)。另叠一张敏感文件表接住"无 ../ 的裸绝对路径 LFI"(/etc/passwd 等,D6)。
// overlong UTF-8 及编码变体 → 规范字符,防 ..%c0%af 之类绕过(URL 解码后是裸字节)。
var traversalOverlong = strings.NewReplacer(
"\xc0\xaf", "/", "\xe0\x80\xaf", "/", "\xf0\x80\x80\xaf", "/", // '/'
"\xc0\xae", ".", "\xe0\x80\xae", ".", // '.'
"\xc1\x9c", "/", "\xe0\x81\x9c", "/", // '\\' → 统一按 '/'
)
// normalizeTraversalPath 归一化用于穿越/LFI 判定的串(输出小写、正斜杠)。
func normalizeTraversalPath(raw string) string {
if raw == "" {
return ""
}
s := NormalizeForDetection(raw) // 多轮 URL 解码(含双重编码 %252e)
s = traversalOverlong.Replace(s) // overlong UTF-8 → / .
s = strings.ReplaceAll(s, "\\", "/") // 反斜杠 → 正斜杠
for strings.Contains(s, "....//") { // 折叠 ....// → ../("删一次 ../"式绕过)
s = strings.ReplaceAll(s, "....//", "../")
}
return strings.ToLower(s)
}
// escapesRoot 报告归一化后的路径是否"逃出根"(../ 累计深度超过前缀目录深度)。
// /assets/theme/../css/app.css 规范化后仍在根内 → false;../../etc/passwd → true。
func escapesRoot(p string) bool {
depth := 0
for _, seg := range strings.Split(p, "/") {
switch seg {
case "", ".":
// 分隔符 / 当前目录:不改变深度
case "..":
depth--
if depth < 0 {
return true // 跳出根
}
default:
depth++
}
}
return false
}
// 敏感文件/路径特征(对标 OWASP CRS 930120 lfi-os-files)。均小写。
// absSensitiveFiles 需绝对定位(前导 / 或 // 边界)——避免站内子目录 foo/etc/passwd 误伤。
var absSensitiveFiles = []string{
"/etc/passwd", "/etc/shadow", "/etc/gshadow",
"/proc/self/environ", "/proc/self/cmdline", "/proc/version",
"/root/.bash_history",
"/.ssh/id_rsa", "/.ssh/id_dsa", "/.ssh/authorized_keys",
"/windows/win.ini", "/windows/system.ini", "/windows/system32/config/sam",
"/boot.ini",
}
// nameSensitiveFiles 足够独特的文件名,出现在任意路径段边界即判(前导 / 或串首)。
var nameSensitiveFiles = []string{
"wp-config.php", ".htpasswd", "web-inf/web.xml", ".git/config", ".svn/entries",
}
// hitSensitiveFile 归一化串是否命中敏感文件表(路径边界匹配,压误报)。
func hitSensitiveFile(normalized string) bool {
for _, f := range absSensitiveFiles {
if containsSensitiveToken(normalized, f) {
return true
}
}
for _, f := range nameSensitiveFiles {
if containsSensitiveToken(normalized, f) {
return true
}
}
return false
}
// containsSensitiveToken 在"路径边界"上查子串:左侧为串首或 '/',右侧为串尾或 / ? # .
// —— 避免 /etc/passwd 命中 /etc/passwderr、/etc/hosts 命中 /etc/hostsettings。
func containsSensitiveToken(s, token string) bool {
from := 0
for {
rel := strings.Index(s[from:], token)
if rel < 0 {
return false
}
i := from + rel
leftOK := i == 0 || s[i-1] == '/' || s[i-1] == ':' // ':' 容纳盘符 c:/ 与 scheme
end := i + len(token)
rightOK := end == len(s)
if !rightOK {
c := s[end]
rightOK = c == '/' || c == '?' || c == '#' || c == '.'
}
if leftOK && rightOK {
return true
}
from = i + 1
}
}
// HasTraversalOrLFI 归一化后判"逃出根"或命中敏感文件。供 CheckDirTraversal 逐值调用。
func HasTraversalOrLFI(raw string) bool {
if raw == "" {
return false
}
n := normalizeTraversalPath(raw)
if escapesRoot(n) {
return true
}
return hitSensitiveFile(n)
}
@@ -0,0 +1,83 @@
package wafhttpcore
import "testing"
// D4+D6:目录穿越/LFI —— 7 条真漏样本(SamWafPoc traversal 语料)必须命中,
// path-css 误报样本及常见正常值必须放行。
func TestHasTraversalOrLFI(t *testing.T) {
malicious := map[string]string{
"unix-passwd": "../../../../etc/passwd",
"enc-passwd": "..%2f..%2f..%2fetc%2fpasswd",
"dotdot-slash": "....//....//etc/passwd",
"abs-passwd": "/etc/passwd", // D6 裸绝对路径
"win-ini": "..\\..\\..\\windows\\win.ini",
"double-enc": "%252e%252e%252fetc%252fpasswd",
"utf8-overlong": "..%c0%af..%c0%afetc/passwd",
// 额外 D6 敏感文件
"abs-shadow": "/etc/shadow",
"abs-environ": "/proc/self/environ",
"win-abs-ini": "C:\\windows\\win.ini",
"wp-config": "/var/www/wp-config.php",
}
for name, p := range malicious {
if !HasTraversalOrLFI(p) {
t.Errorf("应命中但漏过 [%s]: %q", name, p)
}
}
benign := map[string]string{
"path-css": "/assets/theme/../css/app.css", // 站内相对,规范化后不逃根
"normal-path": "/api/v1/users/123",
"version": "1.2.3",
"abs-url": "https://cdn.example.com/a/../b/app.js", // 绝对 URL 内的 .. 不逃根
"query-normal": "keyword=价格100元",
"dotfile": "app.config.js",
"subdir-etc": "uploads/etc/report.csv", // 站内 etc 子目录,非系统 /etc
"env-word": "/api/environment/list", // 不应被 .env 之类误伤
}
for name, p := range benign {
if HasTraversalOrLFI(p) {
t.Errorf("应放行但误拦 [%s]: %q", name, p)
}
}
}
func TestEscapesRoot(t *testing.T) {
esc := []string{"../x", "../../etc/passwd", "a/../../b", "/../etc"}
for _, p := range esc {
if !escapesRoot(p) {
t.Errorf("应判逃根: %q", p)
}
}
stay := []string{"/assets/theme/../css/app.css", "a/b/../c", "/etc/passwd", "foo/bar", ""}
for _, p := range stay {
if escapesRoot(p) {
t.Errorf("应在根内: %q", p)
}
}
}
func TestContainsSensitiveToken(t *testing.T) {
// 边界匹配:命中
hit := [][2]string{
{"/etc/passwd", "/etc/passwd"},
{"/var/www/wp-config.php", "wp-config.php"},
{"/x/.git/config", ".git/config"},
}
for _, c := range hit {
if !containsSensitiveToken(c[0], c[1]) {
t.Errorf("应命中 token %q in %q", c[1], c[0])
}
}
// 边界匹配:不命中(前缀/子串误伤)
miss := [][2]string{
{"/etc/passwderr", "/etc/passwd"}, // 右边界
{"/app/etc/passwd", "/etc/passwd"}, // 左边界(子目录 app/etc)
{"/etc/hostsettings", "/etc/hosts"}, // 若未来加 hosts 也不误伤
}
for _, c := range miss {
if containsSensitiveToken(c[0], c[1]) {
t.Errorf("不应命中 token %q in %q", c[1], c[0])
}
}
}
+15
View File
@@ -384,6 +384,19 @@ func setConfigStringValue(name string, value string, change int) {
global.GCONFIG_AI_MODE = "observe"
}
break
case "body_detect_mode":
switch value {
case "observe", "block", "off":
global.GCONFIG_BODY_DETECT_MODE = value
default:
zlog.Warn("invalid body_detect_mode value, fallback to observe", value)
global.GCONFIG_BODY_DETECT_MODE = "observe"
}
break
case "body_detect_field_exclude":
global.GCONFIG_BODY_DETECT_FIELD_EXCLUDE = value
wafenginecore.SetBodyDetectFieldExclude(value) // 同步解析成 RCU 排除集
break
case "kafka_url":
global.GCONFIG_RECORD_KAFKA_URL = value
break
@@ -746,6 +759,8 @@ func TaskLoadSetting(initLoad bool) {
updateConfigIntItem(initLoad, "system", "ipprobe_enable", global.GCONFIG_IPPROBE_ENABLE, "真实IP来源探针(1开启 0关闭,默认关闭)。开启后记录各站点最近到达的请求头(脱敏,仅内存,每站每秒最多1条),供站点「真实IP来源」处排查CDN送来的是哪个头;排查完建议关闭", "int", "", configMap)
updateConfigIntItem(initLoad, "system", "ai_enable", global.GCONFIG_AI_ENABLE, "启动AI智能检测总开关(1启动 0关闭,需先在AI模型管理上传模型包并在站点开启)", "int", "", configMap)
updateConfigStringItem(initLoad, "system", "ai_mode", global.GCONFIG_AI_MODE, "AI检测工作模式:observe(仅记录/观察) block(达拦截阈值则拦截)", "options", "observe|仅记录,block|拦截", configMap)
updateConfigStringItem(initLoad, "system", "body_detect_mode", global.GCONFIG_BODY_DETECT_MODE, "请求体深度检测模式(formValue/JSON 逐值 XSS/SQLi/RCE):observe 观察(命中只记录不拦,默认) block 拦截(命中即拦) off 关闭。默认 observe 避免误伤正常 JSON/表单流量,观察日志确认无误后再切 block;查询串检测不受影响始终拦截", "options", "observe|观察(只记录),block|拦截,off|关闭", configMap)
updateConfigStringItem(initLoad, "system", "body_detect_field_exclude", global.GCONFIG_BODY_DETECT_FIELD_EXCLUDE, "请求体深检字段排除清单(逗号分隔字段名,不区分大小写)。这些字段名下的表单值/JSON值(匹配直接父键名)跳过 XSS/SQLi/RCE 深检,用于放行已知携带富文本/HTML/代码的合法字段(如 content,html,markdown),压 block 模式误报。看观察日志里是哪些字段误报再填", "string", "", configMap)
updateConfigIntItem(initLoad, "system", "rule_chain_mode", global.GCONFIG_RULE_CHAIN_MODE, "自定义规则在检测链中的位置(0默认:排在CC之后;1规则优先:排在黑名单之后、Bot/SQLI/XSS等之前,此时规则的放行动作才能跳过这些检测)", "int", "", configMap)
updateConfigIntItem(initLoad, "access", "access_enable", global.GCONFIG_ACCESS_ENABLE, "统一访问认证总开关:开启后所有被WAF代理的站点默认都需先登录才能访问(可在站点里单独强制开/关,也可配置免认证路径与IP组)。请先在【统一访问认证-访问账号】里建好账号再开启", "options", "0|关闭,1|开启", configMap)