mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-08-31 01:41:39 +08:00
feat:web page anti-tamper protection
This commit is contained in:
@@ -41,6 +41,7 @@ type APIGroup struct {
|
||||
WafPrivateInfoApi
|
||||
WafPrivateGroupApi
|
||||
WafCacheRuleApi
|
||||
WafTamperRuleApi
|
||||
WafTunnelApi
|
||||
WafVpConfigApi
|
||||
WafFileApi
|
||||
@@ -120,6 +121,7 @@ var (
|
||||
wafPrivateInfoService = waf_service.WafPrivateInfoServiceApp
|
||||
wafPrivateGroupService = waf_service.WafPrivateGroupServiceApp
|
||||
wafCacheRuleService = waf_service.WafCacheRuleServiceApp
|
||||
wafTamperRuleService = waf_service.WafTamperRuleServiceApp
|
||||
wafTunnelService = waf_service.WafTunnelServiceApp
|
||||
|
||||
wafMonitorService = waf_service.WafSystemMonitorServiceApp
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"SamWaf/enums"
|
||||
"SamWaf/global"
|
||||
"SamWaf/model"
|
||||
"SamWaf/model/common/response"
|
||||
"SamWaf/model/request"
|
||||
"SamWaf/model/spec"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type WafTamperRuleApi struct {
|
||||
}
|
||||
|
||||
// AddApi 新增网页防篡改规则
|
||||
func (w *WafTamperRuleApi) AddApi(c *gin.Context) {
|
||||
var req request.WafTamperRuleAddReq
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err == nil {
|
||||
cnt := wafTamperRuleService.CheckIsExistApi(req)
|
||||
if cnt == 0 {
|
||||
err = wafTamperRuleService.AddApi(req)
|
||||
if err == nil {
|
||||
w.NotifyWaf(req.HostCode)
|
||||
response.OkWithMessage("添加成功", c)
|
||||
} else {
|
||||
response.FailWithMessage("添加失败:"+err.Error(), c)
|
||||
}
|
||||
return
|
||||
} else {
|
||||
response.FailWithMessage("同站点下该URL的防篡改规则已存在", c)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
response.FailWithMessage("解析失败", c)
|
||||
}
|
||||
}
|
||||
|
||||
// GetDetailApi 获取网页防篡改规则详情(不含基线正文)
|
||||
func (w *WafTamperRuleApi) GetDetailApi(c *gin.Context) {
|
||||
var req request.WafTamperRuleDetailReq
|
||||
err := c.ShouldBind(&req)
|
||||
if err == nil {
|
||||
bean := wafTamperRuleService.GetDetailApi(req)
|
||||
response.OkWithDetailed(bean, "获取成功", c)
|
||||
} else {
|
||||
response.FailWithMessage("解析失败", c)
|
||||
}
|
||||
}
|
||||
|
||||
// GetListApi 获取网页防篡改规则列表(不含基线正文,保持轻量)
|
||||
func (w *WafTamperRuleApi) GetListApi(c *gin.Context) {
|
||||
var req request.WafTamperRuleSearchReq
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err == nil {
|
||||
list, total, _ := wafTamperRuleService.GetListApi(req)
|
||||
response.OkWithDetailed(response.PageResult{
|
||||
List: list,
|
||||
Total: total,
|
||||
PageIndex: req.PageIndex,
|
||||
PageSize: req.PageSize,
|
||||
}, "获取成功", c)
|
||||
} else {
|
||||
response.FailWithMessage("解析失败", c)
|
||||
}
|
||||
}
|
||||
|
||||
// DelApi 删除网页防篡改规则
|
||||
func (w *WafTamperRuleApi) DelApi(c *gin.Context) {
|
||||
var req request.WafTamperRuleDelReq
|
||||
err := c.ShouldBind(&req)
|
||||
if err == nil {
|
||||
bean := wafTamperRuleService.GetDetailByIdApi(req.Id)
|
||||
if bean.Id == "" {
|
||||
response.FailWithMessage("未找到信息", c)
|
||||
return
|
||||
}
|
||||
err = wafTamperRuleService.DelApi(req)
|
||||
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
response.FailWithMessage("请检测参数", c)
|
||||
} else if err != nil {
|
||||
response.FailWithMessage("发生错误", c)
|
||||
} else {
|
||||
w.NotifyWaf(bean.HostCode)
|
||||
response.OkWithMessage("删除成功", c)
|
||||
}
|
||||
} else {
|
||||
response.FailWithMessage("解析失败", c)
|
||||
}
|
||||
}
|
||||
|
||||
// ModifyApi 编辑网页防篡改规则
|
||||
func (w *WafTamperRuleApi) ModifyApi(c *gin.Context) {
|
||||
var req request.WafTamperRuleEditReq
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err == nil {
|
||||
err = wafTamperRuleService.ModifyApi(req)
|
||||
if err != nil {
|
||||
response.FailWithMessage("编辑发生错误:"+err.Error(), c)
|
||||
} else {
|
||||
w.NotifyWaf(req.HostCode)
|
||||
response.OkWithMessage("编辑成功", c)
|
||||
}
|
||||
} else {
|
||||
response.FailWithMessage("解析失败", c)
|
||||
}
|
||||
}
|
||||
|
||||
// RelearnApi 重新学习基线
|
||||
func (w *WafTamperRuleApi) RelearnApi(c *gin.Context) {
|
||||
var req request.WafTamperRuleRelearnReq
|
||||
err := c.ShouldBind(&req)
|
||||
if err == nil {
|
||||
bean := wafTamperRuleService.GetDetailByIdApi(req.Id)
|
||||
if bean.Id == "" {
|
||||
response.FailWithMessage("未找到信息", c)
|
||||
return
|
||||
}
|
||||
err = wafTamperRuleService.RelearnApi(req)
|
||||
if err != nil {
|
||||
response.FailWithMessage("操作失败:"+err.Error(), c)
|
||||
} else {
|
||||
w.NotifyWaf(bean.HostCode)
|
||||
response.OkWithMessage("已标记重新学习,下次访问该URL将重新捕获基线", c)
|
||||
}
|
||||
} else {
|
||||
response.FailWithMessage("解析失败", c)
|
||||
}
|
||||
}
|
||||
|
||||
// GetBaselineApi 查看基线正文(按需拉取;文本回内容,二进制只回元数据)
|
||||
func (w *WafTamperRuleApi) GetBaselineApi(c *gin.Context) {
|
||||
var req request.WafTamperRuleBaselineReq
|
||||
err := c.ShouldBind(&req)
|
||||
if err != nil {
|
||||
response.FailWithMessage("解析失败", c)
|
||||
return
|
||||
}
|
||||
bean := wafTamperRuleService.GetBaselineApi(req.Id)
|
||||
if bean.Id == "" {
|
||||
response.FailWithMessage("未找到信息", c)
|
||||
return
|
||||
}
|
||||
isText := isTextContentType(bean.ContentType)
|
||||
result := gin.H{
|
||||
"content_type": bean.ContentType,
|
||||
"content_size": bean.ContentSize,
|
||||
"baseline_hash": bean.BaselineHash,
|
||||
"baseline_status": bean.BaselineStatus,
|
||||
"last_learn_time": bean.LastLearnTime,
|
||||
"is_text": isText,
|
||||
"content": "", // 文本正文
|
||||
"content_base64": "", // 非文本(图片等)以 base64 返回,供前端 data URL 渲染/下载
|
||||
}
|
||||
if len(bean.BaselineContent) > 0 {
|
||||
if isText {
|
||||
result["content"] = string(bean.BaselineContent)
|
||||
} else {
|
||||
result["content_base64"] = base64.StdEncoding.EncodeToString(bean.BaselineContent)
|
||||
}
|
||||
}
|
||||
response.OkWithDetailed(result, "获取成功", c)
|
||||
}
|
||||
|
||||
// isTextContentType 判断 Content-Type 是否可按文本展示
|
||||
func isTextContentType(ct string) bool {
|
||||
ct = strings.ToLower(ct)
|
||||
if strings.HasPrefix(ct, "text/") {
|
||||
return true
|
||||
}
|
||||
for _, kw := range []string{"javascript", "json", "xml", "html", "css", "ecmascript", "x-www-form-urlencoded"} {
|
||||
if strings.Contains(ct, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NotifyWaf 通知到 waf 引擎实时生效
|
||||
func (w *WafTamperRuleApi) NotifyWaf(host_code string) {
|
||||
var list []model.TamperRule
|
||||
global.GWAF_LOCAL_DB.Where("host_code = ? ", host_code).Find(&list)
|
||||
var chanInfo = spec.ChanCommonHost{
|
||||
HostCode: host_code,
|
||||
Type: enums.ChanTypeTamperRule,
|
||||
Content: list,
|
||||
}
|
||||
global.GWAF_CHAN_MSG <- chanInfo
|
||||
}
|
||||
@@ -683,6 +683,11 @@ func (m *wafSystenService) run() {
|
||||
globalobj.GWAF_RUNTIME_OBJ_WAF_ENGINE.UpdateHost(msg.HostCode, func(h *wafenginmodel.HostSafe) { h.CacheRule = cacheRule })
|
||||
zlog.Debug("远程配置", zap.Any("配置缓存规则", cacheRule))
|
||||
break
|
||||
case enums.ChanTypeTamperRule:
|
||||
tamperRules := msg.Content.([]model.TamperRule)
|
||||
globalobj.GWAF_RUNTIME_OBJ_WAF_ENGINE.UpdateHost(msg.HostCode, func(h *wafenginmodel.HostSafe) { h.TamperRules = tamperRules })
|
||||
zlog.Debug("远程配置", zap.Any("配置网页防篡改规则", tamperRules))
|
||||
break
|
||||
case enums.ChanTypeHostPathRule:
|
||||
pathRules := msg.Content.([]model.HostPathRule)
|
||||
globalobj.GWAF_RUNTIME_OBJ_WAF_ENGINE.UpdateHost(msg.HostCode, func(h *wafenginmodel.HostSafe) { h.PathRules = pathRules })
|
||||
|
||||
@@ -17,4 +17,5 @@ const (
|
||||
ChanTypeBlockingPage
|
||||
ChanTypeCacheRule
|
||||
ChanTypeHostPathRule
|
||||
ChanTypeTamperRule
|
||||
)
|
||||
|
||||
@@ -51,6 +51,7 @@ type Hosts struct {
|
||||
ResponseCompressJSON string `gorm:"type:text" json:"response_compress_json"` //响应压缩配置 json(Gzip/Brotli/Zstd)
|
||||
CookieSecurityJSON string `gorm:"type:text" json:"cookie_security_json"` //Cookie安全保护配置 json(HttpOnly/Secure/SameSite)
|
||||
CsrfJSON string `gorm:"type:text" json:"csrf_json"` //CSRF防护配置 json(Origin/Referer 强校验)
|
||||
TamperJSON string `gorm:"type:text" json:"tamper_json"` //网页防篡改配置 json(响应基线比对)
|
||||
IPMode string `gorm:"size:20" json:"ip_mode"` //IP提取模式: "nic" 网卡模式 或 "proxy" 代理模式
|
||||
}
|
||||
|
||||
@@ -244,6 +245,35 @@ func ParseCsrfConfig(jsonStr string) CsrfConfig {
|
||||
return c
|
||||
}
|
||||
|
||||
// TamperConfig 网页防篡改配置(反代响应基线比对)
|
||||
type TamperConfig struct {
|
||||
IsEnable int `json:"is_enable"` // 1 开启 0 关闭(默认0,老站点不受影响)
|
||||
Action string `json:"action"` // "replace"(比对+命中回吐正确副本+告警,默认) / "alert"(仅告警,仍放行后端页,监控档)
|
||||
MaxSizeKB int `json:"max_size_kb"` // 基线最大字节(KB),默认1024(1MB),超限不学习/不保护
|
||||
}
|
||||
|
||||
// ParseTamperConfig 解析网页防篡改配置;空 JSON 给安全默认值(但默认关闭)
|
||||
func ParseTamperConfig(jsonStr string) TamperConfig {
|
||||
c := TamperConfig{
|
||||
IsEnable: 0,
|
||||
Action: "replace",
|
||||
MaxSizeKB: 1024,
|
||||
}
|
||||
if jsonStr == "" {
|
||||
return c
|
||||
}
|
||||
if err := json.Unmarshal([]byte(jsonStr), &c); err != nil {
|
||||
return TamperConfig{IsEnable: 0, Action: "replace", MaxSizeKB: 1024}
|
||||
}
|
||||
if c.Action == "" {
|
||||
c.Action = "replace"
|
||||
}
|
||||
if c.MaxSizeKB <= 0 {
|
||||
c.MaxSizeKB = 1024
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// TransportConfig 传输配置
|
||||
type TransportConfig struct {
|
||||
MaxIdleConns int `json:"max_idle_conns"` // 最大空闲连接数
|
||||
|
||||
@@ -45,6 +45,7 @@ type WafHostAddReq struct {
|
||||
ResponseCompressJSON string `json:"response_compress_json"` //响应压缩配置 json
|
||||
CookieSecurityJSON string `json:"cookie_security_json"` //Cookie安全保护配置 json
|
||||
CsrfJSON string `json:"csrf_json"` //CSRF防护配置 json
|
||||
TamperJSON string `json:"tamper_json"` //网页防篡改配置 json
|
||||
IPMode string `json:"ip_mode"` //IP提取模式: "nic" 网卡模式 或 "proxy" 代理模式
|
||||
}
|
||||
|
||||
@@ -99,6 +100,7 @@ type WafHostEditReq struct {
|
||||
ResponseCompressJSON string `json:"response_compress_json"` //响应压缩配置 json
|
||||
CookieSecurityJSON string `json:"cookie_security_json"` //Cookie安全保护配置 json
|
||||
CsrfJSON string `json:"csrf_json"` //CSRF防护配置 json
|
||||
TamperJSON string `json:"tamper_json"` //网页防篡改配置 json
|
||||
IPMode string `json:"ip_mode"` //IP提取模式: "nic" 网卡模式 或 "proxy" 代理模式
|
||||
}
|
||||
type WafHostGuardStatusReq struct {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package request
|
||||
|
||||
import "SamWaf/model/common/request"
|
||||
|
||||
type WafTamperRuleAddReq struct {
|
||||
HostCode string `json:"host_code" form:"host_code"`
|
||||
Url string `json:"url" form:"url"`
|
||||
RuleName string `json:"rule_name" form:"rule_name"`
|
||||
IsEnable int `json:"is_enable" form:"is_enable"`
|
||||
IgnoreQuery int `json:"ignore_query" form:"ignore_query"`
|
||||
Remarks string `json:"remarks" form:"remarks"`
|
||||
}
|
||||
type WafTamperRuleEditReq struct {
|
||||
Id string `json:"id"`
|
||||
HostCode string `json:"host_code" form:"host_code"`
|
||||
Url string `json:"url" form:"url"`
|
||||
RuleName string `json:"rule_name" form:"rule_name"`
|
||||
IsEnable int `json:"is_enable" form:"is_enable"`
|
||||
IgnoreQuery int `json:"ignore_query" form:"ignore_query"`
|
||||
Remarks string `json:"remarks" form:"remarks"`
|
||||
}
|
||||
type WafTamperRuleDetailReq struct {
|
||||
Id string `json:"id" form:"id"`
|
||||
}
|
||||
type WafTamperRuleDelReq struct {
|
||||
Id string `json:"id" form:"id"`
|
||||
}
|
||||
type WafTamperRuleRelearnReq struct {
|
||||
Id string `json:"id" form:"id"`
|
||||
}
|
||||
|
||||
// WafTamperRuleBaselineReq 查看/下载基线正文
|
||||
type WafTamperRuleBaselineReq struct {
|
||||
Id string `json:"id" form:"id"`
|
||||
}
|
||||
type WafTamperRuleSearchReq struct {
|
||||
HostCode string `json:"host_code" form:"host_code"`
|
||||
request.PageInfo
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
import "SamWaf/model/baseorm"
|
||||
|
||||
// TamperRule 网页防篡改规则(按站点、按精确 URL 存基线,反代响应基线比对)
|
||||
type TamperRule struct {
|
||||
baseorm.BaseOrm
|
||||
HostCode string `json:"host_code" gorm:"column:host_code;size:64"` // 主机代码
|
||||
Url string `json:"url" gorm:"column:url;size:1024"` // 精确URL路径(不含query),如 /index.html、/app.js
|
||||
RuleName string `json:"rule_name" gorm:"column:rule_name;size:255"` // 规则名称
|
||||
IsEnable int `json:"is_enable" gorm:"column:is_enable"` // 1启用 0停用
|
||||
IgnoreQuery int `json:"ignore_query" gorm:"column:ignore_query"` // 1忽略query照常比对(静态资源带?v=时间戳默认) / 0带query则跳过放行
|
||||
BaselineHash string `json:"baseline_hash" gorm:"column:baseline_hash;size:64"` // sha256(hex) of 解压后正文
|
||||
BaselineContent []byte `json:"baseline_content" gorm:"column:baseline_content"` // 解压后正文字节(≤上限);DB存储→多节点可同步
|
||||
ContentType string `json:"content_type" gorm:"column:content_type;size:255"` // 学习时记录的 Content-Type
|
||||
StatusCode int `json:"status_code" gorm:"column:status_code"` // 学习时记录的状态码
|
||||
ContentSize int `json:"content_size" gorm:"column:content_size"` // 基线正文字节数
|
||||
BaselineStatus int `json:"baseline_status" gorm:"column:baseline_status"` // 0未学习 1已学习 2学习失败(超限)
|
||||
BaselineMsg string `json:"baseline_msg" gorm:"column:baseline_msg;size:500"` // 学习失败原因
|
||||
LastLearnTime string `json:"last_learn_time" gorm:"column:last_learn_time;size:32"` // 上次学习时间
|
||||
TamperCount int `json:"tamper_count" gorm:"column:tamper_count"` // 累计命中篡改次数
|
||||
LastTamperTime string `json:"last_tamper_time" gorm:"column:last_tamper_time;size:32"` // 上次命中篡改时间
|
||||
Remarks string `json:"remarks" gorm:"column:remarks;size:500"` // 备注
|
||||
}
|
||||
@@ -33,6 +33,7 @@ type HostSafe struct {
|
||||
HttpAuthBases []model.HttpAuthBase //HTTP AUTH校验
|
||||
BlockingPage map[string]model.BlockingPage //自定义拦截界面
|
||||
CacheRule []model.CacheRule //CacheRule
|
||||
TamperRules []model.TamperRule //网页防篡改规则(含基线正文,供响应比对/回吐)
|
||||
PathRules []model.HostPathRule //路径路由规则
|
||||
StaticConfig model.StaticSiteConfig //解析后的静态站点安全配置,供路径规则静态服务共享
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ type ApiGroup struct {
|
||||
WafPrivateInfoRouter
|
||||
WafPrivateGroupRouter
|
||||
WafCacheRuleRouter
|
||||
WafTamperRuleRouter
|
||||
WafTunnelRouter
|
||||
WafVpConfigRouter
|
||||
WafFileRouter
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"SamWaf/api"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type WafTamperRuleRouter struct {
|
||||
}
|
||||
|
||||
func (receiver *WafTamperRuleRouter) InitWafTamperRuleRouter(group *gin.RouterGroup) {
|
||||
api := api.APIGroupAPP.WafTamperRuleApi
|
||||
router := group.Group("")
|
||||
router.POST("/api/v1/wafhost/tamperrule/add", api.AddApi)
|
||||
router.POST("/api/v1/wafhost/tamperrule/list", api.GetListApi)
|
||||
router.GET("/api/v1/wafhost/tamperrule/detail", api.GetDetailApi)
|
||||
router.POST("/api/v1/wafhost/tamperrule/edit", api.ModifyApi)
|
||||
router.GET("/api/v1/wafhost/tamperrule/del", api.DelApi)
|
||||
router.GET("/api/v1/wafhost/tamperrule/relearn", api.RelearnApi)
|
||||
router.GET("/api/v1/wafhost/tamperrule/baseline", api.GetBaselineApi)
|
||||
}
|
||||
@@ -103,6 +103,7 @@ func (receiver *WafHostService) AddApi(wafHostAddReq request.WafHostAddReq) (str
|
||||
ResponseCompressJSON: wafHostAddReq.ResponseCompressJSON,
|
||||
CookieSecurityJSON: wafHostAddReq.CookieSecurityJSON,
|
||||
CsrfJSON: wafHostAddReq.CsrfJSON,
|
||||
TamperJSON: wafHostAddReq.TamperJSON,
|
||||
IPMode: wafHostAddReq.IPMode,
|
||||
}
|
||||
global.GWAF_LOCAL_DB.Create(wafHost)
|
||||
@@ -170,6 +171,7 @@ func (receiver *WafHostService) ModifyApi(wafHostEditReq request.WafHostEditReq)
|
||||
"ResponseCompressJSON": wafHostEditReq.ResponseCompressJSON,
|
||||
"CookieSecurityJSON": wafHostEditReq.CookieSecurityJSON,
|
||||
"CsrfJSON": wafHostEditReq.CsrfJSON,
|
||||
"TamperJSON": wafHostEditReq.TamperJSON,
|
||||
"IPMode": wafHostEditReq.IPMode,
|
||||
}
|
||||
err := global.GWAF_LOCAL_DB.Debug().Model(model.Hosts{}).Where("CODE=?", wafHostEditReq.CODE).Updates(hostMap).Error
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package waf_service
|
||||
|
||||
import (
|
||||
"SamWaf/common/uuid"
|
||||
"SamWaf/customtype"
|
||||
"SamWaf/global"
|
||||
"SamWaf/model"
|
||||
"SamWaf/model/baseorm"
|
||||
"SamWaf/model/request"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type WafTamperRuleService struct{}
|
||||
|
||||
var WafTamperRuleServiceApp = new(WafTamperRuleService)
|
||||
|
||||
// validateUrl 受保护 URL 必须是精确路径:非空、以 / 开头、不含通配符与 query
|
||||
func validateTamperUrl(url string) error {
|
||||
url = strings.TrimSpace(url)
|
||||
if url == "" {
|
||||
return errors.New("受保护URL不能为空")
|
||||
}
|
||||
if !strings.HasPrefix(url, "/") {
|
||||
return errors.New("受保护URL需以 / 开头的精确路径,例如 /index.html")
|
||||
}
|
||||
if strings.Contains(url, "*") {
|
||||
return errors.New("受保护URL不支持通配符,请填写精确路径")
|
||||
}
|
||||
if strings.ContainsAny(url, "?#") {
|
||||
return errors.New("受保护URL不能包含参数(?)或锚点(#)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (receiver *WafTamperRuleService) AddApi(req request.WafTamperRuleAddReq) error {
|
||||
if err := validateTamperUrl(req.Url); err != nil {
|
||||
return err
|
||||
}
|
||||
var bean = &model.TamperRule{
|
||||
BaseOrm: baseorm.BaseOrm{
|
||||
Id: uuid.GenUUID(),
|
||||
USER_CODE: global.GWAF_USER_CODE,
|
||||
Tenant_ID: global.GWAF_TENANT_ID,
|
||||
CREATE_TIME: customtype.JsonTime(time.Now()),
|
||||
UPDATE_TIME: customtype.JsonTime(time.Now()),
|
||||
},
|
||||
HostCode: req.HostCode,
|
||||
Url: strings.TrimSpace(req.Url),
|
||||
RuleName: req.RuleName,
|
||||
IsEnable: req.IsEnable,
|
||||
IgnoreQuery: req.IgnoreQuery,
|
||||
BaselineStatus: 0, // 待学习
|
||||
Remarks: req.Remarks,
|
||||
}
|
||||
global.GWAF_LOCAL_DB.Create(bean)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (receiver *WafTamperRuleService) CheckIsExistApi(req request.WafTamperRuleAddReq) int {
|
||||
var total int64 = 0
|
||||
global.GWAF_LOCAL_DB.Model(&model.TamperRule{}).Where("host_code=? and url=?", req.HostCode, strings.TrimSpace(req.Url)).Count(&total)
|
||||
return int(total)
|
||||
}
|
||||
|
||||
func (receiver *WafTamperRuleService) ModifyApi(req request.WafTamperRuleEditReq) error {
|
||||
if err := validateTamperUrl(req.Url); err != nil {
|
||||
return err
|
||||
}
|
||||
// 同站点同 URL 唯一(排除自身)
|
||||
var total int64 = 0
|
||||
global.GWAF_LOCAL_DB.Model(&model.TamperRule{}).Where("host_code=? and url=? and id<>?", req.HostCode, strings.TrimSpace(req.Url), req.Id).Count(&total)
|
||||
if total > 0 {
|
||||
return errors.New("同站点下该URL的防篡改规则已存在")
|
||||
}
|
||||
|
||||
// 若 URL 变化则基线作废,需重新学习
|
||||
var old model.TamperRule
|
||||
global.GWAF_LOCAL_DB.Where("id=?", req.Id).Find(&old)
|
||||
|
||||
beanMap := map[string]interface{}{
|
||||
"HostCode": req.HostCode,
|
||||
"Url": strings.TrimSpace(req.Url),
|
||||
"RuleName": req.RuleName,
|
||||
"IsEnable": req.IsEnable,
|
||||
"IgnoreQuery": req.IgnoreQuery,
|
||||
"Remarks": req.Remarks,
|
||||
"UPDATE_TIME": customtype.JsonTime(time.Now()),
|
||||
}
|
||||
if old.Id != "" && old.Url != strings.TrimSpace(req.Url) {
|
||||
beanMap["BaselineStatus"] = 0
|
||||
beanMap["BaselineHash"] = ""
|
||||
beanMap["BaselineContent"] = []byte{}
|
||||
beanMap["BaselineMsg"] = "URL已变更,待重新学习"
|
||||
}
|
||||
err := global.GWAF_LOCAL_DB.Model(model.TamperRule{}).Where("id = ?", req.Id).Updates(beanMap).Error
|
||||
return err
|
||||
}
|
||||
|
||||
// RelearnApi 触发重新学习:清空基线状态,下次访问该 URL 时重新捕获
|
||||
func (receiver *WafTamperRuleService) RelearnApi(req request.WafTamperRuleRelearnReq) error {
|
||||
beanMap := map[string]interface{}{
|
||||
"BaselineStatus": 0,
|
||||
"BaselineHash": "",
|
||||
"BaselineContent": []byte{},
|
||||
"ContentSize": 0,
|
||||
"BaselineMsg": "已标记重新学习",
|
||||
"UPDATE_TIME": customtype.JsonTime(time.Now()),
|
||||
}
|
||||
return global.GWAF_LOCAL_DB.Model(model.TamperRule{}).Where("id = ?", req.Id).Updates(beanMap).Error
|
||||
}
|
||||
|
||||
func (receiver *WafTamperRuleService) GetDetailApi(req request.WafTamperRuleDetailReq) model.TamperRule {
|
||||
var bean model.TamperRule
|
||||
// 列表/详情不带大 blob,避免加载慢
|
||||
global.GWAF_LOCAL_DB.Omit("baseline_content").Where("id=?", req.Id).Find(&bean)
|
||||
return bean
|
||||
}
|
||||
|
||||
func (receiver *WafTamperRuleService) GetDetailByIdApi(id string) model.TamperRule {
|
||||
var bean model.TamperRule
|
||||
global.GWAF_LOCAL_DB.Omit("baseline_content").Where("id=?", id).Find(&bean)
|
||||
return bean
|
||||
}
|
||||
|
||||
// GetBaselineApi 按需取基线正文(含 blob),供“查看基线”弹窗
|
||||
func (receiver *WafTamperRuleService) GetBaselineApi(id string) model.TamperRule {
|
||||
var bean model.TamperRule
|
||||
global.GWAF_LOCAL_DB.Where("id=?", id).Find(&bean)
|
||||
return bean
|
||||
}
|
||||
|
||||
func (receiver *WafTamperRuleService) GetListApi(req request.WafTamperRuleSearchReq) ([]model.TamperRule, int64, error) {
|
||||
var list []model.TamperRule
|
||||
var total int64 = 0
|
||||
|
||||
var whereField = ""
|
||||
var whereValues []interface{}
|
||||
if len(req.HostCode) > 0 {
|
||||
whereField = " host_code=? "
|
||||
whereValues = append(whereValues, req.HostCode)
|
||||
}
|
||||
|
||||
// Omit baseline_content:列表绝不携带大 blob
|
||||
global.GWAF_LOCAL_DB.Model(&model.TamperRule{}).Omit("baseline_content").Where(whereField, whereValues...).Limit(req.PageSize).Offset(req.PageSize * (req.PageIndex - 1)).Order("create_time desc").Find(&list)
|
||||
global.GWAF_LOCAL_DB.Model(&model.TamperRule{}).Where(whereField, whereValues...).Count(&total)
|
||||
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
func (receiver *WafTamperRuleService) DelApi(req request.WafTamperRuleDelReq) error {
|
||||
var bean model.TamperRule
|
||||
err := global.GWAF_LOCAL_DB.Where("id = ?", req.Id).First(&bean).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = global.GWAF_LOCAL_DB.Where("id = ?", req.Id).Delete(model.TamperRule{}).Error
|
||||
return err
|
||||
}
|
||||
@@ -1089,6 +1089,39 @@ func RunCoreDBMigrations(db *gorm.DB) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "202607010001_add_tamper_protection",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
zlog.Info("迁移 202607010001: 为 hosts 表添加 tamper_json 字段并新建 tamper_rule 表")
|
||||
if !tx.Migrator().HasColumn(&model.Hosts{}, "tamper_json") {
|
||||
if err := tx.Migrator().AddColumn(&model.Hosts{}, "tamper_json"); err != nil {
|
||||
return fmt.Errorf("添加 tamper_json 字段失败: %w", err)
|
||||
}
|
||||
defaultJSON := `{"is_enable":0,"action":"replace","max_size_kb":1024}`
|
||||
if err := tx.Exec("UPDATE hosts SET tamper_json = ? WHERE tamper_json IS NULL OR tamper_json = ''", defaultJSON).Error; err != nil {
|
||||
zlog.Warn("设置 tamper_json 默认值失败", "error", err.Error())
|
||||
}
|
||||
} else {
|
||||
zlog.Info("tamper_json 字段已存在,跳过添加")
|
||||
}
|
||||
// 创建/同步网页防篡改规则表(幂等)
|
||||
if err := tx.AutoMigrate(&model.TamperRule{}); err != nil {
|
||||
return fmt.Errorf("同步 tamper_rule 表失败: %w", err)
|
||||
}
|
||||
zlog.Info("网页防篡改结构迁移成功")
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
zlog.Info("回滚 202607010001: 删除 tamper_json 字段与 tamper_rule 表")
|
||||
if tx.Migrator().HasColumn(&model.Hosts{}, "tamper_json") {
|
||||
_ = tx.Migrator().DropColumn(&model.Hosts{}, "tamper_json")
|
||||
}
|
||||
if tx.Migrator().HasTable(&model.TamperRule{}) {
|
||||
return tx.Migrator().DropTable(&model.TamperRule{})
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "202606290001_add_rbac_and_password_policy",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"SamWaf/model"
|
||||
"SamWaf/model/wafenginmodel"
|
||||
"bytes"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
@@ -296,6 +297,11 @@ func (waf *WafEngine) serveStaticFile(w http.ResponseWriter, r *http.Request, co
|
||||
return true
|
||||
}
|
||||
|
||||
// 网页防篡改:受保护静态文件的基线比对/回吐(命中 replace 则短路,不再走正常伺服)
|
||||
if waf.checkAndServeStaticTamper(w, r, absFullPath, config, weblog, hostsafe) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 记录合法的静态文件访问到日志队列
|
||||
waf.logStaticFileAccess(r.URL.Path, r.RemoteAddr, fileInfo.Size(), weblog, hostsafe)
|
||||
|
||||
@@ -522,6 +528,84 @@ func (waf *WafEngine) setSecurityHeaders(w http.ResponseWriter, config model.Sta
|
||||
}
|
||||
}
|
||||
|
||||
// checkAndServeStaticTamper 静态伺服链路的网页防篡改:命中受保护规则则读盘比对基线。
|
||||
// 静态站点服务 / 路径路由静态文件走 http.ServeFile 直接落盘伺服、不过 modifyResponse,
|
||||
// 故在此单独挂钩。返回 true 表示已完整处理(已回吐正确副本并记日志),调用方应直接 return;
|
||||
// 返回 false 表示未拦截(未开启/非GET/无匹配规则/带参跳过/未学习/一致/仅告警),走正常静态伺服。
|
||||
func (waf *WafEngine) checkAndServeStaticTamper(w http.ResponseWriter, r *http.Request, absFullPath string, config model.StaticSiteConfig, weblog *innerbean.WebLog, hostsafe *wafenginmodel.HostSafe) bool {
|
||||
cfg := model.ParseTamperConfig(hostsafe.Host.TamperJSON)
|
||||
if cfg.IsEnable != 1 {
|
||||
return false
|
||||
}
|
||||
// 只对 GET 比对(HEAD 无正文,交由正常伺服)
|
||||
if !strings.EqualFold(r.Method, http.MethodGet) {
|
||||
return false
|
||||
}
|
||||
rule := matchTamperRule(hostsafe.TamperRules, r.URL.Path)
|
||||
if rule == nil {
|
||||
return false
|
||||
}
|
||||
if rule.IgnoreQuery == 0 && r.URL.RawQuery != "" {
|
||||
return false
|
||||
}
|
||||
// 静态文件即基线原文(磁盘未压缩),直接读盘做哈希基准
|
||||
content, err := os.ReadFile(absFullPath)
|
||||
if err != nil || len(content) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
switch evaluateTamper(sha256Hex(content), *rule) {
|
||||
case tamperCapture:
|
||||
waf.captureTamperBaseline(rule, content, staticContentType(absFullPath, content), http.StatusOK, cfg)
|
||||
return false
|
||||
case tamperPass, tamperSkip:
|
||||
return false
|
||||
case tamperTampered:
|
||||
replaced := cfg.Action != "alert"
|
||||
// 统计 + 告警(异步,文案区分是否已替换)
|
||||
waf.onTamperDetected(rule, weblog, replaced)
|
||||
weblog.RULE = "网页防篡改"
|
||||
weblog.RISK_LEVEL = 3
|
||||
weblog.GUEST_IDENTIFICATION = "网页篡改"
|
||||
if !replaced {
|
||||
// 仅告警:不替换,放行原(被篡改)文件,日志由正常流程记录
|
||||
return false
|
||||
}
|
||||
// replace:回吐基线正确副本并短路
|
||||
waf.serveStaticTamperBaseline(w, r, rule, config)
|
||||
weblog.ACTION = "阻止"
|
||||
weblog.STATUS = "200 OK"
|
||||
weblog.STATUS_CODE = http.StatusOK
|
||||
weblog.RES_CONTENT_LENGTH = int64(len(rule.BaselineContent))
|
||||
waf.enqueueTamperLog(weblog, hostsafe.Host.EXCLUDE_URL_LOG)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// serveStaticTamperBaseline 静态链路回吐基线正确副本(未压缩直出,带安全头)
|
||||
func (waf *WafEngine) serveStaticTamperBaseline(w http.ResponseWriter, r *http.Request, rule *model.TamperRule, config model.StaticSiteConfig) {
|
||||
waf.setSecurityHeaders(w, config)
|
||||
ct := rule.ContentType
|
||||
if ct == "" {
|
||||
ct = "application/octet-stream"
|
||||
}
|
||||
w.Header().Set("Content-Type", ct)
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(rule.BaselineContent)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if !strings.EqualFold(r.Method, http.MethodHead) {
|
||||
w.Write(rule.BaselineContent)
|
||||
}
|
||||
}
|
||||
|
||||
// staticContentType 依扩展名/内容推断静态文件 Content-Type(与 http.ServeFile 行为一致)
|
||||
func staticContentType(fullPath string, content []byte) string {
|
||||
if ct := mime.TypeByExtension(filepath.Ext(fullPath)); ct != "" {
|
||||
return ct
|
||||
}
|
||||
return http.DetectContentType(content)
|
||||
}
|
||||
|
||||
// logStaticFileAccess 记录成功的静态文件访问到传入的weblog
|
||||
func (waf *WafEngine) logStaticFileAccess(path, remoteAddr string, fileSize int64, weblog *innerbean.WebLog, hostsafe *wafenginmodel.HostSafe) {
|
||||
// 更新weblog信息
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
package wafenginecore
|
||||
|
||||
import (
|
||||
"SamWaf/enums"
|
||||
"SamWaf/global"
|
||||
"SamWaf/innerbean"
|
||||
"SamWaf/model"
|
||||
"SamWaf/model/spec"
|
||||
"SamWaf/utils"
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// tamperLearning 防止同一规则并发重复学习(内存态,key=ruleId)
|
||||
var tamperLearning sync.Map
|
||||
|
||||
// tamperDecision 防篡改判定结果
|
||||
type tamperDecision int
|
||||
|
||||
const (
|
||||
tamperPass tamperDecision = iota // 未篡改,放行
|
||||
tamperCapture // 未学习,需捕获基线
|
||||
tamperTampered // 已篡改
|
||||
tamperSkip // 学习失败等,跳过(不保护)
|
||||
)
|
||||
|
||||
// sha256Hex 计算字节内容的 sha256 十六进制串
|
||||
func sha256Hex(b []byte) string {
|
||||
h := sha256.Sum256(b)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// matchTamperRule 按路径精确匹配启用中的防篡改规则(r.URL.Path 天然不含 query)
|
||||
func matchTamperRule(rules []model.TamperRule, path string) *model.TamperRule {
|
||||
for i := range rules {
|
||||
if rules[i].IsEnable == 1 && rules[i].Url == path {
|
||||
return &rules[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isTamperCandidate 是否需要做防篡改比对:GET + 规则启用 + (忽略query 或 无query)
|
||||
func isTamperCandidate(method, rawQuery string, rule model.TamperRule) bool {
|
||||
if !strings.EqualFold(method, http.MethodGet) {
|
||||
return false
|
||||
}
|
||||
if rule.IsEnable != 1 {
|
||||
return false
|
||||
}
|
||||
if rule.IgnoreQuery == 0 && rawQuery != "" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// overBaselineCap 基线正文是否超过大小上限
|
||||
func overBaselineCap(size, maxKB int) bool {
|
||||
if maxKB <= 0 {
|
||||
maxKB = 1024
|
||||
}
|
||||
return size > maxKB*1024
|
||||
}
|
||||
|
||||
// evaluateTamper 依据基线状态与实时哈希判定
|
||||
func evaluateTamper(liveHash string, rule model.TamperRule) tamperDecision {
|
||||
switch rule.BaselineStatus {
|
||||
case 0:
|
||||
return tamperCapture
|
||||
case 1:
|
||||
if liveHash == rule.BaselineHash {
|
||||
return tamperPass
|
||||
}
|
||||
return tamperTampered
|
||||
default:
|
||||
return tamperSkip
|
||||
}
|
||||
}
|
||||
|
||||
// readDecompressedBody 读取响应正文并按 Content-Encoding 解压,返回解压后字节;
|
||||
// 同时把 resp.Body 复位为原始字节,供后续常规处理再次读取。
|
||||
// 以“解压后内容”为哈希基准,避免同一文件因 Accept-Encoding 不同(压/不压)导致哈希不一致。
|
||||
func readDecompressedBody(resp *http.Response) ([]byte, error) {
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 复位原始 body 供下游再次读取
|
||||
resp.Body = io.NopCloser(bytes.NewReader(raw))
|
||||
|
||||
switch strings.ToLower(resp.Header.Get("Content-Encoding")) {
|
||||
case "gzip":
|
||||
zr, e := gzip.NewReader(bytes.NewReader(raw))
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer zr.Close()
|
||||
return io.ReadAll(zr)
|
||||
case "deflate":
|
||||
fr := flate.NewReader(bytes.NewReader(raw))
|
||||
defer fr.Close()
|
||||
return io.ReadAll(fr)
|
||||
case "br":
|
||||
return io.ReadAll(brotli.NewReader(bytes.NewReader(raw)))
|
||||
case "zstd":
|
||||
zr, e := zstd.NewReader(bytes.NewReader(raw))
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer zr.Close()
|
||||
return io.ReadAll(zr)
|
||||
default:
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
|
||||
// checkAndHandleTamper 网页防篡改主流程。返回 true 表示已完整处理(回吐基线并记日志),
|
||||
// 调用方应直接 return nil;返回 false 则继续正常应答处理。
|
||||
func (waf *WafEngine) checkAndHandleTamper(resp *http.Response, r *http.Request, host string, weblog *innerbean.WebLog) bool {
|
||||
hostTarget, ok := waf.rt().HostTarget[host]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
cfg := model.ParseTamperConfig(hostTarget.Host.TamperJSON)
|
||||
if cfg.IsEnable != 1 {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(r.Method, http.MethodGet) {
|
||||
return false
|
||||
}
|
||||
// 仅对正常 200 响应学习/比对,避免把 404/500/302 学成基线
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
rule := matchTamperRule(hostTarget.TamperRules, r.URL.Path)
|
||||
if rule == nil {
|
||||
return false
|
||||
}
|
||||
if rule.IgnoreQuery == 0 && r.URL.RawQuery != "" {
|
||||
return false
|
||||
}
|
||||
|
||||
body, err := readDecompressedBody(resp)
|
||||
if err != nil || len(body) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
switch evaluateTamper(sha256Hex(body), *rule) {
|
||||
case tamperCapture:
|
||||
waf.captureTamperBaseline(rule, body, resp.Header.Get("Content-Type"), resp.StatusCode, cfg)
|
||||
return false
|
||||
case tamperPass, tamperSkip:
|
||||
return false
|
||||
case tamperTampered:
|
||||
// 命中篡改:alert 仅告警不替换;replace 回吐正确副本并短路
|
||||
replaced := cfg.Action != "alert"
|
||||
// 统计 + 告警(异步,文案区分是否已替换)
|
||||
waf.onTamperDetected(rule, weblog, replaced)
|
||||
weblog.RULE = "网页防篡改"
|
||||
weblog.RISK_LEVEL = 3
|
||||
weblog.GUEST_IDENTIFICATION = "网页篡改"
|
||||
if !replaced {
|
||||
// 仅告警:不替换,放行后端页,日志由正常流程记录
|
||||
return false
|
||||
}
|
||||
// replace:回吐基线正确副本并短路
|
||||
waf.serveTamperBaseline(resp, rule)
|
||||
// 与 WAF 其它拦截一致的处置态(访问日志「状态」列识别 放行/阻止/禁止)
|
||||
weblog.ACTION = "阻止"
|
||||
weblog.STATUS = resp.Status
|
||||
weblog.STATUS_CODE = resp.StatusCode
|
||||
weblog.RES_CONTENT_LENGTH = sanitizeContentLength(resp.ContentLength)
|
||||
weblog.ResHeader = joinHeader(resp.Header)
|
||||
waf.enqueueTamperLog(weblog, hostTarget.Host.EXCLUDE_URL_LOG)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// serveTamperBaseline 用基线正文替换响应体(基线为解压后原文,未压缩直出)
|
||||
func (waf *WafEngine) serveTamperBaseline(resp *http.Response, rule *model.TamperRule) {
|
||||
content := rule.BaselineContent
|
||||
resp.Body = io.NopCloser(bytes.NewReader(content))
|
||||
resp.Header.Del("Content-Encoding")
|
||||
if rule.ContentType != "" {
|
||||
resp.Header.Set("Content-Type", rule.ContentType)
|
||||
}
|
||||
resp.ContentLength = int64(len(content))
|
||||
resp.Header.Set("Content-Length", strconv.Itoa(len(content)))
|
||||
resp.StatusCode = http.StatusOK
|
||||
resp.Status = "200 OK"
|
||||
}
|
||||
|
||||
// captureTamperBaseline 被动捕获基线(异步、按规则去重),完成后触发热重载
|
||||
func (waf *WafEngine) captureTamperBaseline(rule *model.TamperRule, body []byte, contentType string, statusCode int, cfg model.TamperConfig) {
|
||||
if _, loaded := tamperLearning.LoadOrStore(rule.Id, struct{}{}); loaded {
|
||||
return
|
||||
}
|
||||
ruleId := rule.Id
|
||||
hostCode := rule.HostCode
|
||||
contentCopy := make([]byte, len(body))
|
||||
copy(contentCopy, body)
|
||||
|
||||
go func() {
|
||||
defer tamperLearning.Delete(ruleId)
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
upd := map[string]interface{}{
|
||||
"LastLearnTime": now,
|
||||
"ContentSize": len(contentCopy),
|
||||
}
|
||||
if overBaselineCap(len(contentCopy), cfg.MaxSizeKB) {
|
||||
upd["BaselineStatus"] = 2
|
||||
upd["BaselineMsg"] = fmt.Sprintf("正文 %d 字节超过上限 %d KB,未学习", len(contentCopy), cfg.MaxSizeKB)
|
||||
} else {
|
||||
upd["BaselineHash"] = sha256Hex(contentCopy)
|
||||
upd["BaselineContent"] = contentCopy
|
||||
upd["ContentType"] = contentType
|
||||
upd["StatusCode"] = statusCode
|
||||
upd["BaselineStatus"] = 1
|
||||
upd["BaselineMsg"] = ""
|
||||
}
|
||||
global.GWAF_LOCAL_DB.Model(&model.TamperRule{}).Where("id=?", ruleId).Updates(upd)
|
||||
waf.notifyTamperReload(hostCode)
|
||||
}()
|
||||
}
|
||||
|
||||
// onTamperDetected 命中篡改后异步累计次数并发告警;replaced 区分是否已回吐正确副本
|
||||
func (waf *WafEngine) onTamperDetected(rule *model.TamperRule, weblog *innerbean.WebLog, replaced bool) {
|
||||
ruleId := rule.Id
|
||||
domain := weblog.HOST
|
||||
srcIp := weblog.SRC_IP
|
||||
url := weblog.URL
|
||||
go func() {
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
global.GWAF_LOCAL_DB.Model(&model.TamperRule{}).Where("id=?", ruleId).Updates(map[string]interface{}{
|
||||
"TamperCount": gorm.Expr("tamper_count + 1"),
|
||||
"LastTamperTime": now,
|
||||
})
|
||||
ruleInfo := "检测到网页被篡改(仅告警,未替换):" + url
|
||||
if replaced {
|
||||
ruleInfo = "检测到网页被篡改,已回吐正确副本:" + url
|
||||
}
|
||||
global.GQEQUE_MESSAGE_DB.Enqueue(innerbean.RuleMessageInfo{
|
||||
BaseMessageInfo: innerbean.BaseMessageInfo{OperaType: "网页防篡改", Server: global.GWAF_CUSTOM_SERVER_NAME},
|
||||
Domain: domain,
|
||||
RuleInfo: ruleInfo,
|
||||
Ip: fmt.Sprintf("%s (%s)", srcIp, utils.GetCountry(srcIp)),
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// notifyTamperReload 重新读取该站点防篡改规则并推送到引擎热更新
|
||||
func (waf *WafEngine) notifyTamperReload(hostCode string) {
|
||||
var list []model.TamperRule
|
||||
global.GWAF_LOCAL_DB.Where("host_code=?", hostCode).Find(&list)
|
||||
global.GWAF_CHAN_MSG <- spec.ChanCommonHost{
|
||||
HostCode: hostCode,
|
||||
Type: enums.ChanTypeTamperRule,
|
||||
Content: list,
|
||||
}
|
||||
}
|
||||
|
||||
// enqueueTamperLog 防篡改替换短路时记录访问日志(复用 all/abnormal + EXCLUDE_URL_LOG 规则)。
|
||||
// 反代/静态两条链路共用:调用方先填好 STATUS/STATUS_CODE/RES_CONTENT_LENGTH/ResHeader/ACTION,再传各自的 EXCLUDE_URL_LOG。
|
||||
func (waf *WafEngine) enqueueTamperLog(weblog *innerbean.WebLog, excludeURLLog string) {
|
||||
datetimeNow := time.Now()
|
||||
weblog.TimeSpent = datetimeNow.UnixNano()/1e6 - weblog.UNIX_ADD_TIME
|
||||
weblog.TASK_FLAG = 1
|
||||
|
||||
// 防篡改替换属于异常事件,all / abnormal 两种模式都记录
|
||||
if excludeURLLog != "" {
|
||||
for _, line := range strings.Split(excludeURLLog, "\n") {
|
||||
if line != "" && strings.HasPrefix(weblog.URL, line) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
global.GQEQUE_LOG_DB.Enqueue(weblog)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package wafenginecore
|
||||
|
||||
import (
|
||||
"SamWaf/model"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMatchTamperRule(t *testing.T) {
|
||||
rules := []model.TamperRule{
|
||||
{Url: "/index.html", IsEnable: 1},
|
||||
{Url: "/app.js", IsEnable: 1},
|
||||
{Url: "/disabled.html", IsEnable: 0},
|
||||
}
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool // 是否匹配到
|
||||
}{
|
||||
{"/index.html", true},
|
||||
{"/app.js", true},
|
||||
{"/disabled.html", false}, // 停用
|
||||
{"/notfound", false},
|
||||
{"/INDEX.HTML", false}, // 大小写敏感
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := matchTamperRule(rules, c.path)
|
||||
if (got != nil) != c.want {
|
||||
t.Errorf("matchTamperRule(%q) matched=%v, 期望 %v", c.path, got != nil, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTamperCandidate(t *testing.T) {
|
||||
ruleIgnore := model.TamperRule{IsEnable: 1, IgnoreQuery: 1}
|
||||
ruleStrict := model.TamperRule{IsEnable: 1, IgnoreQuery: 0}
|
||||
ruleOff := model.TamperRule{IsEnable: 0, IgnoreQuery: 1}
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
rawQuery string
|
||||
rule model.TamperRule
|
||||
want bool
|
||||
}{
|
||||
{"GET无参忽略query", "GET", "", ruleIgnore, true},
|
||||
{"GET带参忽略query照常", "GET", "v=123", ruleIgnore, true},
|
||||
{"GET带参严格跳过", "GET", "v=123", ruleStrict, false},
|
||||
{"GET无参严格", "GET", "", ruleStrict, true},
|
||||
{"POST不比对", "POST", "", ruleIgnore, false},
|
||||
{"HEAD不比对", "HEAD", "", ruleIgnore, false},
|
||||
{"规则停用", "GET", "", ruleOff, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isTamperCandidate(c.method, c.rawQuery, c.rule); got != c.want {
|
||||
t.Errorf("%s: isTamperCandidate=%v, 期望 %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverBaselineCap(t *testing.T) {
|
||||
cases := []struct {
|
||||
size int
|
||||
maxKB int
|
||||
want bool
|
||||
}{
|
||||
{1024, 1, false}, // 恰好 1KB,不超
|
||||
{1025, 1, true}, // 超 1KB
|
||||
{1024 * 1024, 1024, false}, // 恰好 1MB
|
||||
{1024*1024 + 1, 1024, true},
|
||||
{500, 0, false}, // maxKB<=0 用默认 1024
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := overBaselineCap(c.size, c.maxKB); got != c.want {
|
||||
t.Errorf("overBaselineCap(%d,%d)=%v, 期望 %v", c.size, c.maxKB, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateTamper(t *testing.T) {
|
||||
learned := model.TamperRule{BaselineStatus: 1, BaselineHash: "abc"}
|
||||
unlearned := model.TamperRule{BaselineStatus: 0}
|
||||
failed := model.TamperRule{BaselineStatus: 2}
|
||||
cases := []struct {
|
||||
name string
|
||||
liveHash string
|
||||
rule model.TamperRule
|
||||
want tamperDecision
|
||||
}{
|
||||
{"未学习→捕获", "abc", unlearned, tamperCapture},
|
||||
{"已学习哈希一致→放行", "abc", learned, tamperPass},
|
||||
{"已学习哈希不一致→篡改", "xyz", learned, tamperTampered},
|
||||
{"学习失败→跳过", "abc", failed, tamperSkip},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := evaluateTamper(c.liveHash, c.rule); got != c.want {
|
||||
t.Errorf("%s: evaluateTamper=%v, 期望 %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSha256HexStable(t *testing.T) {
|
||||
a := sha256Hex([]byte("hello world"))
|
||||
b := sha256Hex([]byte("hello world"))
|
||||
c := sha256Hex([]byte("hello world!"))
|
||||
if a != b {
|
||||
t.Errorf("相同内容哈希应一致: %s vs %s", a, b)
|
||||
}
|
||||
if a == c {
|
||||
t.Errorf("不同内容哈希应不同")
|
||||
}
|
||||
if len(a) != 64 {
|
||||
t.Errorf("sha256 十六进制应为64字符,实际 %d", len(a))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDecompressedBody(t *testing.T) {
|
||||
plain := []byte("<html>hello 防篡改</html>")
|
||||
|
||||
// 1) 无压缩
|
||||
respPlain := &http.Response{
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(bytes.NewReader(plain)),
|
||||
}
|
||||
got, err := readDecompressedBody(respPlain)
|
||||
if err != nil || !bytes.Equal(got, plain) {
|
||||
t.Fatalf("无压缩读取失败: err=%v got=%q", err, got)
|
||||
}
|
||||
// 复位后 body 应可再次读取到原始字节
|
||||
again, _ := io.ReadAll(respPlain.Body)
|
||||
if !bytes.Equal(again, plain) {
|
||||
t.Errorf("resp.Body 未正确复位,got=%q", again)
|
||||
}
|
||||
|
||||
// 2) gzip 压缩:解压后应与原文一致(哈希基准稳定)
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
gw.Write(plain)
|
||||
gw.Close()
|
||||
respGzip := &http.Response{
|
||||
Header: http.Header{"Content-Encoding": []string{"gzip"}},
|
||||
Body: io.NopCloser(bytes.NewReader(buf.Bytes())),
|
||||
}
|
||||
gotGzip, err := readDecompressedBody(respGzip)
|
||||
if err != nil || !bytes.Equal(gotGzip, plain) {
|
||||
t.Fatalf("gzip 解压失败: err=%v got=%q", err, gotGzip)
|
||||
}
|
||||
// 压缩与不压缩,解压后哈希必须一致
|
||||
if sha256Hex(got) != sha256Hex(gotGzip) {
|
||||
t.Errorf("同一内容压/不压后哈希应一致")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticContentType(t *testing.T) {
|
||||
// 有扩展名:按扩展名推断(不同机器 mime 表可能有别,只断言非空)
|
||||
if ct := staticContentType("/site/index.html", []byte("<html></html>")); ct == "" {
|
||||
t.Errorf(".html 应能推断出 Content-Type,实际为空")
|
||||
}
|
||||
// 无扩展名 + HTML 内容:回退 http.DetectContentType
|
||||
if ct := staticContentType("/site/noext", []byte("<html>hi</html>")); !strings.Contains(ct, "text/html") {
|
||||
t.Errorf("无扩展名 HTML 内容应回退推断为 text/html,实际 %q", ct)
|
||||
}
|
||||
// 无扩展名 + 未知二进制:回退为 application/octet-stream
|
||||
bin := []byte{0x00, 0x01, 0x02, 0x03, 0x04}
|
||||
if ct := staticContentType("/site/blob", bin); !strings.Contains(ct, "application/octet-stream") {
|
||||
t.Errorf("无扩展名未知内容应回退为 octet-stream,实际 %q", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeStaticTamperBaseline(t *testing.T) {
|
||||
waf := &WafEngine{}
|
||||
baseline := []byte("<html>correct baseline</html>")
|
||||
rule := &model.TamperRule{ContentType: "text/html; charset=utf-8", BaselineContent: baseline}
|
||||
|
||||
// GET:回吐基线正文 + 头部
|
||||
getRec := httptest.NewRecorder()
|
||||
waf.serveStaticTamperBaseline(getRec, httptest.NewRequest("GET", "/index.html", nil), rule, model.StaticSiteConfig{})
|
||||
if getRec.Code != http.StatusOK {
|
||||
t.Errorf("状态码应为 200,实际 %d", getRec.Code)
|
||||
}
|
||||
if !bytes.Equal(getRec.Body.Bytes(), baseline) {
|
||||
t.Errorf("GET 应回吐基线正文,实际 %q", getRec.Body.String())
|
||||
}
|
||||
if ct := getRec.Header().Get("Content-Type"); ct != rule.ContentType {
|
||||
t.Errorf("Content-Type 应为 %q,实际 %q", rule.ContentType, ct)
|
||||
}
|
||||
if cl := getRec.Header().Get("Content-Length"); cl != "29" {
|
||||
t.Errorf("Content-Length 应为 29,实际 %q", cl)
|
||||
}
|
||||
|
||||
// HEAD:只出头部不出正文
|
||||
headRec := httptest.NewRecorder()
|
||||
waf.serveStaticTamperBaseline(headRec, httptest.NewRequest("HEAD", "/index.html", nil), rule, model.StaticSiteConfig{})
|
||||
if headRec.Body.Len() != 0 {
|
||||
t.Errorf("HEAD 不应写正文,实际 %d 字节", headRec.Body.Len())
|
||||
}
|
||||
if cl := headRec.Header().Get("Content-Length"); cl != "29" {
|
||||
t.Errorf("HEAD 仍应带 Content-Length 29,实际 %q", cl)
|
||||
}
|
||||
|
||||
// ContentType 为空 → 回退 octet-stream
|
||||
emptyRec := httptest.NewRecorder()
|
||||
emptyRule := &model.TamperRule{BaselineContent: []byte("x")}
|
||||
waf.serveStaticTamperBaseline(emptyRec, httptest.NewRequest("GET", "/x", nil), emptyRule, model.StaticSiteConfig{})
|
||||
if ct := emptyRec.Header().Get("Content-Type"); ct != "application/octet-stream" {
|
||||
t.Errorf("空 ContentType 应回退 octet-stream,实际 %q", ct)
|
||||
}
|
||||
}
|
||||
@@ -1109,6 +1109,12 @@ func (waf *WafEngine) modifyResponse() func(*http.Response) error {
|
||||
// 上游 chunked 传输时 resp.ContentLength 为 -1,先按 0 计;非静态资源后续会用真实落盘字节数回填
|
||||
weblogfrist.RES_CONTENT_LENGTH = sanitizeContentLength(resp.ContentLength)
|
||||
|
||||
// 网页防篡改:反代响应基线比对(命中且 replace 动作则回吐正确副本并短路)
|
||||
if waf.checkAndHandleTamper(resp, r, host, weblogfrist) {
|
||||
weblogfrist.BackendCheckCost = time.Now().UnixNano()/1e6 - backendCheckStart
|
||||
return nil
|
||||
}
|
||||
|
||||
//返回内容的类型
|
||||
respContentType := strings.ToLower(resp.Header.Get("Content-Type"))
|
||||
respContentType = strings.Replace(respContentType, "; charset=utf-8", "", -1)
|
||||
|
||||
@@ -229,6 +229,10 @@ func (waf *WafEngine) LoadHost(inHost model.Hosts) []innerbean.ServerRunTime {
|
||||
var cacheRuleList []model.CacheRule
|
||||
global.GWAF_LOCAL_DB.Where("host_code=? ", inHost.Code).Find(&cacheRuleList)
|
||||
|
||||
//查询网页防篡改规则(含基线正文,供响应比对/回吐)
|
||||
var tamperRuleList []model.TamperRule
|
||||
global.GWAF_LOCAL_DB.Where("host_code=? ", inHost.Code).Find(&tamperRuleList)
|
||||
|
||||
//查询路径路由规则
|
||||
var pathRuleList []model.HostPathRule
|
||||
global.GWAF_LOCAL_DB.Where("host_code=? ", inHost.Code).Order("priority asc, create_time asc").Find(&pathRuleList)
|
||||
@@ -263,6 +267,7 @@ func (waf *WafEngine) LoadHost(inHost model.Hosts) []innerbean.ServerRunTime {
|
||||
HttpAuthBases: httpAuthList,
|
||||
BlockingPage: blockingPageMap,
|
||||
CacheRule: cacheRuleList,
|
||||
TamperRules: tamperRuleList,
|
||||
PathRules: pathRuleList,
|
||||
StaticConfig: staticCfg,
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@ func (web *WafWebManager) initRouter(r *gin.Engine) {
|
||||
router.ApiGroupApp.InitWafPrivateInfoRouter(RouterGroup)
|
||||
router.ApiGroupApp.InitWafPrivateGroupRouter(RouterGroup)
|
||||
router.ApiGroupApp.InitWafCacheRuleRouter(RouterGroup)
|
||||
router.ApiGroupApp.InitWafTamperRuleRouter(RouterGroup)
|
||||
router.ApiGroupApp.InitWafTunnelRouter(RouterGroup)
|
||||
router.ApiGroupApp.InitWafVpConfigRouter(RouterGroup)
|
||||
router.ApiGroupApp.InitWafFileRouter(RouterGroup)
|
||||
|
||||
Reference in New Issue
Block a user