!41 feat: website password access can now protect only specified paths, wi…

Merge pull request !41 from samwaf/feat_httpauth_path
This commit is contained in:
samwaf
2026-10-12 06:49:24 +00:00
committed by Gitee
7 changed files with 640 additions and 13 deletions
+30 -8
View File
@@ -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
}
+43
View File
@@ -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)
}
})
}
}
+108
View File
@@ -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)
}
+147
View File
@@ -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)
}
}
+184
View File
@@ -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))
}
+89 -5
View File
@@ -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" {
+39
View File
@@ -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.