diff --git a/model/hosts.go b/model/hosts.go index f36894d..2146fa5 100644 --- a/model/hosts.go +++ b/model/hosts.go @@ -746,17 +746,27 @@ func GetClientIPByMode(ipMode string, netSrcIp string, srcIP string) string { return netSrcIp } -// HttpAuthConfig 「网站密码访问」的会话时效配置(hosts.HttpAuthJSON)。 +// HttpAuthConfig 「网站密码访问」的会话时效与保护范围配置(hosts.HttpAuthJSON)。 // // 兼容硬约束:HttpAuthJSON 为空串时(全部存量站点),DecodeHttpAuthConfig 必须还原成 -// 「24 小时绝对有效期 + 绑定登录 IP + 不启用空闲超时」,即与加这套配置之前的行为逐条一致。 +// 「24 小时绝对有效期 + 绑定登录 IP + 不启用空闲超时 + 整站保护」,即与加这套配置之前的行为逐条一致。 // 数值字段用 FlexInt 是因为前端表单回传的是字符串,普通 int 会让该字段悄悄回落默认值。 type HttpAuthConfig struct { - SessionTTL FlexInt `json:"session_ttl"` // 绝对有效期(分钟),<=0 视为默认 1440 - IdleTimeout FlexInt `json:"idle_timeout"` // 空闲超时(分钟),0=不启用 - BindIP FlexInt `json:"bind_ip"` // 1=登录令牌绑定登录时的 IP(默认) 0=不绑 + SessionTTL FlexInt `json:"session_ttl"` // 绝对有效期(分钟),<=0 视为默认 1440 + IdleTimeout FlexInt `json:"idle_timeout"` // 空闲超时(分钟),0=不启用 + BindIP FlexInt `json:"bind_ip"` // 1=登录令牌绑定登录时的 IP(默认) 0=不绑 + PathScope string `json:"path_scope"` // 保护范围:all=整站(默认) include=仅指定路径 exclude=除指定路径外 + Paths string `json:"paths"` // 路径模式清单(换行/逗号分隔,支持 * 通配),path_scope=all 时忽略 + BypassIPGroup string `json:"bypass_ip_group"` // 免密IP组 code,命中该组的来源跳过密码校验,空=不启用 } +// 保护范围取值。空串与非法值一律等价 all(整站),解析失败不允许静默改变防护行为。 +const ( + HttpAuthScopeAll = "all" + HttpAuthScopeInclude = "include" + HttpAuthScopeExclude = "exclude" +) + // 默认值。DefaultHttpAuthSessionTTL 对齐改造前硬编码的 24 小时。 const ( DefaultHttpAuthSessionTTL = 1440 // 分钟 @@ -769,9 +779,12 @@ const ( // 两者都属于「解析失败反而改变了防护行为」,这里不允许发生。 func DecodeHttpAuthConfig(raw string) HttpAuthConfig { cfg := HttpAuthConfig{ - SessionTTL: DefaultHttpAuthSessionTTL, - IdleTimeout: 0, - BindIP: 1, + SessionTTL: DefaultHttpAuthSessionTTL, + IdleTimeout: 0, + BindIP: 1, + PathScope: HttpAuthScopeAll, + Paths: "", + BypassIPGroup: "", } if strings.TrimSpace(raw) == "" { return cfg @@ -792,6 +805,15 @@ func DecodeHttpAuthConfig(raw string) HttpAuthConfig { if !jsonHasKey(raw, "bind_ip") { cfg.BindIP = 1 } + // 保护范围:只认三个合法取值,其余(含缺省)一律整站 + switch parsed.PathScope { + case HttpAuthScopeInclude, HttpAuthScopeExclude: + cfg.PathScope = parsed.PathScope + default: + cfg.PathScope = HttpAuthScopeAll + } + cfg.Paths = strings.TrimSpace(parsed.Paths) + cfg.BypassIPGroup = strings.TrimSpace(parsed.BypassIPGroup) return cfg } diff --git a/model/http_auth_config_test.go b/model/http_auth_config_test.go index 4995846..bba2a6e 100644 --- a/model/http_auth_config_test.go +++ b/model/http_auth_config_test.go @@ -17,6 +17,13 @@ func TestDecodeHttpAuthConfigCompat(t *testing.T) { if cfg.BindIP != 1 { t.Errorf("raw=%q BindIP=%d, 期望 1(绑定)", raw, cfg.BindIP) } + // #987 之前/之内的老配置都没有范围字段,必须整站保护,不能因为字段缺失改变防护行为 + if cfg.PathScope != HttpAuthScopeAll { + t.Errorf("raw=%q PathScope=%q, 期望 %q(整站)", raw, cfg.PathScope, HttpAuthScopeAll) + } + if cfg.Paths != "" || cfg.BypassIPGroup != "" { + t.Errorf("raw=%q Paths/BypassIPGroup 应为空, 实际 %q/%q", raw, cfg.Paths, cfg.BypassIPGroup) + } } } @@ -53,3 +60,39 @@ func TestDecodeHttpAuthConfig(t *testing.T) { }) } } + +// TestDecodeHttpAuthConfigPathScope 保护范围三态:合法取值透传,缺省与非法值回落整站。 +func TestDecodeHttpAuthConfigPathScope(t *testing.T) { + cases := []struct { + name string + raw string + wantScope string + wantPaths string + wantBypass string + }{ + {"include 透传", `{"path_scope":"include","paths":"/admin","bypass_ip_group":"g1"}`, + HttpAuthScopeInclude, "/admin", "g1"}, + {"exclude 透传", `{"path_scope":"exclude","paths":"/public"}`, + HttpAuthScopeExclude, "/public", ""}, + {"all 显式", `{"path_scope":"all"}`, HttpAuthScopeAll, "", ""}, + {"缺省 = 整站", `{"session_ttl":60}`, HttpAuthScopeAll, "", ""}, + {"非法值回落整站", `{"path_scope":"INCLUDE"}`, HttpAuthScopeAll, "", ""}, + {"未知值回落整站", `{"path_scope":"only"}`, HttpAuthScopeAll, "", ""}, + {"周边空白被裁剪", `{"path_scope":"include","paths":" /admin ","bypass_ip_group":" "}`, + HttpAuthScopeInclude, "/admin", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cfg := DecodeHttpAuthConfig(c.raw) + if cfg.PathScope != c.wantScope { + t.Errorf("PathScope=%q, 期望 %q", cfg.PathScope, c.wantScope) + } + if cfg.Paths != c.wantPaths { + t.Errorf("Paths=%q, 期望 %q", cfg.Paths, c.wantPaths) + } + if cfg.BypassIPGroup != c.wantBypass { + t.Errorf("BypassIPGroup=%q, 期望 %q", cfg.BypassIPGroup, c.wantBypass) + } + }) + } +} diff --git a/wafenginecore/accessgate/pathmatch.go b/wafenginecore/accessgate/pathmatch.go new file mode 100644 index 0000000..48b1ad4 --- /dev/null +++ b/wafenginecore/accessgate/pathmatch.go @@ -0,0 +1,108 @@ +package accessgate + +import "strings" + +// pathmatch.go 是「按路径开启」类功能的通用路径模式匹配器。 +// +// 与 MatchPathPrefix(纯前缀白名单)的区别:这里的条目允许写 `*` 通配符, +// 供「网站密码访问」的 include/exclude 路径清单使用。两个函数并存、语义各自独立: +// Access 白名单保持纯前缀行为不变,本文件不回头影响它。 + +const ( + // pathPatternMaxEntries / pathPatternMaxLen 限制单站点清单的规模。 + // 匹配发生在每个受保护请求的热路径上,清单无上限等于让用户自己决定引擎的开销。 + pathPatternMaxEntries = 50 + pathPatternMaxLen = 256 +) + +// BuildPathPatterns 把用户填的多行文本解析成可匹配的模式列表。 +// 接受换行与逗号分隔;去空白、转小写、丢弃空条目、补前导 /; +// 每条截断到 256 字符、整表截断到 50 条(截断只影响匹配能力,不影响安全性)。 +func BuildPathPatterns(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + replaced := strings.NewReplacer("\r\n", "\n", "\r", "\n", ",", "\n").Replace(raw) + var out []string + for _, line := range strings.Split(replaced, "\n") { + if len(out) >= pathPatternMaxEntries { + break + } + item := strings.ToLower(strings.TrimSpace(line)) + if item == "" { + continue + } + // 路径都以 / 开头(*.php 归一成 /*.php 后语义不变:请求路径必然带前导 /) + if !strings.HasPrefix(item, "/") { + item = "/" + item + } + if len(item) > pathPatternMaxLen { + item = item[:pathPatternMaxLen] + } + out = append(out, item) + } + return out +} + +// MatchPathPatterns 判断已归一化的请求路径 p 是否命中模式列表。 +// +// 双语义,由条目自己决定: +// - 不含 *:段边界前缀,与 MatchPathPrefix 同款——/admin 命中 /admin 与 /admin/**, +// 不误伤 /adminconsole。这是默认写法,用户填的绝大多数是这种。 +// - 含 *:全路径通配,* 可跨 /(/api/*/private、*.php、/admin*)。 +// 用户显式写了 * 就等于放弃段边界保护,按字面通配。 +// +// 调用方须先把 p 归一化(小写 + path.Clean),与 MatchPathPrefix 的约定一致。 +func MatchPathPatterns(p string, list []string) bool { + for _, item := range list { + if item == "" { + continue + } + if strings.Contains(item, "*") { + if globMatch(item, p) { + return true + } + continue + } + // 尾斜杠归一化 + 段边界比对,逻辑与 MatchPathPrefix 单条一致 + item = strings.TrimRight(item, "/") + if item == "" { + return true // 用户显式写了单个 "/",语义就是全部路径 + } + if p == item || strings.HasPrefix(p, item+"/") { + return true + } + } + return false +} + +// globMatch 只支持 * 一种元字符的全路径通配匹配(* 可跨 /)。 +// +// 刻意用双指针贪心+回退而不是 regexp.Compile:多个 * 的正则会产生灾难性回溯, +// 而这个算法是线性的,模式再长也不会让一个请求把引擎拖死。 +func globMatch(pattern, s string) bool { + si, pi := 0, 0 + starIdx, matchIdx := -1, 0 + for si < len(s) { + switch { + case pi < len(pattern) && pattern[pi] == '*': + starIdx = pi + matchIdx = si + pi++ + case pi < len(pattern) && pattern[pi] == s[si]: + si++ + pi++ + case starIdx != -1: + // 当前位置失配,回退到最近一个 * 的下一位,让它多吃一个字符 + pi = starIdx + 1 + matchIdx++ + si = matchIdx + default: + return false + } + } + for pi < len(pattern) && pattern[pi] == '*' { + pi++ + } + return pi == len(pattern) +} diff --git a/wafenginecore/accessgate/pathmatch_test.go b/wafenginecore/accessgate/pathmatch_test.go new file mode 100644 index 0000000..04e0158 --- /dev/null +++ b/wafenginecore/accessgate/pathmatch_test.go @@ -0,0 +1,147 @@ +package accessgate + +import ( + "strings" + "testing" + "time" +) + +// TestMatchPathPatterns 双语义匹配:普通条目按段边界前缀,含 * 条目按全路径通配。 +func TestMatchPathPatterns(t *testing.T) { + list := []string{"/admin", "/api/*/private", "*.php", "/manage*"} + cases := []struct { + p string + want bool + }{ + // /admin:段边界前缀 + {"/admin", true}, + {"/admin/login", true}, + {"/admin/", true}, // 调用方传归一化后的路径,此行只防未归一化的漏网 + {"/adminconsole", false}, + {"/adminx", false}, + + // /api/*/private:* 跨 / + {"/api/v1/private", true}, + {"/api/v1/v2/private", true}, + {"/api/private", false}, + {"/api/v1/private/x", false}, // 通配条目是全路径匹配,不是前缀 + + // *.php:后缀通配 + {"/index.php", true}, + {"/dir/a.php", true}, + {"/index.php.jpg", false}, + {"/index.html", false}, + + // /manage*:显式通配放弃段边界 + {"/manage", true}, + {"/managex", true}, + {"/manage/panel", true}, + {"/manageconsole", true}, + {"/man", false}, + } + for _, c := range cases { + if got := MatchPathPatterns(c.p, list); got != c.want { + t.Fatalf("MatchPathPatterns(%q) = %v, 期望 %v", c.p, got, c.want) + } + } +} + +func TestMatchPathPatternsEdge(t *testing.T) { + // nil / 空条目安全 + if MatchPathPatterns("/anything", nil) { + t.Fatal("空列表不该匹配任何路径") + } + if MatchPathPatterns("/anything", []string{""}) { + t.Fatal("空条目不能匹配所有路径") + } + // 单个 "/" 是显式的"全部路径" + if !MatchPathPatterns("/whatever", []string{"/"}) { + t.Fatal(`"/" 应匹配所有路径`) + } + // 尾斜杠条目等价于无尾斜杠 + if !MatchPathPatterns("/static/a.js", []string{"/static/"}) { + t.Fatal("/static/ 应命中其子路径") + } + // 纯 * 条目匹配一切 + if !MatchPathPatterns("/a/b/c", []string{"*"}) { + t.Fatal("* 应匹配所有路径") + } + // 两个 * 的长模式也不能退化 + if MatchPathPatterns("/aaaaab", []string{"/*a*a*a*a*a*a*a*a*a*a*a*a*b*c"}) { + t.Fatal("不该命中") + } + if !MatchPathPatterns("/aaaaab", []string{"/*a*a*b"}) { + t.Fatal("该命中而未命中") + } + // * 跨 / 的显式用例(对抗审查补充:文档承诺的语义必须有测试钉住) + if !MatchPathPatterns("/a/x/b", []string{"/a*b"}) { + t.Fatal("* 必须能跨越 /") + } + // 单字符路径与星号收尾 + if !MatchPathPatterns("/a", []string{"/*"}) || !MatchPathPatterns("/a", []string{"/a*"}) { + t.Fatal("/* 与 /a* 都应命中 /a") + } +} + +// TestBuildPathPatterns 解析:换行/逗号混合、去空、小写、补前导 /、截断。 +func TestBuildPathPatterns(t *testing.T) { + got := BuildPathPatterns("/Admin,\r\n/api/*/private, *.php \n\n,,no-slash") + expect := []string{"/admin", "/api/*/private", "/*.php", "/no-slash"} + if len(got) != len(expect) { + t.Fatalf("解析出 %d 条, 期望 %d 条: %v", len(got), len(expect), got) + } + for i := range expect { + if got[i] != expect[i] { + t.Fatalf("第 %d 条 = %q, 期望 %q", i, got[i], expect[i]) + } + } + + if BuildPathPatterns(" \r\n , ") != nil { + t.Fatal("全空输入应返回 nil") + } + + // 超 50 条截断 + var sb strings.Builder + for i := 0; i < 80; i++ { + sb.WriteString("/p") + sb.WriteString(itoa(i)) + sb.WriteString("\n") + } + if got := BuildPathPatterns(sb.String()); len(got) != pathPatternMaxEntries { + t.Fatalf("80 条输入应截断为 %d 条, 实际 %d", pathPatternMaxEntries, len(got)) + } + + // 单条超 256 字符截断 + long := "/" + strings.Repeat("a", 400) + if got := BuildPathPatterns(long); len(got[0]) != pathPatternMaxLen { + t.Fatalf("超长条目应截断为 %d 字符, 实际 %d", pathPatternMaxLen, len(got[0])) + } +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + return string(b) +} + +// TestGlobMatchPathological 性能锚点:50 条多 * 长模式 × 2048 字符路径, +// 线性算法应瞬时完成;若有人换成 regexp 实现会在这里超时。 +func TestGlobMatchPathological(t *testing.T) { + pattern := "/" + strings.Repeat("a*/", 20) + strings.Repeat("*a", 20) + list := make([]string, pathPatternMaxEntries) + for i := range list { + list[i] = pattern + } + s := "/" + strings.Repeat("a", 2048) + start := time.Now() + MatchPathPatterns(s, list) + if elapsed := time.Since(start); elapsed > 500*time.Millisecond { + t.Fatalf("50 条多*模式匹配耗时 %v, 疑似退化为回溯实现", elapsed) + } +} diff --git a/wafenginecore/httpauth_scope_test.go b/wafenginecore/httpauth_scope_test.go new file mode 100644 index 0000000..5959eb9 --- /dev/null +++ b/wafenginecore/httpauth_scope_test.go @@ -0,0 +1,184 @@ +package wafenginecore + +import ( + "encoding/base64" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "testing" + + "SamWaf/cache" + "SamWaf/common/zlog" + "SamWaf/global" + "SamWaf/model" + "SamWaf/model/wafenginmodel" + "SamWaf/wafdb/dialect" + "SamWaf/wafenginecore/ipset" + + sqlitedriver "github.com/samwafgo/sqlitedriver" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// TestHttpAuthPathScope 「网站密码访问」按路径开启的引擎级判定。 +// +// 覆盖:include/exclude 命中与未命中、路径穿越与大小写变体、免密IP组、 +// custom 登录页可达性、include 空清单 fail-closed、非法 scope 回落整站、空 JSON 回归。 +// +// 正确凭证的会话记账链路(TouchBasicSession 建行)不在本文件覆盖: +// GWAF_LOCAL_DB 指向未建 session 表的库,Create 报错走「记账失败不连累访问」分支, +// 恰好验证放行;完整会话行为由 Python 验证脚本对真实实例验收。 +func TestHttpAuthPathScope(t *testing.T) { + zlog.InitZLog(false, "console") + + oldCache := global.GCACHE_WAFCACHE + oldLogDB, oldCoreDB := global.GWAF_LOCAL_LOG_DB, global.GWAF_LOCAL_DB + global.GCACHE_WAFCACHE = cache.InitWafCache() + defer func() { + global.GCACHE_WAFCACHE = oldCache + global.GWAF_LOCAL_LOG_DB = oldLogDB + global.GWAF_LOCAL_DB = oldCoreDB + }() + + // log 库:迁移审计表,被拦路径的节流审计要落这里 + // core 库:刻意不迁移,session 建行必然失败 → 走「不连累访问」分支 + dialect.Register(&dialect.SQLiteDialect{}) + dir := t.TempDir() + open := func(name string) *gorm.DB { + dsn := filepath.Join(dir, name) + "?_db_key=" + url.QueryEscape("ktest") + db, err := gorm.Open(sqlitedriver.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("sqlite 打开 %s 失败: %v", name, err) + } + // Windows 下不关句柄,TempDir 的 RemoveAll 会因文件占用失败 + t.Cleanup(func() { + if s, e := db.DB(); e == nil { + _ = s.Close() + } + }) + return db + } + logDB := open("log.db") + if err := logDB.AutoMigrate(&model.SecurityAuditLog{}); err != nil { + t.Fatalf("迁移审计表失败: %v", err) + } + global.GWAF_LOCAL_LOG_DB = logDB + global.GWAF_LOCAL_DB = open("core-nomigrate.db") + + // 免密IP组:注册一个含 10.1.1.1 的组,组不存在/被删的场景用未注册 code 表达 + ipset.UpsertGroupMatcher("g_test", "测试组", ipset.BuildMatchSet([]string{"10.1.1.1"})) + defer ipset.SetGroupSnapshot(nil) + + waf := &WafEngine{} + newHost := func(authJSON, authType string) *wafenginmodel.HostSafe { + h := &wafenginmodel.HostSafe{} + h.Host.Code = "hs_scope_host" + h.Host.Host = "test.example.com" + h.Host.IsEnableHttpAuthBase = 1 + h.Host.HttpAuthBaseType = authType + h.Host.HttpAuthJSON = authJSON + h.HttpAuthBases = []model.HttpAuthBase{{UserName: "u", Password: "p"}} + return h + } + // run 发一个请求并返回 (是否被这道门接住, 响应记录器) + run := func(host *wafenginmodel.HostSafe, target string, clientIP string, hdr map[string]string) (bool, *httptest.ResponseRecorder) { + r := httptest.NewRequest(http.MethodGet, "http://test.example.com"+target, nil) + for k, v := range hdr { + r.Header.Set(k, v) + } + w := httptest.NewRecorder() + blocked, _ := waf.DoHttpAuthBase(host, w, r, clientIP) + return blocked, w + } + basic := func(u, p string) map[string]string { + return map[string]string{"Authorization": "Basic " + b64(u+":"+p)} + } + + cases := []struct { + name string + json string + authTyp string + target string + clientIP string + hdr map[string]string + blocked bool + }{ + // —— 回归:空 JSON / 缺省 scope 一律整站 —— + {"空JSON整站拦", ``, "", "/anything", "8.8.8.8", nil, true}, + {"显式all整站拦", `{"path_scope":"all"}`, "", "/anything", "8.8.8.8", nil, true}, + {"非法scope回落整站拦", `{"path_scope":"only"}`, "", "/anything", "8.8.8.8", nil, true}, + + // —— include:仅命中清单的路径要密码 —— + {"include命中拦", `{"path_scope":"include","paths":"/admin"}`, "", "/admin", "8.8.8.8", nil, true}, + {"include子路径拦", `{"path_scope":"include","paths":"/admin"}`, "", "/admin/login", "8.8.8.8", nil, true}, + {"include范围外直通", `{"path_scope":"include","paths":"/admin"}`, "", "/public/index", "8.8.8.8", nil, false}, + {"include段边界不误伤", `{"path_scope":"include","paths":"/admin"}`, "", "/adminconsole", "8.8.8.8", nil, false}, + {"include通配命中拦", `{"path_scope":"include","paths":"*.php"}`, "", "/dir/a.php", "8.8.8.8", nil, true}, + {"include通配未命中直通", `{"path_scope":"include","paths":"*.php"}`, "", "/dir/a.html", "8.8.8.8", nil, false}, + {"include多行逗号混合", `{"path_scope":"include","paths":"/a,/b\n/c"}`, "", "/c/x", "8.8.8.8", nil, true}, + + // —— 安全核心:归一化防穿越 —— + {"穿越变体必须拦", `{"path_scope":"include","paths":"/admin"}`, "", "/x/../admin", "8.8.8.8", nil, true}, + {"点号段进子路径必须拦", `{"path_scope":"include","paths":"/admin"}`, "", "/admin/../admin/panel", "8.8.8.8", nil, true}, + {"编码变体必须拦", `{"path_scope":"include","paths":"/admin"}`, "", "/%41dmin", "8.8.8.8", nil, true}, + {"大写路径必须拦", `{"path_scope":"include","paths":"/admin"}`, "", "/ADMIN/console", "8.8.8.8", nil, true}, + {"双斜杠变体必须拦", `{"path_scope":"include","paths":"/admin"}`, "", "//admin", "8.8.8.8", nil, true}, + {"点号尾巴必须拦", `{"path_scope":"include","paths":"/admin"}`, "", "/admin/.", "8.8.8.8", nil, true}, + {"归一到根则不拦", `{"path_scope":"include","paths":"/admin"}`, "", "/x/..", "8.8.8.8", nil, false}, + + // —— exclude:除清单外全站要密码 —— + {"exclude命中放行", `{"path_scope":"exclude","paths":"/public"}`, "", "/public/x", "8.8.8.8", nil, false}, + {"exclude未命中拦", `{"path_scope":"exclude","paths":"/public"}`, "", "/admin", "8.8.8.8", nil, true}, + {"exclude空清单=整站", `{"path_scope":"exclude","paths":""}`, "", "/anything", "8.8.8.8", nil, true}, + + // —— fail-closed:include 空清单按整站 —— + {"include空清单按整站拦", `{"path_scope":"include","paths":""}`, "", "/anything", "8.8.8.8", nil, true}, + + // —— 免密IP组 —— + {"免密组命中直通", `{"path_scope":"all","bypass_ip_group":"g_test"}`, "", "/admin", "10.1.1.1", nil, false}, + {"免密组未命中仍拦", `{"path_scope":"all","bypass_ip_group":"g_test"}`, "", "/admin", "10.2.2.2", nil, true}, + {"免密组不存在回落拦", `{"path_scope":"all","bypass_ip_group":"g_missing"}`, "", "/admin", "10.1.1.1", nil, true}, + + // —— 凭证与范围的组合 —— + {"include外带错凭证也直通(不验凭证)", `{"path_scope":"include","paths":"/admin"}`, "", "/public", "8.8.8.8", basic("u", "wrong"), false}, + {"include内对凭证放行", `{"path_scope":"include","paths":"/admin"}`, "", "/admin", "8.8.8.8", basic("u", "p"), false}, + + // —— custom 模式:登录页端点无条件可达 —— + {"custom+include登录页可达", `{"path_scope":"include","paths":"/admin"}`, model.HttpAuthTypeCustom, "/samwaf_httpauth/validate", "8.8.8.8", nil, true}, + {"custom+include受保护路径拦", `{"path_scope":"include","paths":"/admin"}`, model.HttpAuthTypeCustom, "/admin", "8.8.8.8", nil, true}, + {"custom+include范围外直通", `{"path_scope":"include","paths":"/admin"}`, model.HttpAuthTypeCustom, "/public", "8.8.8.8", nil, false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + host := newHost(c.json, c.authTyp) + blocked, rec := run(host, c.target, c.clientIP, c.hdr) + if blocked != c.blocked { + t.Fatalf("blocked = %v, 期望 %v (status=%d body=%.120s)", blocked, c.blocked, rec.Code, rec.Body.String()) + } + }) + } +} + +// TestHttpAuthPathScopeUnit 判定函数本身的补充口径(不碰 DB/审计链路)。 +func TestHttpAuthPathScopeUnit(t *testing.T) { + zlog.InitZLog(false, "console") // include 空清单的节流告警要走 zlog + + // 自定义前缀也能当自服务路径 + h := model.Hosts{HttpAuthBaseType: model.HttpAuthTypeCustom, HttpAuthPathPrefix: "/My_Auth/"} + if got := httpAuthLoginPrefix(h); got != "/my_auth" { + t.Fatalf("httpAuthLoginPrefix = %q, 期望 /my_auth", got) + } + if got := httpAuthLoginPrefix(model.Hosts{HttpAuthBaseType: ""}); got != "" { + t.Fatalf("非 custom 模式应返回空串, 实际 %q", got) + } + // include 清单全是空白条目 = 空清单,必须按整站保护 + if !httpAuthPathInScope(model.HttpAuthConfig{PathScope: model.HttpAuthScopeInclude, Paths: " ,,\n "}, "/x") { + t.Fatal("include 空白清单必须按整站保护") + } +} + +func b64(s string) string { + return base64.StdEncoding.EncodeToString([]byte(s)) +} diff --git a/wafenginecore/wafworker.go b/wafenginecore/wafworker.go index be7294a..de174cf 100644 --- a/wafenginecore/wafworker.go +++ b/wafenginecore/wafworker.go @@ -11,6 +11,8 @@ import ( "SamWaf/model/wafenginmodel" "SamWaf/service/waf_service" "SamWaf/utils" + "SamWaf/wafenginecore/accessgate" + "SamWaf/wafenginecore/ipset" "SamWaf/wafenginecore/loadbalance" "SamWaf/wafproxy" "context" @@ -19,8 +21,10 @@ import ( "fmt" "net/http" "os" + "path" "strconv" "strings" + "sync/atomic" "time" goahocorasick "github.com/samwafgo/ahocorasick" @@ -547,13 +551,88 @@ func (waf *WafEngine) CheckResponseSensitive() bool { // 全站都是同一个边缘节点地址,「绑定登录 IP」这条约束也会一并失效。 func (waf *WafEngine) DoHttpAuthBase(hostSafe *wafenginmodel.HostSafe, w http.ResponseWriter, r *http.Request, clientIP string) (bool, string) { cfg := model.DecodeHttpAuthConfig(hostSafe.Host.HttpAuthJSON) + + // 路径归一化:小写 + path.Clean。下面所有路径判定(登录页前缀、保护范围)都必须用 p, + // 不能用 r.URL.Path —— 后者带着未消解的 . 与 ..,一个 /x/../admin 能绕过任何 + // 按前缀写的清单,而 Go 的 http.Server 对非 ServeMux 的 Handler 不做任何路径清洗。 + p := strings.ToLower(path.Clean(r.URL.Path)) + + // ⓪ custom 模式的登录/校验/登出端点无条件视为在保护范围内:include 模式下这些 + // 前缀通常不在用户填的清单里,若按范围判定放行透传给后端就是 404,登录流程死锁。 + // 必须排在免密IP组与范围判定之前(自服务分支,参照 Access 模式 ⓶ 的设计)。 + // 与原实现一致只认 前缀+"/":裸前缀精确访问维持走正常认证流程。 + if loginPrefix := httpAuthLoginPrefix(hostSafe.Host); loginPrefix != "" && + strings.HasPrefix(p, loginPrefix+"/") { + return waf.doCustomAuth(hostSafe, w, r, clientIP, cfg) + } + + // ① 免密IP组:命中的来源直接放行。组被删除时 GetGroupMatcher 返回 nil, + // ContainsStr 安全返回 false → 回落到要密码(fail-closed),删组不会敞开门。 + if cfg.BypassIPGroup != "" && ipset.GetGroupMatcher(cfg.BypassIPGroup).ContainsStr(clientIP) { + return false, "" + } + + // ② 保护范围判定:范围外直接放行,不写日志也不记审计—— + // 这不是拦截,是这些路径本来就不归这道门管。 + if !httpAuthPathInScope(cfg, p) { + return false, "" + } + + // ③ 空值(存量站点)与未知取值都按 Basic 走:开关已经开了,认不出类型就放行等于这道门形同虚设 if hostSafe.Host.HttpAuthBaseType == model.HttpAuthTypeCustom { return waf.doCustomAuth(hostSafe, w, r, clientIP, cfg) } - // 空值(存量站点)与未知取值都按 Basic 走:开关已经开了,认不出类型就放行等于这道门形同虚设 return waf.doBasicAuth(hostSafe, w, r, clientIP, cfg) } +// httpAuthLoginPrefix custom 模式登录页前缀的归一化形态(小写、无尾斜杠)。 +// 非 custom 模式返回空串,表示没有自服务路径。 +func httpAuthLoginPrefix(host model.Hosts) string { + if host.HttpAuthBaseType != model.HttpAuthTypeCustom { + return "" + } + return strings.TrimRight(strings.ToLower(utils.GetHttpAuthPathOrDefault(host.HttpAuthPathPrefix)), "/") +} + +// httpAuthEmptyIncludeWarnAt include 模式空清单告警的节流时间戳(秒)。 +// 这条告警发生在热路径上,不节流的话受保护站点的每个请求都会刷一行日志。 +var httpAuthEmptyIncludeWarnAt atomic.Int64 + +// httpAuthPathInScope 判断已归一化的 p 是否落在「网站密码访问」的保护范围内。 +func httpAuthPathInScope(cfg model.HttpAuthConfig, p string) bool { + switch cfg.PathScope { + case model.HttpAuthScopeInclude: + patterns := accessgate.BuildPathPatterns(cfg.Paths) + if len(patterns) == 0 { + // fail-closed:选了「仅指定路径」却没填路径,按整站保护。 + // 静默当成"什么都不保护"等于一次配置失误就拆掉整道门。 + warnHttpAuthEmptyInclude() + return true + } + return accessgate.MatchPathPatterns(p, patterns) + case model.HttpAuthScopeExclude: + patterns := accessgate.BuildPathPatterns(cfg.Paths) + if len(patterns) == 0 { + // 「除空集外全站要密码」的字面语义就是整站,正常特例,无需告警 + return true + } + return !accessgate.MatchPathPatterns(p, patterns) + default: + // all 与一切非法取值:整站(DecodeHttpAuthConfig 已把非法值洗成 all,这里兜底) + return true + } +} + +func warnHttpAuthEmptyInclude() { + now := time.Now().Unix() + last := httpAuthEmptyIncludeWarnAt.Load() + if now-last < 300 || !httpAuthEmptyIncludeWarnAt.CompareAndSwap(last, now) { + return + } + zlog.Warn("网站密码访问:保护范围选了「仅指定路径」但路径清单为空,已按整站保护。" + + "请到站点编辑里补全路径,或把保护范围改回整站") +} + // writeBasicChallenge 发 401 并要求浏览器输入密码。 // // nonce 非空时 realm 会跟着变——浏览器按 realm 缓存凭证,realm 不变就只会拿旧凭证 @@ -689,10 +768,13 @@ func (waf *WafEngine) doBasicAuth(hostSafe *wafenginmodel.HostSafe, w http.Respo func (waf *WafEngine) doCustomAuth(hostSafe *wafenginmodel.HostSafe, w http.ResponseWriter, r *http.Request, clientIP string, cfg model.HttpAuthConfig) (bool, string) { - authPathPrefix := utils.GetHttpAuthPathOrDefault(hostSafe.Host.HttpAuthPathPrefix) + // 归一化后的登录页前缀:与 DoHttpAuthBase ⓪ 用同一份判定口径, + // 大小写混淆、点号段变体进不了这个分支 + authPathPrefix := httpAuthLoginPrefix(hostSafe.Host) + p := strings.ToLower(path.Clean(r.URL.Path)) - // 处理登录页面自身的请求 - if strings.HasPrefix(r.URL.Path, authPathPrefix+"/") { + // 处理登录页面自身的请求(保持原口径:只认 前缀+"/",裸前缀走正常认证流程) + if strings.HasPrefix(p, authPathPrefix+"/") { waf.handleHttpAuthRequest(hostSafe, w, r, authPathPrefix, clientIP, cfg) return true, "处理HTTP Auth请求" } @@ -716,7 +798,9 @@ func (waf *WafEngine) doCustomAuth(hostSafe *wafenginmodel.HostSafe, w http.Resp // handleHttpAuthRequest 处理HTTP Auth相关请求 func (waf *WafEngine) handleHttpAuthRequest(hostSafe *wafenginmodel.HostSafe, w http.ResponseWriter, r *http.Request, pathPrefix, clientIP string, cfg model.HttpAuthConfig) { - path := strings.TrimPrefix(r.URL.Path, pathPrefix+"/") + // 用归一化后的路径裁剪:进入本函数的请求已在 doCustomAuth 按归一化前缀判过, + // 若按 r.URL.Path 裁剪,大小写或点号段变体会让 TrimPrefix 落空、validate 变 404 + path := strings.TrimPrefix(strings.ToLower(path.Clean(r.URL.Path)), pathPrefix+"/") // 处理验证接口 if path == "validate" && r.Method == "POST" { diff --git a/wafupgradenotice/upgrade_notes.yaml b/wafupgradenotice/upgrade_notes.yaml index 1107046..8c6ad9e 100644 --- a/wafupgradenotice/upgrade_notes.yaml +++ b/wafupgradenotice/upgrade_notes.yaml @@ -938,3 +938,42 @@ notes: (2026-08-19 13:51~14:40) with counts written as readable numbers instead of seven identical dates, and 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_httpauth_path_scope + version: v1.3.25 + kind: notice + level: normal + page: /waf/wafhost + doc: https://doc.samwaf.com/quickstart/Update.html + apply: + type: navigate + zh: + title: 网站密码访问可以只保护指定路径了,还可搭配免密IP组 + detail: >- + 「网站密码访问」新增**保护范围**设置,站点编辑的密码访问页签里可选三种: + 整站开启(原有行为,默认)、仅指定路径需要密码(比如只保护 /admin、/api/manage + 这类后台与敏感接口)、除指定路径外全站需要密码(比如只放开 /healthz 给监控探针)。 + 已有站点不改配置则行为完全不变。路径清单每行一条(也支持逗号分隔), + 普通路径按整段目录匹配:写 /admin 保护 /admin 及其全部子路径,不会误伤 /adminconsole; + 带 * 的条目按通配匹配且 * 可以跨目录,如 *.php、/api/*/private。 + 同时新增**免密IP组**:命中所选 IP 组的来源跳过密码校验直接访问,适合内网监控、 + 健康检查这类无法登录的调用方;IP 组在「IP组」页面统一维护,组被删除后自动回落为需要密码。 + 一个兜底行为需要知道:保护范围选了「仅指定路径」但路径清单是空的,会按整站保护并在 + 系统日志里提示——配置不完整时不会让这道门静默失效。 + en: + title: Site password access can now protect only specific paths, with a password-free IP group option + detail: >- + Site password access gains a **protection scope** setting in the password tab of site editing, + with three choices: whole site (the original behavior, and the default), only listed paths + require the password (protect just /admin or /api/manage style consoles and APIs), and whole + site except listed paths (open only /healthz for a monitor probe, for example). Existing sites + keep exactly the same behavior until you change the setting. The path list takes one entry per + line (commas also work); a plain path matches a whole directory tree, so /admin protects + /admin and everything below it without touching /adminconsole, while an entry containing * + is a wildcard match where * also crosses directories, as in *.php or /api/*/private. + A **password-free IP group** comes along: sources hitting the selected IP group skip the + password check entirely, which suits internal monitors and health checks that cannot log in. + Groups are managed on the IP Groups page, and deleting a group simply restores the password + requirement. One fallback to know: if the scope is set to "only listed paths" but the list is + empty, protection falls back to the whole site with a note in the system log - an incomplete + configuration never silently disables the gate.