mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-10-31 02:01:44 +08:00
!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:
+30
-8
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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" {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user