fix: record CC-ban-window block (#1016)

#1001
This commit is contained in:
samwafgo
2026-10-14 09:47:55 +08:00
committed by GitHub
21 changed files with 1020 additions and 66 deletions
+3 -3
View File
@@ -11,10 +11,10 @@ import (
type WafIPLookupApi struct {
}
// LookupApi IP归属查询
// @Summary IP归属查询
// LookupApi IP策略命中查询
// @Summary IP策略命中查询
// @Description 查一个IP当前落在哪些名单里:IP黑/白名单、IP组、威胁情报IP、IP失败封禁、CC封禁、系统防火墙封禁、CDN回源段
// @Tags 网站防护-IP归属查询
// @Tags 网站防护-IP策略命中查询
// @Produce json
// @Param ip query string true "待查询的IP"
// @Param sources query string false "只查指定来源(逗号分隔),留空查全部"
+38 -2
View File
@@ -26,6 +26,19 @@ import (
type WafLogAPi struct {
}
var logRequestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$`)
func normalizeLogRequestID(value string, required bool) (string, error) {
value = strings.TrimSpace(value)
if value == "" && !required {
return "", nil
}
if !logRequestIDPattern.MatchString(value) {
return "", fmt.Errorf("访问识别码格式不正确(需为 1–64 位字母、数字或分隔符)")
}
return value, nil
}
// GetDetailApi 获取攻击日志详情
// @Summary 获取攻击日志详情
// @Description 根据 req_uuid 获取单条攻击日志详情(含请求体、响应体)
@@ -47,7 +60,16 @@ func (w *WafLogAPi) GetDetailApi(c *gin.Context) {
response.FailWithMessage("正在切换数据库请等待", c)
return
}
wafLog, _ := wafLogService.GetDetailApi(req)
req.REQ_UUID, err = normalizeLogRequestID(req.REQ_UUID, true)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
wafLog, err := wafLogService.GetDetailApi(req)
if err != nil {
response.FailWithMessage("获取日志详情失败:"+err.Error(), c)
return
}
response.OkWithDetailed(wafLog, "获取成功", c)
} else {
response.FailWithMessage("解析失败", c)
@@ -73,6 +95,11 @@ func (w *WafLogAPi) GetListApi(c *gin.Context) {
response.FailWithMessage("正在切换数据库请等待", c)
return
}
req.ReqUuid, err = normalizeLogRequestID(req.ReqUuid, false)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
wafLogs, total, meta, err2 := wafLogService.GetListApiWithMeta(req)
if err2 != nil {
response.FailWithMessage("访问列表失败:"+err2.Error(), c)
@@ -317,7 +344,16 @@ func (w *WafLogAPi) GetHttpCopyMaskApi(c *gin.Context) {
response.FailWithMessage("正在切换数据库请等待", c)
return
}
wafLog, _ := wafLogService.GetDetailApi(req)
req.REQ_UUID, err = normalizeLogRequestID(req.REQ_UUID, true)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
wafLog, err := wafLogService.GetDetailApi(req)
if err != nil {
response.FailWithMessage("获取日志详情失败:"+err.Error(), c)
return
}
if req.OutputFormat == "curl" {
response.OkWithDetailed(GenerateCurlRequest(wafLog), "获取成功", c)
+172
View File
@@ -0,0 +1,172 @@
package api
import (
"SamWaf/global"
"SamWaf/innerbean"
"SamWaf/model"
"SamWaf/model/common/response"
"SamWaf/service/waf_service"
"SamWaf/wafdb/dialect"
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func setupLogAPITest(t *testing.T) *gorm.DB {
t.Helper()
priorDialect := func() (d dialect.DBDialect) {
defer func() { _ = recover() }()
return dialect.Get()
}()
dialect.Register(&dialect.SQLiteDialect{})
db := openNotifyTestDB(t)
if err := db.AutoMigrate(&model.ShareDb{}, &model.AccessLog{}, &model.SecurityEvent{}, &model.EventPayload{}, &innerbean.WebLog{}); err != nil {
t.Fatal(err)
}
oldCore, oldLog := global.GWAF_LOCAL_DB, global.GWAF_LOCAL_LOG_DB
oldTenant, oldUser, oldChanging := global.GWAF_TENANT_ID, global.GWAF_USER_CODE, global.GDATA_CURRENT_CHANGE
global.GWAF_LOCAL_DB, global.GWAF_LOCAL_LOG_DB = db, db
global.GWAF_TENANT_ID, global.GWAF_USER_CODE, global.GDATA_CURRENT_CHANGE = "api-log-tenant", "api-log-user", false
waf_service.InvalidateShardCounts()
t.Cleanup(func() {
global.GWAF_LOCAL_DB, global.GWAF_LOCAL_LOG_DB = oldCore, oldLog
global.GWAF_TENANT_ID, global.GWAF_USER_CODE, global.GDATA_CURRENT_CHANGE = oldTenant, oldUser, oldChanging
waf_service.InvalidateShardCounts()
dialect.Register(priorDialect)
})
if err := db.Callback().Query().Before("gorm:query").Register("tenant_plugin:before_query", func(d *gorm.DB) {
d.Where("tenant_id = ? and user_code = ?", global.GWAF_TENANT_ID, global.GWAF_USER_CODE)
}); err != nil {
t.Fatal(err)
}
row := model.SecurityEvent{LogNarrow: model.LogNarrow{
ReqUUID: "req_nonstandard_0", TenantId: global.GWAF_TENANT_ID, UserCode: global.GWAF_USER_CODE,
URL: "https://example.test/check", Method: "GET", HostCode: "api-host", UNIX_ADD_TIME: 1,
}}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.AccessLog{LogNarrow: row.LogNarrow}).Error; err != nil {
t.Fatal(err)
}
return db
}
func callLogTestAPI(t *testing.T, handler gin.HandlerFunc, method, target, body string) response.Response {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) { c.Set("is_openapi", true) })
r.Handle(method, "/t", handler)
req := httptest.NewRequest(method, target, bytes.NewBufferString(body))
if method == http.MethodPost {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
var result response.Response
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("response decode: %v, body=%s", err, rec.Body.String())
}
return result
}
func TestLogDetailAPIsHandleErrorsAndLegacyIDs(t *testing.T) {
db := setupLogAPITest(t)
api := &WafLogAPi{}
for _, handler := range []struct {
name string
call gin.HandlerFunc
}{{"detail", api.GetDetailApi}, {"copy", api.GetHttpCopyMaskApi}} {
for _, format := range []string{"raw", "curl"} {
for _, tc := range []struct {
name, id, shard, wantMsg string
wantCode int
}{
{"legacy-form", "req_nonstandard_0", "local_log.db", "获取成功", response.SUCCESS},
{"trimmed", " req_nonstandard_0 ", "local_log.db", "获取成功", response.SUCCESS},
{"missing", "req_missing", "local_log.db", "未找到该识别码", response.ERROR},
{"missing-auto", "req_missing", "auto", "未找到该识别码", response.ERROR},
{"missing-shard", "req_missing", "local_log_199901.db", "存储已不存在", response.ERROR},
{"empty", "", "local_log.db", "解析失败", response.ERROR},
{"whitespace", " ", "local_log.db", "格式不正确", response.ERROR},
{"too-long", strings.Repeat("a", 65), "local_log.db", "解析失败", response.ERROR},
{"invalid", "req_bad\nvalue", "local_log.db", "格式不正确", response.ERROR},
} {
t.Run(handler.name+"/"+format+"/"+tc.name, func(t *testing.T) {
query := url.Values{"REQ_UUID": {tc.id}, "current_db_name": {tc.shard}, "output_format": {format}}
got := callLogTestAPI(t, handler.call, http.MethodGet, "/t?"+query.Encode(), "")
if got.Code != tc.wantCode || !strings.Contains(got.Msg, tc.wantMsg) {
t.Fatalf("expected code=%d msg contains %q, got %+v", tc.wantCode, tc.wantMsg, got)
}
if got.Code != response.SUCCESS {
data, ok := got.Data.(map[string]interface{})
if !ok || len(data) != 0 {
t.Fatalf("failure must not return empty detail or generated request: %+v", got.Data)
}
} else if handler.name == "detail" {
data, ok := got.Data.(map[string]interface{})
if !ok || data["req_uuid"] != "req_nonstandard_0" {
t.Fatalf("REQ_UUID form binding failed: %+v", got.Data)
}
} else if data, ok := got.Data.(string); !ok || !strings.Contains(data, "GET") || strings.Contains(data, "curl -X ") {
t.Fatalf("copy should contain a populated request: %+v", got.Data)
}
})
}
}
}
if err := db.Callback().Query().Before("gorm:query").Register("test:read_error", func(db *gorm.DB) { db.AddError(errors.New("test read failure")) }); err != nil {
t.Fatal(err)
}
for _, handler := range []gin.HandlerFunc{api.GetDetailApi, api.GetHttpCopyMaskApi} {
got := callLogTestAPI(t, handler, http.MethodGet, "/t?REQ_UUID=req_nonstandard_0&current_db_name=local_log.db&output_format=curl", "")
if got.Code != response.ERROR || !strings.Contains(got.Msg, "查询日志详情失败") {
t.Fatalf("read failure must propagate: %+v", got)
}
}
}
func TestLogListAPIUUIDValidationAndSelectedShard(t *testing.T) {
setupLogAPITest(t)
api := &WafLogAPi{}
for _, tc := range []struct {
name, id string
wantCode int
wantTotal float64
}{
{"legacy-trimmed", " req_nonstandard_0 ", response.SUCCESS, 1},
{"missing", "req_missing", response.SUCCESS, 0},
{"too-long", strings.Repeat("a", 65), response.ERROR, 0},
{"invalid", "req_bad value", response.ERROR, 0},
} {
t.Run(tc.name, func(t *testing.T) {
body, err := json.Marshal(map[string]interface{}{
"req_uuid": tc.id, "current_db_name": "local_log_199901.db",
"unix_add_time_begin": "4102444800000", "unix_add_time_end": "4102531200000",
"sort_by": "unix_add_time", "sort_descending": "desc", "pageIndex": 1, "pageSize": 10,
})
if err != nil {
t.Fatal(err)
}
got := callLogTestAPI(t, api.GetListApi, http.MethodPost, "/t", string(body))
if got.Code != tc.wantCode {
t.Fatalf("expected code=%d got %+v", tc.wantCode, got)
}
if got.Code == response.SUCCESS {
data, ok := got.Data.(map[string]interface{})
if !ok || data["total"] != tc.wantTotal || data["uuid_lookup"] != true {
t.Fatalf("selected shard/date UUID query: %+v", got.Data)
}
}
})
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
package global
// GLOBAL_DEFAULT_BLOCK_INFO 默认被拦截的HTML的内容
var GLOBAL_DEFAULT_BLOCK_INFO string = "<!DOCTYPE html>\n <html lang=\"zh-CN\">\n <head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>您的访问被阻止</title>\n <style>\n /* 基本样式 */\n body {\n font-family: 'Arial', sans-serif;\n background-color: #f4f7fa;\n margin: 0;\n padding: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n height: 100vh;\n color: #333;\n }\n \n /* 页面内容的容器 */\n .container {\n background-color: #ffffff;\n border-radius: 8px;\n padding: 40px;\n box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);\n text-align: center;\n max-width: 600px;\n width: 100%;\n }\n \n /* 标题样式 */\n h1 {\n font-size: 2.5em;\n color: #d9534f;\n margin: 0;\n }\n \n /* 访问识别码样式 */\n h3 {\n font-size: 1.5em;\n color: #5bc0de;\n margin: 20px 0;\n }\n \n /* 提示文字 */\n .message {\n font-size: 1.1em;\n color: #999;\n margin-bottom: 30px;\n }\n \n /* 返回首页按钮样式 */\n .back-btn {\n background-color: #5cb85c;\n color: white;\n border: none;\n padding: 10px 20px;\n border-radius: 5px;\n text-decoration: none;\n font-size: 1.1em;\n }\n \n .back-btn:hover {\n background-color: #4cae4c;\n }\n \n .back-btn:active {\n background-color: #398439;\n }\n \n /* 响应式设计 */\n @media (max-width: 768px) {\n .container {\n padding: 30px;\n }\n \n h1 {\n font-size: 2em;\n }\n \n h3 {\n font-size: 1.3em;\n }\n \n .message {\n font-size: 1em;\n }\n }\n\t\t /* 版权信息 */\n .footer {\n margin-top: 30px;\n font-size: 0.9em;\n color: #777;\n }\n </style>\n </head>\n <body>\n <div class=\"container\">\n <h1>您的访问被阻止</h1>\n <p class=\"message\">由于安全策略,您的请求被阻止。<br>如果有疑问,请将下面的访问识别码发给管理员,以便进一步排查。</p>\n <h3>访问识别码:[[.SAMWAF_REQ_UUID]]</h3>\n\t\t <div class=\"footer\">\n 安全防护由SamWAF提供。\n </div>\n </div> \n </body>\n </html>"
var GLOBAL_DEFAULT_BLOCK_INFO string = "<!DOCTYPE html>\n <html lang=\"zh-CN\">\n <head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>您的访问被阻止</title>\n <style>\n /* 基本样式 */\n body {\n font-family: 'Arial', sans-serif;\n background-color: #f4f7fa;\n margin: 0;\n padding: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n height: 100vh;\n color: #333;\n }\n \n /* 页面内容的容器 */\n .container {\n background-color: #ffffff;\n border-radius: 8px;\n padding: 40px;\n box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);\n text-align: center;\n max-width: 600px;\n width: calc(100% - 32px);\n box-sizing: border-box;\n }\n \n /* 标题样式 */\n h1 {\n font-size: 2.5em;\n color: #d9534f;\n margin: 0;\n }\n \n /* 访问识别码样式 */\n h3 {\n font-size: 1.5em;\n color: #5bc0de;\n margin: 20px 0;\n overflow-wrap: anywhere;\n }\n\n .source-ip {\n overflow-wrap: anywhere;\n }\n \n /* 提示文字 */\n .message {\n font-size: 1.1em;\n color: #999;\n margin-bottom: 30px;\n }\n \n /* 返回首页按钮样式 */\n .back-btn {\n background-color: #5cb85c;\n color: white;\n border: none;\n padding: 10px 20px;\n border-radius: 5px;\n text-decoration: none;\n font-size: 1.1em;\n }\n \n .back-btn:hover {\n background-color: #4cae4c;\n }\n \n .back-btn:active {\n background-color: #398439;\n }\n \n /* 响应式设计 */\n @media (max-width: 768px) {\n .container {\n padding: 30px;\n }\n \n h1 {\n font-size: 2em;\n }\n \n h3 {\n font-size: 1.3em;\n }\n \n .message {\n font-size: 1em;\n }\n }\n\t\t /* 版权信息 */\n .footer {\n margin-top: 30px;\n font-size: 0.9em;\n color: #777;\n }\n </style>\n </head>\n <body>\n <div class=\"container\">\n <h1>您的访问被阻止</h1>\n <p class=\"message\">由于安全策略,您的请求被阻止。<br>如果有疑问,请将下面的访问识别码和您的 IP 发给管理员,以便进一步排查。</p>\n <h3>访问识别码:[[.SAMWAF_REQ_UUID]]</h3>\n <p class=\"source-ip\">您的 IP:[[.SAMWAF_IP]]</p>\n\t\t <div class=\"footer\">\n 安全防护由SamWAF提供。\n </div>\n </div> \n </body>\n </html>"
+2
View File
@@ -65,6 +65,8 @@ type WebLog struct {
// 截断只发生在落库这一步,内存对象与 Kafka 出口始终是原文。
Truncated int `json:"truncated"`
SkipPayloadStore bool `gorm:"-" json:"-"` // 仅保存窄行
// GeoUnresolved 本次请求的地区无法判定(没有可用的地区库,或查询失败),
// 区别于"查出来是未知"。为 true 时规则引擎会跳过引用了 COUNTRY/PROVINCE/CITY 的规则,
// 避免 `MF.COUNTRY != "中国"` 这类规则在 IPv6 地区库缺失时把访客整片误杀。
+2 -2
View File
@@ -6,7 +6,7 @@ type WafAttackLogDetailReq struct {
// detail/httpcopymask 为 GET 请求,需 form tag 才能从 query 绑定;
// 前端发送的 query 键为 current_db_name / REQ_UUID / output_format
CurrrentDbName string `json:"current_db_name" form:"current_db_name"`
REQ_UUID string `json:"req_uuid" form:"REQ_UUID"`
REQ_UUID string `json:"req_uuid" form:"REQ_UUID" binding:"required,max=64"`
OutputFormat string `json:"output_format" form:"output_format"` //输出格式 raw,curl
}
@@ -19,7 +19,7 @@ type WafAttackLogSearch struct {
ViewType string `json:"view_type" form:"view_type"` //视图:access=访问日志(默认) event=安全事件
HostCode string `json:"host_code" form:"host_code"` //主机码
Rule string `json:"rule" form:"rule"` //规则名
ReqUuid string `json:"req_uuid" form:"req_uuid"` //请求UUID
ReqUuid string `json:"req_uuid" form:"req_uuid" binding:"max=64"` //请求UUID
Action string `json:"action" form:"action"` //状态
SrcIp string `json:"src_ip" form:"src_ip"` //请求IP
StatusCode string `json:"status_code" form:"status_code"` //响应码
+7 -1
View File
@@ -22,6 +22,8 @@ type WafLogService struct{}
var WafLogServiceApp = new(WafLogService)
var ErrLogNotFound = errors.New("未找到该识别码的日志,记录可能未保存或已过保留期")
// listExcludeColumns 列表查询排除的列:大文本字段和原始 blob 字段
var listExcludeColumns = map[string]bool{
"body": true, "res_body": true, "post_form": true,
@@ -123,6 +125,10 @@ func (receiver *WafLogService) ModifyApi(log innerbean.WebLog) error {
}
func (receiver *WafLogService) GetDetailApi(req request.WafAttackLogDetailReq) (innerbean.WebLog, error) {
var weblog innerbean.WebLog
req.REQ_UUID = strings.TrimSpace(req.REQ_UUID)
if req.REQ_UUID == "" {
return weblog, ErrLogNotFound
}
// 解析当前分片的三层表:安全事件 → 访问日志 → 存量 web_logs,按 req_uuid 逐层点查。
// 事件双写了窄行,内容一致,但事件表保留期更长,优先从它读。
// 分区标识为空或 auto 时先按识别码定位分区:详情最常见的来路就是
@@ -151,7 +157,7 @@ func (receiver *WafLogService) GetDetailApi(req request.WafAttackLogDetailReq) (
}
}
if !found {
return weblog, nil
return weblog, ErrLogNotFound
}
// 告诉界面这条是在哪个分区找到的:auto 时用户并没有选分区,得有个地方说清楚
weblog.ShardName = shardName
+189
View File
@@ -0,0 +1,189 @@
package waf_service
import (
"SamWaf/customtype"
"SamWaf/global"
"SamWaf/innerbean"
"SamWaf/model"
"SamWaf/model/baseorm"
"SamWaf/model/request"
"SamWaf/wafdb"
"SamWaf/wafdb/dialect"
"errors"
"os"
"path/filepath"
"testing"
"time"
sqlite "github.com/samwafgo/sqlitedriver"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func setupLogLookupTest(t *testing.T) (*gorm.DB, *gorm.DB, string) {
t.Helper()
priorDialect := func() (d dialect.DBDialect) {
defer func() { _ = recover() }()
return dialect.Get()
}()
dialect.Register(&dialect.SQLiteDialect{})
oldCore, oldLog := global.GWAF_LOCAL_DB, global.GWAF_LOCAL_LOG_DB
oldTenant, oldUser, oldKey := global.GWAF_TENANT_ID, global.GWAF_USER_CODE, global.GWAF_PWD_LOGDB
global.GWAF_TENANT_ID, global.GWAF_USER_CODE, global.GWAF_PWD_LOGDB = "lookup-tenant", "lookup-user", ""
InvalidateShardCounts()
t.Cleanup(func() {
global.GWAF_LOCAL_DB, global.GWAF_LOCAL_LOG_DB = oldCore, oldLog
global.GWAF_TENANT_ID, global.GWAF_USER_CODE, global.GWAF_PWD_LOGDB = oldTenant, oldUser, oldKey
InvalidateShardCounts()
dialect.Register(priorDialect)
})
open := func(path string) *gorm.DB {
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { sqlDB, _ := db.DB(); _ = sqlDB.Close() })
return db
}
root := t.TempDir()
core, live := open(filepath.Join(root, "core.db")), open(filepath.Join(root, "live.db"))
global.GWAF_LOCAL_DB, global.GWAF_LOCAL_LOG_DB = core, live
if err := core.AutoMigrate(&model.ShareDb{}); err != nil {
t.Fatal(err)
}
if err := live.AutoMigrate(&model.AccessLog{}, &model.SecurityEvent{}, &model.EventPayload{}, &innerbean.WebLog{}); err != nil {
t.Fatal(err)
}
for _, db := range []*gorm.DB{core, live} {
if err := db.Callback().Query().Before("gorm:query").Register("tenant_plugin:before_query", func(d *gorm.DB) {
d.Where("tenant_id = ? and user_code = ?", global.GWAF_TENANT_ID, global.GWAF_USER_CODE)
}); err != nil {
t.Fatal(err)
}
}
return core, live, root
}
func TestLogLookupUUIDIgnoresSelectedShardAndDate(t *testing.T) {
core, _, root := setupLogLookupTest(t)
const archiveName = "local_log_202001.db"
if err := os.Mkdir(filepath.Join(root, "data"), 0700); err != nil {
t.Fatal(err)
}
archive, err := gorm.Open(sqlite.Open(filepath.Join(root, "data", archiveName)), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
archiveSQL, _ := archive.DB()
t.Cleanup(func() { _ = archiveSQL.Close() })
if err = archive.AutoMigrate(&model.AccessLog{}, &model.SecurityEvent{}, &model.EventPayload{}); err != nil {
t.Fatal(err)
}
at := time.Date(2020, 1, 10, 12, 0, 0, 0, time.Local)
narrow := model.LogNarrow{
ReqUUID: "fan_nonstandard_202001_0", TenantId: global.GWAF_TENANT_ID, UserCode: global.GWAF_USER_CODE,
HostCode: "host-old", Method: "GET", SRC_IP: "203.0.113.8", RULE: "CC封禁", ACTION: "阻止",
UNIX_ADD_TIME: at.UnixMilli(), CREATE_TIME: at.Format("2006-01-02 15:04:05"), USER_AGENT: "lookup-agent",
}
for _, row := range []interface{}{&model.AccessLog{LogNarrow: narrow}, &model.SecurityEvent{LogNarrow: narrow}} {
if err = archive.Create(row).Error; err != nil {
t.Fatal(err)
}
}
foreign := narrow
foreign.ReqUUID, foreign.TenantId = "req_foreign_tenant", "other-tenant"
if err = archive.Create(&model.AccessLog{LogNarrow: foreign}).Error; err != nil {
t.Fatal(err)
}
if err = core.Create(&model.ShareDb{
BaseOrm: baseorm.BaseOrm{Id: "lookup-archive", Tenant_ID: global.GWAF_TENANT_ID, USER_CODE: global.GWAF_USER_CODE},
DbLogicType: "log", FileName: archiveName, Cnt: 2,
StartTime: customtype.JsonTime(at.Add(-time.Hour)), EndTime: customtype.JsonTime(at.Add(time.Hour)),
}).Error; err != nil {
t.Fatal(err)
}
// 通过现有连接缓存装入临时归档,避免读取工作区 data/。
if err = wafdb.InitManaulLogDb(root, archiveName); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { wafdb.CloseManualLogDb(archiveName) })
base := request.WafAttackLogSearch{
ReqUuid: " " + narrow.ReqUUID + " ", UnixAddTimeBegin: "4102444800000", UnixAddTimeEnd: "4102531200000",
HostCode: narrow.HostCode, SrcIp: narrow.SRC_IP, Rule: narrow.RULE, FilterBy: "user_agent", FilterValue: "lookup-agent",
SortBy: "unix_add_time", SortDescending: "desc",
}
base.PageIndex, base.PageSize = 1, 10
for _, selected := range []string{"", AutoShard, wafdb.LiveLogName(), "local_log_199901.db"} {
for _, view := range []string{"access", "event"} {
t.Run(selected+"/"+view, func(t *testing.T) {
req := base
req.CurrrentDbName, req.ViewType = selected, view
rows, total, meta, err := WafLogServiceApp.GetListApiWithMeta(req)
if err != nil || total != 1 || len(rows) != 1 || rows[0].REQ_UUID != narrow.ReqUUID || meta.FoundIn != archiveName || !meta.UuidLookup {
t.Fatalf("archive UUID lookup: total=%d rows=%+v meta=%+v err=%v", total, rows, meta, err)
}
})
}
}
for _, filter := range []string{"host", "ip", "rule", "header-filter", "tenant", "user", "missing"} {
t.Run(filter, func(t *testing.T) {
req := base
req.CurrrentDbName = "local_log_199901.db"
switch filter {
case "host":
req.HostCode = "other-host"
case "ip":
req.SrcIp = "203.0.113.9"
case "rule":
req.Rule = "other-rule"
case "header-filter":
req.FilterValue = "different-agent"
case "tenant":
req.ReqUuid = foreign.ReqUUID
case "user":
global.GWAF_USER_CODE = "other-user"
defer func() { global.GWAF_USER_CODE = narrow.UserCode }()
case "missing":
req.ReqUuid = "req_missing"
}
rows, total, meta, err := WafLogServiceApp.GetListApiWithMeta(req)
if err != nil || total != 0 || len(rows) != 0 || !meta.UuidLookup {
t.Fatalf("expected filtered empty lookup: total=%d rows=%+v meta=%+v err=%v", total, rows, meta, err)
}
})
}
// 无识别码仍按显式分区、日期查询。
base.ReqUuid, base.CurrrentDbName = " ", wafdb.LiveLogName()
rows, total, meta, err := WafLogServiceApp.GetListApiWithMeta(base)
if err != nil || len(rows) != 0 || total != 0 || meta.UuidLookup || len(meta.Shards) != 1 {
t.Fatalf("ordinary selected query changed: total=%d meta=%+v err=%v", total, meta, err)
}
}
func TestLogDetailMissingAndReadError(t *testing.T) {
_, live, _ := setupLogLookupTest(t)
for _, id := range []string{"req_missing", " "} {
got, err := WafLogServiceApp.GetDetailApi(request.WafAttackLogDetailReq{REQ_UUID: id, CurrrentDbName: wafdb.LiveLogName()})
if !errors.Is(err, ErrLogNotFound) || got.REQ_UUID != "" {
t.Fatalf("expected identifiable missing error: got=%+v err=%v", got, err)
}
}
if _, err := WafLogServiceApp.GetDetailApi(request.WafAttackLogDetailReq{REQ_UUID: "req_missing", CurrrentDbName: "local_log_199901.db"}); err == nil || errors.Is(err, ErrLogNotFound) {
t.Fatalf("missing shard must return storage error, got %v", err)
}
row := model.SecurityEvent{LogNarrow: model.LogNarrow{ReqUUID: "req_detail", TenantId: global.GWAF_TENANT_ID, UserCode: global.GWAF_USER_CODE}}
if err := live.Create(&row).Error; err != nil {
t.Fatal(err)
}
got, err := WafLogServiceApp.GetDetailApi(request.WafAttackLogDetailReq{REQ_UUID: " req_detail ", CurrrentDbName: wafdb.LiveLogName()})
if err != nil || got.REQ_UUID != row.ReqUUID {
t.Fatalf("trimmed detail lookup: got=%+v err=%v", got, err)
}
if err := live.Callback().Query().Before("gorm:query").Register("test:read_error", func(db *gorm.DB) { db.AddError(errors.New("test read failure")) }); err != nil {
t.Fatal(err)
}
if _, err := WafLogServiceApp.GetDetailApi(request.WafAttackLogDetailReq{REQ_UUID: row.ReqUUID, CurrrentDbName: wafdb.LiveLogName()}); err == nil || errors.Is(err, ErrLogNotFound) {
t.Fatalf("read failure must remain an error, got %v", err)
}
}
+8 -2
View File
@@ -491,7 +491,8 @@ func (receiver *WafLogService) GetListApiWithMeta(req request.WafAttackLogSearch
// 悄悄改成扇出会让它们的每次查询都多打几个分区
auto := shardName == AutoShard
if auto && strings.TrimSpace(req.ReqUuid) != "" {
req.ReqUuid = strings.TrimSpace(req.ReqUuid)
if req.ReqUuid != "" {
return receiver.lookupByUuid(req)
}
@@ -718,7 +719,9 @@ func (receiver *WafLogService) GetListApiWithMeta(req request.WafAttackLogSearch
//
// 识别码是三张日志表的主键,一次点查就是一次索引命中;最常见的来路是访客把拦截页上的
// 识别码报给管理员——他手里只有这串码,不知道是哪天,更不知道在哪个分区。
// 查不到不是错误:调用方按空结果处理,界面负责解释可能的原因(已过保留期 / 抄错 / 本就没记录)。
// 查不到不是错误:所有分区都查询成功但没有行时,调用方按空结果处理,界面负责解释可能的原因
// (已过保留期 / 抄错 / 本就没记录)。有分区查询失败则返回首个错误——明明可能是扫描失败,
// 不能伪装成「未找到」。
func (receiver *WafLogService) lookupByUuid(req request.WafAttackLogSearch) ([]innerbean.WebLog, int64, LogQueryMeta, error) {
meta := LogQueryMeta{UuidLookup: true}
ctx, cancel := context.WithTimeout(context.Background(), fanoutBudget)
@@ -763,6 +766,9 @@ func (receiver *WafLogService) lookupByUuid(req request.WafAttackLogSearch) ([]i
return found, int64(len(found)), meta, nil
}
}
if firstErr != nil {
return []innerbean.WebLog{}, 0, meta, firstErr
}
return []innerbean.WebLog{}, 0, meta, nil
}
+31
View File
@@ -31,6 +31,37 @@ func matchCCBan(hostCode, netSrcIP, srcIP, ipMode string) (hitKey string, client
return "", clientIP
}
func ccBanLog(log *innerbean.WebLog, responseCode int) *innerbean.WebLog {
recorded := *log
recorded.SkipPayloadStore = true
recorded.HEADER = ""
recorded.BODY = ""
recorded.RES_BODY = ""
recorded.COOKIES = ""
recorded.POST_FORM = ""
recorded.ResHeader = ""
recorded.SrcByteBody = nil
recorded.SrcByteResBody = nil
recorded.SrcURL = nil
recorded.BodyDecoded = ""
recorded.CookiesDecoded = ""
recorded.BodyValues = nil
recorded.BodyFields = nil
recorded.BodyHash = ""
recorded.RULE = "CC封禁"
recorded.ACTION = "阻止"
recorded.STATUS = "阻止访问"
if responseCode == 444 {
recorded.STATUS = "连接关闭"
}
recorded.STATUS_CODE = responseCode
recorded.TASK_FLAG = 1
recorded.RISK_LEVEL = 1
recorded.GUEST_IDENTIFICATION = "可疑用户"
recorded.TimeSpent = time.Now().UnixMilli() - recorded.UNIX_ADD_TIME
return &recorded
}
// notifyCCBanOnce 推送 CC 封禁提醒,同一封禁键在节流窗口内只推一条。
func notifyCCBanOnce(hitKey, clientIP string, region []string) {
throttleKey := "cc_ban_notify_" + hitKey
+244
View File
@@ -0,0 +1,244 @@
package wafenginecore
import (
"bufio"
"fmt"
"net"
"net/http"
"net/http/httptest"
"regexp"
"testing"
"time"
"SamWaf/cache"
"SamWaf/common/queue"
"SamWaf/global"
"SamWaf/innerbean"
"SamWaf/model"
"SamWaf/model/wafenginmodel"
)
// 本文件是 CC 封禁拦截链路的 HTTP 级用例:真实走 ServeHTTP 全链路
// (路由解析 → 流量计量 → CC 封禁命中 → 拦截页渲染 → 窄行入队 → 提醒节流),
// 验证「拦截页展示的识别码/IP 与落库窄行一一对应」这一 #1001 修复的核心承诺。
var ccBanPageUUIDRe = regexp.MustCompile(`访问识别码:([0-9a-fA-F-]{36})`)
// hijackRecorder 让 httptest 记录器具备 Hijack 能力,用于覆盖 444 关闭连接分支。
type hijackRecorder struct {
*httptest.ResponseRecorder
conn net.Conn
}
func (h *hijackRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
server, client := net.Pipe()
h.conn = server
go func() { _, _ = bufio.NewReader(client).ReadString(0) }()
return server, bufio.NewReadWriter(bufio.NewReader(server), bufio.NewWriter(server)), nil
}
// setupCCBanEngine 搭一套可跑 ServeHTTP 的最小引擎:站点 + 全局网站 + 封禁键 + 空队列。
// 返回的清理函数恢复被替换的全局对象。
func setupCCBanEngine(t *testing.T, banIP string, mutateSite func(h *wafenginmodel.HostSafe)) (*WafEngine, func()) {
t.Helper()
waf := &WafEngine{}
waf.InitRouting()
waf.ServerOnline = wafenginmodel.NewSafeServerMap()
site := &wafenginmodel.HostSafe{
Host: model.Hosts{
Code: "ccb-code",
Host: "ccb-test.com",
Port: 80,
GUARD_STATUS: 1,
IPSourceMode: "nic",
},
BlockingPage: map[string]model.BlockingPage{},
RuleData: []model.Rules{},
IPBlockLists: []model.IPBlockList{},
LoadBalanceRuntime: &wafenginmodel.LoadBalanceRuntime{},
}
if mutateSite != nil {
mutateSite(site)
}
globalHost := &wafenginmodel.HostSafe{
Host: model.Hosts{Code: global.GWAF_GLOBAL_HOST_CODE},
BlockingPage: map[string]model.BlockingPage{},
RuleData: []model.Rules{},
IPBlockLists: []model.IPBlockList{},
LoadBalanceRuntime: &wafenginmodel.LoadBalanceRuntime{},
}
waf.withWriteTable(func(nt *routingTable) {
nt.HostTarget["ccb-test.com:80"] = site
nt.HostCode["ccb-code"] = "ccb-test.com:80"
nt.HostTarget["global:80"] = globalHost
nt.HostCode[global.GWAF_GLOBAL_HOST_CODE] = "global:80"
})
oldCache, oldLogQ, oldMsgQ := global.GCACHE_WAFCACHE, global.GQEQUE_LOG_DB, global.GQEQUE_MESSAGE_DB
global.GCACHE_WAFCACHE = cache.InitWafCache()
global.GQEQUE_LOG_DB = queue.NewQueue()
global.GQEQUE_MESSAGE_DB = queue.NewQueue()
banKey := model.BuildCCBanKey(model.CCBanScopeHost, "ccb-code", banIP)
global.GCACHE_WAFCACHE.SetWithTTl(banKey, 300, 10*time.Minute)
return waf, func() {
global.GCACHE_WAFCACHE = oldCache
global.GQEQUE_LOG_DB = oldLogQ
global.GQEQUE_MESSAGE_DB = oldMsgQ
}
}
func doCCBanRequest(waf *WafEngine, w http.ResponseWriter, path string) *http.Request {
req := httptest.NewRequest("GET", "http://ccb-test.com"+path, nil)
req.RemoteAddr = "10.1.2.3:40001"
waf.ServeHTTP(w, req)
return req
}
// 封禁期每次请求:拦截页给新的识别码且展示访客 IP,每个识别码对应一条窄行落库。
// 这正是 #1001 的验收口径——页面上看到的识别码必须能在日志里查到。
func TestCCBanServeHTTP_PageIdMatchesNarrowLog(t *testing.T) {
waf, cleanup := setupCCBanEngine(t, "10.1.2.3", nil)
defer cleanup()
const n = 3
pageUUIDs := make([]string, 0, n)
for i := 0; i < n; i++ {
rec := httptest.NewRecorder()
doCCBanRequest(waf, rec, fmt.Sprintf("/ccban?page=%d", i))
if rec.Code != 403 {
t.Fatalf("第 %d 次请求应返回 403,实际 %d", i, rec.Code)
}
body := rec.Body.String()
m := ccBanPageUUIDRe.FindStringSubmatch(body)
if m == nil {
t.Fatalf("拦截页缺少访问识别码:%s", body[:min(200, len(body))])
}
if body != "" && !containsAll(body, "您的 IP:10.1.2.3") {
t.Fatalf("拦截页应展示访客 IP 10.1.2.3")
}
pageUUIDs = append(pageUUIDs, m[1])
}
for i := 1; i < n; i++ {
if pageUUIDs[i] == pageUUIDs[0] {
t.Fatalf("每次请求的识别码应独立生成,第 %d 次与第 0 次相同", i)
}
}
if got := global.GQEQUE_LOG_DB.Size(); got != n {
t.Fatalf("应入队 %d 条窄行,实际 %d", n, got)
}
for i := 0; i < n; i++ {
v, ok := global.GQEQUE_LOG_DB.Dequeue()
if !ok {
t.Fatalf("第 %d 条窄行取不到", i)
}
lg, ok := v.(*innerbean.WebLog)
if !ok {
t.Fatalf("入队对象类型不对: %T", v)
}
if lg.REQ_UUID != pageUUIDs[i] {
t.Fatalf("第 %d 条窄行识别码 %s 与页面展示的 %s 不一致", i, lg.REQ_UUID, pageUUIDs[i])
}
if lg.RULE != "CC封禁" || lg.ACTION != "阻止" || lg.STATUS != "阻止访问" || lg.STATUS_CODE != 403 {
t.Fatalf("第 %d 条窄行归因不对: rule=%q action=%q status=%q code=%d", i, lg.RULE, lg.ACTION, lg.STATUS, lg.STATUS_CODE)
}
if !lg.SkipPayloadStore {
t.Fatalf("第 %d 条窄行应标记跳过重报文留存", i)
}
if lg.HEADER != "" || lg.BODY != "" || lg.COOKIES != "" || lg.SrcByteBody != nil || lg.SrcURL != nil {
t.Fatalf("第 %d 条窄行应只留元数据,实际 HEADER=%d 字节 BODY=%d 字节", i, len(lg.HEADER), len(lg.BODY))
}
if lg.SRC_IP != "10.1.2.3" {
t.Fatalf("第 %d 条窄行来源 IP 应为 10.1.2.3,实际 %q", i, lg.SRC_IP)
}
}
}
// 站点给 cc_attack 配了自定义拦截页时:响应码以页面配置为准,窄行记录实际下发的码。
// 444(关闭连接)在可劫持的真实连接上生效,且窄行状态写明「连接关闭」。
func TestCCBanServeHTTP_CustomStatusAnd444(t *testing.T) {
mkPage := func(code string) func(h *wafenginmodel.HostSafe) {
return func(h *wafenginmodel.HostSafe) {
h.BlockingPage = map[string]model.BlockingPage{
"cc_attack": {AttackType: "cc_attack", ResponseCode: code, ResponseContent: "CC BANNED [[.SAMWAF_REQ_UUID]] [[.SAMWAF_IP]]"},
}
}
}
// 两个子场景各建一套引擎;任一 t.Fatalf 都要把全局替换恢复掉,不能泄漏给同包后续用例
cleanups := []func(){}
defer func() {
for i := len(cleanups) - 1; i >= 0; i-- {
cleanups[i]()
}
}()
setup := func(ip string, mutate func(h *wafenginmodel.HostSafe)) *WafEngine {
waf, cleanup := setupCCBanEngine(t, ip, mutate)
cleanups = append(cleanups, cleanup)
return waf
}
dequeueLog := func(tag string) *innerbean.WebLog {
v, ok := global.GQEQUE_LOG_DB.Dequeue()
if !ok {
t.Fatalf("%s 应有一条窄行入队", tag)
}
lg, ok := v.(*innerbean.WebLog)
if !ok {
t.Fatalf("%s 入队对象类型不对: %T", tag, v)
}
return lg
}
// 429 自定义码
waf := setup("10.1.2.3", mkPage("429"))
rec := httptest.NewRecorder()
doCCBanRequest(waf, rec, "/ccban")
if rec.Code != 429 {
t.Fatalf("自定义拦截页 429 应原样下发,实际 %d", rec.Code)
}
if !containsAll(rec.Body.String(), "CC BANNED", "10.1.2.3") {
t.Fatalf("自定义拦截页应渲染识别码与 IP 变量: %s", rec.Body.String())
}
lg := dequeueLog("429")
if lg.STATUS_CODE != 429 || lg.RULE != "CC封禁" {
t.Fatalf("窄行应记录实际下发的 429,实际 code=%d rule=%q", lg.STATUS_CODE, lg.RULE)
}
// 444 关闭连接(用可劫持的记录器模拟真实连接)
waf = setup("10.1.2.3", mkPage("444"))
hj := &hijackRecorder{ResponseRecorder: httptest.NewRecorder()}
doCCBanRequest(waf, hj, "/ccban")
if hj.Body.Len() != 0 {
t.Fatalf("444 分支不应写出任何响应体,实际 %d 字节", hj.Body.Len())
}
lg = dequeueLog("444")
if lg.STATUS_CODE != 444 || lg.STATUS != "连接关闭" {
t.Fatalf("444 窄行应写明连接关闭,实际 code=%d status=%q", lg.STATUS_CODE, lg.STATUS)
}
}
// 封禁期内持续重试不会刷爆提醒队列:同一封禁键只推一条。
func TestCCBanServeHTTP_NotifyThrottled(t *testing.T) {
waf, cleanup := setupCCBanEngine(t, "10.1.2.3", nil)
defer cleanup()
for i := 0; i < 5; i++ {
rec := httptest.NewRecorder()
doCCBanRequest(waf, rec, "/ccban")
}
if got := global.GQEQUE_MESSAGE_DB.Size(); got != 1 {
t.Fatalf("同一封禁键 5 次请求应只推 1 条提醒,实际 %d", got)
}
}
func containsAll(s string, subs ...string) bool {
for _, sub := range subs {
if !regexp.MustCompile(regexp.QuoteMeta(sub)).MatchString(s) {
return false
}
}
return true
}
+44
View File
@@ -0,0 +1,44 @@
package wafenginecore
import (
"SamWaf/innerbean"
"encoding/json"
"reflect"
"strings"
"testing"
"time"
)
func TestCCBanLogNarrowCopy(t *testing.T) {
original := innerbean.WebLog{
REQ_UUID: "ban-request", HOST_CODE: "host", SRC_IP: "203.0.113.10", NetSrcIp: "127.0.0.1",
URL: "/test", METHOD: "POST", ACTION: "通过", UNIX_ADD_TIME: time.Now().UnixMilli() - 10,
HEADER: "header", BODY: "body", RES_BODY: "response", COOKIES: "cookie", POST_FORM: "form",
ResHeader: "res-header", SrcByteBody: []byte("body"), SrcByteResBody: []byte("response"),
SrcURL: []byte("url"), BodyDecoded: "decoded", CookiesDecoded: "decoded-cookie",
BodyValues: []string{"value"}, BodyFields: []string{"field"}, BodyHash: "hash",
}
before := original
for _, code := range []int{403, 429, 444} {
got := ccBanLog(&original, code)
if got.REQ_UUID != original.REQ_UUID || got.SRC_IP != original.SRC_IP || got.NetSrcIp != original.NetSrcIp || got.URL != original.URL {
t.Fatal("request identifiers changed")
}
if got.STATUS_CODE != code || got.ACTION != "阻止" || got.RULE != "CC封禁" || !got.SkipPayloadStore || !got.IsSecurityEvent() {
t.Fatalf("incorrect event: %+v", got)
}
if code == 444 && got.STATUS != "连接关闭" {
t.Fatal("444 must record connection closure")
}
if got.HEADER != "" || got.BODY != "" || got.RES_BODY != "" || got.COOKIES != "" || got.POST_FORM != "" || got.ResHeader != "" || got.BodyDecoded != "" || got.CookiesDecoded != "" || got.BodyHash != "" || len(got.SrcByteBody)+len(got.SrcByteResBody)+len(got.SrcURL)+len(got.BodyValues)+len(got.BodyFields) != 0 {
t.Fatal("narrow log retains payload")
}
if got.TimeSpent < 0 || !reflect.DeepEqual(original, before) {
t.Fatal("original request changed or negative duration")
}
encoded, err := json.Marshal(got)
if err != nil || strings.Contains(string(encoded), "SkipPayloadStore") || strings.Contains(string(encoded), "skip_payload") {
t.Fatal("runtime marker leaked to JSON")
}
}
}
+2 -1
View File
@@ -92,7 +92,7 @@ func resolveCustomErrorPage(hostSafe, globalHostSafe *wafenginmodel.HostSafe, ba
// - 「优先后端响应」且后端确有内容 → 原样还原(状态码/响应头/压缩编码/Content-Type 全不动)
// - 其余情况 → 渲染模版覆盖
func applyCustomErrorPage(resp *http.Response, page *model.BlockingPage,
backendStatusCode int, backendStatus string, reqUUID string) customErrorPageResult {
backendStatusCode int, backendStatus string, reqUUID string, srcIP string) customErrorPageResult {
var rawBody []byte
if resp.Body != nil && resp.Body != http.NoBody {
@@ -118,6 +118,7 @@ func applyCustomErrorPage(resp *http.Response, page *model.BlockingPage,
renderData := map[string]interface{}{
"SAMWAF_REQ_UUID": reqUUID,
"SAMWAF_IP": templateSourceIP(srcIP),
"SAMWAF_BACKEND_STATUS": backendStatus,
"SAMWAF_BACKEND_CODE": backendStatusCode,
"SAMWAF_BACKEND_BODY": string(plainBody),
+9 -9
View File
@@ -136,7 +136,7 @@ func TestApplyCustomErrorPage_DefaultOverwrites(t *testing.T) {
backendBody := []byte(`{"code":` + strconv.Itoa(code) + `,"message":"backend says no"}`)
resp := newBackendResp(code, strconv.Itoa(code)+" Err", "application/json", backendBody)
result := applyCustomErrorPage(resp, page("", priority), code, resp.Status, "uuid-1")
result := applyCustomErrorPage(resp, page("", priority), code, resp.Status, "uuid-1", "")
if !result.TemplateApplied {
t.Fatalf("priority=%q code=%d 应使用模版覆盖", priority, code)
@@ -162,7 +162,7 @@ func TestApplyCustomErrorPage_BackendFirstPassthrough(t *testing.T) {
backendBody := []byte(`{"code":` + strconv.Itoa(code) + `,"message":"no permission"}`)
resp := newBackendResp(code, strconv.Itoa(code)+" Err", "application/json", backendBody)
result := applyCustomErrorPage(resp, page("", model.BlockingPageContentPriorityBackend), code, resp.Status, "uuid-1")
result := applyCustomErrorPage(resp, page("", model.BlockingPageContentPriorityBackend), code, resp.Status, "uuid-1", "")
if result.TemplateApplied {
t.Fatalf("code=%d 后端有响应体时应透传,不应套模版", code)
@@ -192,7 +192,7 @@ func TestApplyCustomErrorPage_BackendFirstFallback(t *testing.T) {
t.Run(c.name, func(t *testing.T) {
resp := newBackendResp(500, "500 Internal Server Error", "text/html", c.body)
result := applyCustomErrorPage(resp, page("", model.BlockingPageContentPriorityBackend), 500, resp.Status, "uuid-1")
result := applyCustomErrorPage(resp, page("", model.BlockingPageContentPriorityBackend), 500, resp.Status, "uuid-1", "")
if !result.TemplateApplied {
t.Fatal("后端无响应体时应回落到自定义模版")
@@ -210,7 +210,7 @@ func TestApplyCustomErrorPage_NilBody(t *testing.T) {
resp := newBackendResp(502, "502 Bad Gateway", "text/html", nil)
resp.Body = body
result := applyCustomErrorPage(resp, page("", model.BlockingPageContentPriorityBackend), 502, resp.Status, "uuid-1")
result := applyCustomErrorPage(resp, page("", model.BlockingPageContentPriorityBackend), 502, resp.Status, "uuid-1", "")
if !result.TemplateApplied {
t.Fatal("无 body 时应使用模版")
@@ -225,7 +225,7 @@ func TestApplyCustomErrorPage_NilBody(t *testing.T) {
func TestApplyCustomErrorPage_ResponseCodeOverride(t *testing.T) {
resp := newBackendResp(500, "500 Internal Server Error", "text/html", []byte(""))
applyCustomErrorPage(resp, page("503", ""), 500, resp.Status, "uuid-1")
applyCustomErrorPage(resp, page("503", ""), 500, resp.Status, "uuid-1", "")
if resp.StatusCode != 503 {
t.Fatalf("配置 response_code=503 时应下发 503,实际 %d", resp.StatusCode)
@@ -242,7 +242,7 @@ func TestApplyCustomErrorPage_ResponseHeaderApplied(t *testing.T) {
p.ResponseHeader = `[{"name":"Content-Type","value":"text/html"},{"name":"X-Waf-Page","value":"custom"},{"name":"X-Empty","value":""}]`
resp := newBackendResp(404, "404 Not Found", "application/json", []byte(""))
applyCustomErrorPage(resp, p, 404, resp.Status, "uuid-1")
applyCustomErrorPage(resp, p, 404, resp.Status, "uuid-1", "")
body := readBody(t, resp)
if resp.Header.Get("Content-Type") != "text/html" {
@@ -267,7 +267,7 @@ func TestApplyCustomErrorPage_GzipBackend_TemplateDropsContentEncoding(t *testin
resp := newBackendResp(500, "500 Internal Server Error", "application/json", gzipBytes(t, plain))
resp.Header.Set("Content-Encoding", "gzip")
result := applyCustomErrorPage(resp, page("", ""), 500, resp.Status, "uuid-1")
result := applyCustomErrorPage(resp, page("", ""), 500, resp.Status, "uuid-1", "")
if !result.TemplateApplied {
t.Fatal("默认模式下应套模版")
@@ -299,7 +299,7 @@ func TestApplyCustomErrorPage_GzipBackend_PassthroughKeepsBytes(t *testing.T) {
resp := newBackendResp(403, "403 Forbidden", "application/json", raw)
resp.Header.Set("Content-Encoding", "gzip")
result := applyCustomErrorPage(resp, page("", model.BlockingPageContentPriorityBackend), 403, resp.Status, "uuid-1")
result := applyCustomErrorPage(resp, page("", model.BlockingPageContentPriorityBackend), 403, resp.Status, "uuid-1", "")
if result.TemplateApplied {
t.Fatal("后端有内容时应透传")
@@ -326,7 +326,7 @@ func TestApplyCustomErrorPage_GzipEmptyBody_FallsBackToTemplate(t *testing.T) {
resp := newBackendResp(500, "500 Internal Server Error", "text/html", raw)
resp.Header.Set("Content-Encoding", "gzip")
result := applyCustomErrorPage(resp, page("", model.BlockingPageContentPriorityBackend), 500, resp.Status, "uuid-1")
result := applyCustomErrorPage(resp, page("", model.BlockingPageContentPriorityBackend), 500, resp.Status, "uuid-1", "")
if !result.TemplateApplied {
t.Fatal("后端 body 解压后为空,应回落到自定义模版")
+12
View File
@@ -11,13 +11,23 @@ import (
"encoding/json"
"fmt"
"go.uber.org/zap"
"html"
"io"
"net"
"net/http"
"strconv"
"text/template"
"time"
)
func templateSourceIP(srcIP string) string {
ip := net.ParseIP(srcIP)
if ip == nil {
return ""
}
return html.EscapeString(ip.String())
}
func renderTemplate(templateContent string, data map[string]interface{}) ([]byte, error) {
tmpl, err := template.New("blockingPageTemplate").Delims("[[", "]]").Parse(templateContent)
if err != nil {
@@ -47,6 +57,7 @@ func EchoErrorInfo(w http.ResponseWriter, r *http.Request, weblogbean *innerbean
renderData := map[string]interface{}{
"SAMWAF_REQ_UUID": weblogbean.REQ_UUID,
"SAMWAF_BLOCK_INFO": blockInfo,
"SAMWAF_IP": templateSourceIP(weblogbean.SRC_IP),
}
// 优先根据攻击类型查找对应的拦截页面
@@ -198,6 +209,7 @@ func EchoResponseErrorInfo(resp *http.Response, weblogbean *innerbean.WebLog, ru
renderData := map[string]interface{}{
"SAMWAF_REQ_UUID": weblogbean.REQ_UUID,
"SAMWAF_BLOCK_INFO": blockInfo,
"SAMWAF_IP": templateSourceIP(weblogbean.SRC_IP),
}
// 优先根据攻击类型查找对应的拦截页面
+143
View File
@@ -0,0 +1,143 @@
package wafenginecore
import (
"SamWaf/global"
"SamWaf/innerbean"
"SamWaf/model"
"SamWaf/model/wafenginmodel"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
func TestTemplateSourceIP(t *testing.T) {
cases := []struct {
name string
ip string
want string
}{
{"IPv4", "203.0.113.10", "203.0.113.10"},
{"IPv6", "2001:0DB8:0000:0000:0000:0000:0000:0001", "2001:db8::1"},
{"mapped IPv4", "::ffff:203.0.113.10", "203.0.113.10"},
{"empty", "", ""},
{"invalid", "unknown", ""},
{"HTML suffix", "203.0.113.10<b>", ""},
{"quoted value", `"203.0.113.10"`, ""},
{"IPv4 port", "203.0.113.10:443", ""},
{"IPv6 port", "[2001:db8::1]:443", ""},
{"zone", "fe80::1%eth0", ""},
{"CIDR", "203.0.113.0/24", ""},
{"list", "203.0.113.10, 203.0.113.11", ""},
{"spaces", " 203.0.113.10 ", ""},
{"newline", "203.0.113.10\n", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := templateSourceIP(tc.ip); got != tc.want {
t.Fatalf("templateSourceIP(%q) = %q, want %q", tc.ip, got, tc.want)
}
})
}
}
func TestErrorPageTemplatesSourceIP(t *testing.T) {
const reqUUID = "12345678-1234-1234-1234-123456789abc"
const customTemplate = `uuid=[[.SAMWAF_REQ_UUID]] ip=[[.SAMWAF_IP]]`
const legacyTemplate = `<p>legacy uuid=[[.SAMWAF_REQ_UUID]]</p>`
inputs := []struct {
name string
ip string
want string
}{
{"IPv4", "203.0.113.10", "203.0.113.10"},
{"IPv6", "2001:0DB8:0000:0000:0000:0000:0000:0001", "2001:db8::1"},
{"invalid", "203.0.113.10<b>", ""},
{"empty", "", ""},
}
for _, entry := range []string{"request", "response", "backend"} {
for _, mode := range []string{"default", "host", "global", "legacy"} {
for _, input := range inputs {
t.Run(entry+"/"+mode+"/"+input.name, func(t *testing.T) {
hostSafe := &wafenginmodel.HostSafe{BlockingPage: map[string]model.BlockingPage{}}
globalSafe := &wafenginmodel.HostSafe{BlockingPage: map[string]model.BlockingPage{}}
content := customTemplate
if mode == "default" {
content = global.GLOBAL_DEFAULT_BLOCK_INFO
} else if mode == "legacy" {
content = legacyTemplate
}
configuredPage := model.BlockingPage{ResponseCode: "403", ResponseContent: content}
if mode == "global" {
globalSafe.BlockingPage["403"] = configuredPage
} else if mode != "default" || entry == "backend" {
hostSafe.BlockingPage["403"] = configuredPage
}
log := &innerbean.WebLog{REQ_UUID: reqUUID, SRC_IP: input.ip}
var got string
switch entry {
case "request":
r := httptest.NewRequest("GET", "http://example.test/", nil)
r.RemoteAddr = "198.51.100.20:12345"
r.Header.Set("X-Forwarded-For", "192.0.2.30")
w := httptest.NewRecorder()
code := EchoErrorInfo(w, r, log, "test", "blocked", hostSafe, globalSafe, false, "")
if code != 403 || w.Code != 403 {
t.Fatalf("unexpected status: returned=%d response=%d", code, w.Code)
}
got = w.Body.String()
case "response":
resp := newBackendResp(200, "200 OK", "text/plain", []byte("backend"))
EchoResponseErrorInfo(resp, log, "test", "blocked", hostSafe, globalSafe, false, "")
got = string(readBody(t, resp))
if resp.StatusCode != 403 || resp.ContentLength != int64(len(got)) || resp.Header.Get("Content-Length") != strconv.Itoa(len(got)) {
t.Fatalf("response metadata differs from rendered page: %+v", resp)
}
case "backend":
resp := newBackendResp(403, "403 Forbidden", "text/html", nil)
p := resolveCustomErrorPage(hostSafe, globalSafe, 403)
if p == nil {
t.Fatal("configured backend page not resolved")
}
result := applyCustomErrorPage(resp, p, 403, resp.Status, log.REQ_UUID, log.SRC_IP)
if !result.TemplateApplied {
t.Fatal("backend page not rendered")
}
got = string(readBody(t, resp))
if resp.StatusCode != 403 || resp.ContentLength != int64(len(got)) || resp.Header.Get("Content-Length") != strconv.Itoa(len(got)) {
t.Fatalf("backend response metadata differs from rendered page: %+v", resp)
}
}
if mode == "legacy" {
if want := "<p>legacy uuid=" + reqUUID + "</p>"; got != want {
t.Fatalf("legacy template changed: got %q, want %q", got, want)
}
} else if mode == "default" {
for _, want := range []string{
"访问识别码:" + reqUUID,
`<p class="source-ip">您的 IP:` + input.want + "</p>",
"访问识别码和您的 IP 发给管理员",
"overflow-wrap: anywhere;",
"width: calc(100% - 32px);",
"box-sizing: border-box;",
} {
if !strings.Contains(got, want) {
t.Fatalf("default page missing %q", want)
}
}
if strings.Index(got, "您的 IP:") < strings.Index(got, "访问识别码:") {
t.Fatal("IP should appear below request UUID")
}
} else if want := "uuid=" + reqUUID + " ip=" + input.want; got != want {
t.Fatalf("rendered page = %q, want %q", got, want)
}
if log.SRC_IP != input.ip {
t.Fatalf("log source IP changed: %q", log.SRC_IP)
}
})
}
}
}
}
+3 -2
View File
@@ -599,7 +599,8 @@ func (waf *WafEngine) ServeHTTP(w http.ResponseWriter, r *http.Request) {
weblogbean.RULE = "CC封禁"
} else {
notifyCCBanOnce(hitKey, banIP, region)
EchoErrorInfo(w, r, &weblogbean, "", "当前IP由于访问频次太高暂时无法访问", hostTarget, waf.rt().HostTarget[waf.rt().HostCode[global.GWAF_GLOBAL_HOST_CODE]], false, "cc_attack")
responseCode := EchoErrorInfo(w, r, &weblogbean, "", "当前IP由于访问频次太高暂时无法访问", hostTarget, waf.rt().HostTarget[waf.rt().HostCode[global.GWAF_GLOBAL_HOST_CODE]], false, "cc_attack")
global.GQEQUE_LOG_DB.Enqueue(ccBanLog(&weblogbean, responseCode))
return
}
}
@@ -1422,7 +1423,7 @@ func (waf *WafEngine) modifyResponse() func(*http.Response) error {
)
if customBlockingPage != nil {
pageResult := applyCustomErrorPage(resp, customBlockingPage, backendStatusCode, backendStatus, weblogfrist.REQ_UUID)
pageResult := applyCustomErrorPage(resp, customBlockingPage, backendStatusCode, backendStatus, weblogfrist.REQ_UUID, weblogfrist.SRC_IP)
if pageResult.TemplateApplied {
// 记录响应Header信息
+45 -43
View File
@@ -70,8 +70,10 @@ func tierForStore(logs []*innerbean.WebLog) (events []*model.SecurityEvent, acce
if isEvent {
events = dedupByUUID(events, seenEvent, lg.REQ_UUID,
&model.SecurityEvent{LogNarrow: ln, PayloadHash: PayloadHash(lg.RULE, lg.BodyHash)})
if p := extractPayload(lg, PayloadKindEvent); p != nil {
payloads = dedupByUUID(payloads, seenPayload, p.ReqUUID, p)
if !lg.SkipPayloadStore {
if p := extractPayload(lg, PayloadKindEvent); p != nil {
payloads = dedupByUUID(payloads, seenPayload, p.ReqUUID, p)
}
}
}
if watched {
@@ -105,48 +107,48 @@ func narrowFromLog(lg *innerbean.WebLog) model.LogNarrow {
truncated = 1
}
return model.LogNarrow{
ReqUUID: lg.REQ_UUID,
TenantId: lg.TenantId,
UserCode: lg.USER_CODE,
HostCode: lg.HOST_CODE,
Host: lg.HOST,
URL: url,
RawQuery: rawQuery,
Method: lg.METHOD,
Scheme: lg.Scheme,
REFERER: cutUTF8N(lg.REFERER, 1024),
USER_AGENT: cutUTF8N(lg.USER_AGENT, 500),
SRC_IP: lg.SRC_IP,
SRC_PORT: lg.SRC_PORT,
NetSrcIp: lg.NetSrcIp,
COUNTRY: lg.COUNTRY,
PROVINCE: lg.PROVINCE,
CITY: lg.CITY,
ACTION: lg.ACTION,
RULE: lg.RULE,
STATUS: lg.STATUS,
STATUS_CODE: lg.STATUS_CODE,
RISK_LEVEL: lg.RISK_LEVEL,
AI_SCORE: lg.AI_SCORE,
IsBot: lg.IsBot,
LogOnlyMode: lg.LogOnlyMode,
ReqUUID: lg.REQ_UUID,
TenantId: lg.TenantId,
UserCode: lg.USER_CODE,
HostCode: lg.HOST_CODE,
Host: lg.HOST,
URL: url,
RawQuery: rawQuery,
Method: lg.METHOD,
Scheme: lg.Scheme,
REFERER: cutUTF8N(lg.REFERER, 1024),
USER_AGENT: cutUTF8N(lg.USER_AGENT, 500),
SRC_IP: lg.SRC_IP,
SRC_PORT: lg.SRC_PORT,
NetSrcIp: lg.NetSrcIp,
COUNTRY: lg.COUNTRY,
PROVINCE: lg.PROVINCE,
CITY: lg.CITY,
ACTION: lg.ACTION,
RULE: lg.RULE,
STATUS: lg.STATUS,
STATUS_CODE: lg.STATUS_CODE,
RISK_LEVEL: lg.RISK_LEVEL,
AI_SCORE: lg.AI_SCORE,
IsBot: lg.IsBot,
LogOnlyMode: lg.LogOnlyMode,
GUEST_IDENTIFICATION: lg.GUEST_IDENTIFICATION,
TimeSpent: lg.TimeSpent,
CONTENT_LENGTH: lg.CONTENT_LENGTH,
ResContentLength: lg.RES_CONTENT_LENGTH,
PreCheckCost: lg.PreCheckCost,
ForwardCost: lg.ForwardCost,
BackendCheckCost: lg.BackendCheckCost,
IsBalance: lg.IsBalance,
BalanceInfo: lg.BalanceInfo,
BodyHash: lg.BodyHash,
Truncated: truncated,
ActorKey: ActorKey(lg.SRC_IP),
UaHash: UaHash(lg.USER_AGENT),
PathNorm: NormalizePath(lg.URL),
CREATE_TIME: lg.CREATE_TIME,
UNIX_ADD_TIME: lg.UNIX_ADD_TIME,
Day: lg.Day,
TimeSpent: lg.TimeSpent,
CONTENT_LENGTH: lg.CONTENT_LENGTH,
ResContentLength: lg.RES_CONTENT_LENGTH,
PreCheckCost: lg.PreCheckCost,
ForwardCost: lg.ForwardCost,
BackendCheckCost: lg.BackendCheckCost,
IsBalance: lg.IsBalance,
BalanceInfo: lg.BalanceInfo,
BodyHash: lg.BodyHash,
Truncated: truncated,
ActorKey: ActorKey(lg.SRC_IP),
UaHash: UaHash(lg.USER_AGENT),
PathNorm: NormalizePath(lg.URL),
CREATE_TIME: lg.CREATE_TIME,
UNIX_ADD_TIME: lg.UNIX_ADD_TIME,
Day: lg.Day,
}
}
+17
View File
@@ -91,6 +91,23 @@ func TestStoreTieredCrossEngine(t *testing.T) {
if offAccess != 0 {
t.Fatalf("off 档正常请求不应有窄行,实际 %d", offAccess)
}
for _, mode := range []string{"db", "sample", "off"} {
global.GDATA_ACCESS_LOG_MODE = mode
uid := "cc-ban-" + mode
lg := mk(uid, "203.0.113.10", "阻止", "CC封禁")
lg.SkipPayloadStore = true
storeTiered([]*innerbean.WebLog{lg})
for _, table := range []string{model.AccessLogTableName, model.SecurityEventTableName} {
var count int64
if err := db.Table(table).Where("req_uuid = ?", uid).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("%s %s narrow row: count=%d err=%v", mode, table, count, err)
}
}
var payloadCount int64
if err := db.Model(&model.EventPayload{}).Where("req_uuid = ?", uid).Count(&payloadCount).Error; err != nil || payloadCount != 0 {
t.Fatalf("%s payload: count=%d err=%v", mode, payloadCount, err)
}
}
global.GDATA_ACCESS_LOG_MODE = "db"
})
}
+19
View File
@@ -53,6 +53,25 @@ func TestTier_EventWritesAllLayers(t *testing.T) {
}
}
func TestTier_CCBanNarrowOnly(t *testing.T) {
for _, mode := range []string{"db", "sample", "off"} {
t.Run(mode, func(t *testing.T) {
defer resetTier(mode)()
lg := mkLog("cc-ban-"+mode, "203.0.113.10")
lg.ACTION = "阻止"
lg.RULE = "CC封禁"
lg.SkipPayloadStore = true
events, accesses, payloads := tierForStore([]*innerbean.WebLog{lg, lg})
if len(events) != 1 || len(accesses) != 1 || len(payloads) != 0 {
t.Fatalf("CC narrow layers: %d/%d/%d", len(events), len(accesses), len(payloads))
}
if events[0].ReqUUID != lg.REQ_UUID || accesses[0].ReqUUID != lg.REQ_UUID || lg.BODY != "a=1" || lg.HEADER != "User-Agent: curl" {
t.Fatal("identifiers or original payload changed")
}
})
}
}
// db 档:正常请求窄行全量进 access_log;报文不进本批——被蓄水池采中的攒在池里定期刷库(D2)
func TestTier_DBModeNormalNoPayload(t *testing.T) {
defer resetTier("db")()
+29
View File
@@ -939,6 +939,35 @@ notes:
the Access IP Details dialog on the risk log page gained a time range (last 30 days by default) so it
no longer takes going through archives one by one.
- id: v1_3_25_cc_ban_request_logging
version: v1.3.25
kind: notice
level: normal
page: /waf/wafvisitlog
doc: https://doc.samwaf.com/guide/BlockingPage.html
apply:
type: navigate
zh:
title: CC 封禁期间的阻断请求可按访问识别码查询
detail: >-
CC 封禁期间,每次实际阻断请求保留独立的访问识别码,并记录来源 IP、站点、路径、方法、响应码与耗时等窄行信息。
这些事件同时出现在访问日志和安全事件中,不保存重复请求的完整报文;首次触发封禁的日志仍按现有规则保留报文。
封禁期间的请求数、拦截数与窄行磁盘占用会相应增加,通知仍沿用封禁提醒节流。
日志异步写入,查询需开启日志持久化、写入成功且记录尚在对应视图保留期内。
内置拦截页同时显示访客 IP;已有自定义模板可以自行加入 [[.SAMWAF_IP]],其值与日志来源 IP 一致。
顶部访问识别码查询忽略时间范围与所选分区,其余筛选条件仍生效;详情与请求复制会反馈未命中或读取错误。
en:
title: Requests blocked during a CC ban can be looked up by visit identifier
detail: >-
Each request blocked during a CC ban retains its own visit identifier and a narrow log row containing source IP,
site, path, method, response status and duration. These events appear in both access logs and security events,
without retaining repeated full payloads. The request that initially triggers the ban keeps its payload under the existing rules.
Request counts, blocked counts and narrow-row disk usage increase accordingly; ban notifications retain their throttling.
Logs are written asynchronously and require persistence to be enabled, a successful write and an unexpired record in the selected view.
The built-in block page also shows the visitor IP. Existing custom templates can add [[.SAMWAF_IP]], using the same source IP as the logs.
The top visit-identifier lookup ignores the date range and selected partition while other filters still apply.
Request details and request-copy actions report missing records or read errors.
- id: v1_3_25_httpauth_path_scope
version: v1.3.25
kind: notice