Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion model/access_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package model

import (
"SamWaf/model/baseorm"
"strings"
)

// 未认证时的响应方式
Expand All @@ -21,7 +22,8 @@ const (
AccessDefaultIdleMin = 0 // 0=不启用空闲超时
AccessDefaultMaxFail = 10
AccessDefaultLockMinutes = 3
AccessDefaultCachePosTTL = 60 // 正向缓存上限(秒),同时也是踢下线的最坏生效延迟
AccessDefaultCachePosTTL = 60 // 正向缓存上限(秒),同时也是踢下线的最坏生效延迟
AccessCorsMaxAgeCap = 7200 // 预检缓存秒数上限,与 accessgate 侧的封顶一致
)

// AccessConfig 是统一访问认证的租户级全局配置,全表只有一行。
Expand Down Expand Up @@ -67,6 +69,15 @@ type AccessConfig struct {
ServiceTokenHeader string `gorm:"size:64" json:"service_token_header"` //服务令牌请求头名,如 X-Service-Token
ServiceTokenHashes string `gorm:"type:text" json:"-"` //可用令牌的 sha256,换行分隔,永不回显

// —— 跨源(CORS)——
// 默认全空 = 不启用,存量站点行为完全不变。
// 只影响「认证之前」的两种响应:预检代答与未认证的 401/302;
// 认证通过放行后的响应一律不补头,那是后端自己的事(补了会撞成两个 ACAO)。
CorsAllowOrigins string `gorm:"type:text" json:"cors_allow_origins"` //允许的完整 Origin,换行分隔,精确匹配
CorsAllowMethods string `gorm:"size:255" json:"cors_allow_methods"` //空=GET,POST,PUT,PATCH,DELETE,OPTIONS
CorsAllowHeaders string `gorm:"size:1024" json:"cors_allow_headers"` //空=回显请求的 Access-Control-Request-Headers
CorsMaxAge int `json:"cors_max_age"` //预检缓存秒数,空=600,上限7200

// —— 行为 ——
UnauthAction string `gorm:"size:16" json:"unauth_action"` //auto | redirect | 401
PassIdentityHeader int `json:"pass_identity_header"` //1=向后端透传 X-SamWaf-Access-User
Expand Down Expand Up @@ -143,4 +154,26 @@ func (c *AccessConfig) FillDefaults() {
if c.CachePositiveTTLSec <= 0 || c.CachePositiveTTLSec > AccessDefaultCachePosTTL {
c.CachePositiveTTLSec = d.CachePositiveTTLSec
}
// 跨源:不给 Origin 补默认值(空=不启用是刻意的),只做越界与非法字符收敛。
// 这三个值会被原样拼进响应头,CR/LF 必须在落库前就消掉。
if c.CorsMaxAge < 0 || c.CorsMaxAge > AccessCorsMaxAgeCap {
c.CorsMaxAge = 0
}
c.CorsAllowMethods = stripHeaderCtrlChars(c.CorsAllowMethods)
c.CorsAllowHeaders = stripHeaderCtrlChars(c.CorsAllowHeaders)
// Origin 清单是多行文本,换行是合法分隔符,只清 CR 与制表
c.CorsAllowOrigins = strings.NewReplacer("\r", "\n", "\t", " ").Replace(c.CorsAllowOrigins)
}

// stripHeaderCtrlChars 去掉会破坏响应头的控制字符。
// 引擎侧(accessgate)另有一道同样的清理,这里是落库前的第一道。
func stripHeaderCtrlChars(v string) string {
var b strings.Builder
b.Grow(len(v))
for i := 0; i < len(v); i++ {
if c := v[i]; c >= 0x20 && c != 0x7f {
b.WriteByte(c)
}
}
return strings.TrimSpace(b.String())
}
16 changes: 16 additions & 0 deletions model/hosts.go
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,13 @@ type HostAccessConfig struct {
RequireOtp int `json:"require_otp"` //0继承全局 1本站强制 2本站豁免
UnauthAction string `json:"unauth_action"` //""继承全局 auto|redirect|401
AllowIPGroupCode string `json:"allow_ip_group_code"` //本站额外的免认证 IP 组

// 跨源(CORS):按字段覆盖全局,空值即沿用全局,让用户只改 Origin 清单就能生效。
// 两级都为空 = 不启用,存量站点行为完全不变。
CorsAllowOrigins string `json:"cors_allow_origins"` //本站允许的完整 Origin,换行分隔,精确匹配
CorsAllowMethods string `json:"cors_allow_methods"` //空=沿用全局
CorsAllowHeaders string `json:"cors_allow_headers"` //空=沿用全局
CorsMaxAge int `json:"cors_max_age"` //空=沿用全局
}

// ParseAccessConfig 解析站点级 Access 配置。
Expand All @@ -516,6 +523,15 @@ func ParseAccessConfig(jsonStr string) HostAccessConfig {
default:
c.UnauthAction = ""
}
// 越界的预检缓存时长归零(=沿用全局),非法字符在这里就消掉:
// 这三个值会被原样拼进响应头,而站点配置是一整串 JSON 透传进来的,
// 不像全局配置那样必然经过 FillDefaults。
if c.CorsMaxAge < 0 || c.CorsMaxAge > AccessCorsMaxAgeCap {
c.CorsMaxAge = 0
}
c.CorsAllowMethods = stripHeaderCtrlChars(c.CorsAllowMethods)
c.CorsAllowHeaders = stripHeaderCtrlChars(c.CorsAllowHeaders)
c.CorsAllowOrigins = strings.NewReplacer("\r", "\n", "\t", " ").Replace(c.CorsAllowOrigins)
return c
}

Expand Down
6 changes: 6 additions & 0 deletions model/request/waf_access_req.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ type WafAccessConfigSaveReq struct {
// 空字符串 = 保持原样不动;填 "-" = 清空。这样前端不必回显密文也能安全编辑。
ServiceTokens string `json:"service_tokens"`

// 跨源(CORS)。全空 = 不启用,站点级可按字段覆盖。
CorsAllowOrigins string `json:"cors_allow_origins"`
CorsAllowMethods string `json:"cors_allow_methods"`
CorsAllowHeaders string `json:"cors_allow_headers"`
CorsMaxAge int `json:"cors_max_age"`

UnauthAction string `json:"unauth_action"`
PassIdentityHeader int `json:"pass_identity_header"`
ForceSecureCookie int `json:"force_secure_cookie"`
Expand Down
30 changes: 25 additions & 5 deletions service/waf_service/waf_access_config_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ func (receiver *WafAccessConfigService) SaveApi(req request.WafAccessConfigSaveR
bean.GlobalExcludePaths = req.GlobalExcludePaths
bean.BypassIPGroupCode = strings.TrimSpace(req.BypassIPGroupCode)
bean.ServiceTokenHeader = strings.TrimSpace(req.ServiceTokenHeader)
bean.CorsAllowOrigins = req.CorsAllowOrigins
bean.CorsAllowMethods = req.CorsAllowMethods
bean.CorsAllowHeaders = req.CorsAllowHeaders
bean.CorsMaxAge = req.CorsMaxAge
bean.UnauthAction = req.UnauthAction
bean.PassIdentityHeader = boolInt(req.PassIdentityHeader)
bean.ForceSecureCookie = boolInt(req.ForceSecureCookie)
Expand Down Expand Up @@ -211,11 +215,10 @@ func (receiver *WafAccessConfigService) PublishConfig() {
CenterOrigin: bean.CenterOrigin,
CenterHost: centerHost,

PathPrefix: accessgate.NormalizePathPrefix(bean.PathPrefix),
CookiePrefix: bean.CookiePrefix,
CookieSSOName: bean.CookiePrefix + "_sso",
CookieTokenName: bean.CookiePrefix + "_tk",
HmacSecret: []byte(secret),
PathPrefix: accessgate.NormalizePathPrefix(bean.PathPrefix),
CookiePrefix: bean.CookiePrefix,
CookieSSOName: bean.CookiePrefix + "_sso",
HmacSecret: []byte(secret),

SessionTTL: time.Duration(bean.SessionTTLMinutes) * time.Minute,
TokenTTL: time.Duration(bean.TokenTTLMinutes) * time.Minute,
Expand All @@ -233,11 +236,28 @@ func (receiver *WafAccessConfigService) PublishConfig() {
ServiceTokenHeader: bean.ServiceTokenHeader,
ServiceTokenHashes: splitLines(bean.ServiceTokenHashes),

CORS: accessgate.CORSPolicy{
AllowOrigins: accessgate.BuildAllowOrigins(bean.CorsAllowOrigins),
AllowMethods: accessgate.SanitizeHeaderValue(bean.CorsAllowMethods),
AllowHeaders: accessgate.SanitizeHeaderValue(bean.CorsAllowHeaders),
MaxAge: bean.CorsMaxAge,
},

UnauthAction: bean.UnauthAction,
PassIdentityHeader: bean.PassIdentityHeader == 1,
ForceSecureCookie: bean.ForceSecureCookie == 1,
CachePositiveTTL: time.Duration(bean.CachePositiveTTLSec) * time.Second,
}

// 非法 Origin 一律静默丢弃(fail-closed 是对的:宁可少放行一条,也不能因为一行写歪
// 就让整站跨源全开)。但"填了却不生效"必须有个说法,否则用户只会看到
// "明明配了 CORS 还是报跨源错"而无从查起。
if filled, kept := len(splitLines(bean.CorsAllowOrigins)), len(cfg.CORS.AllowOrigins); filled > kept {
zlog.Warn("统一访问认证:部分跨源 Origin 无效或重复,已被忽略。"+
"每行填完整的 scheme://host[:port](区分端口,不支持 * 与 null,不能带路径)",
"填写条数", filled, "生效条数", kept)
}

accessgate.SetConfig(cfg)
}

Expand Down
34 changes: 28 additions & 6 deletions service/waf_service/waf_access_session_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,34 @@ type AccessState struct {
AccountName string `json:"account_name"`
ExpireUnix int64 `json:"expire_unix"`
Host string `json:"host"`
HostCode string `json:"host_code"`
ClientIP string `json:"client_ip"`
Fingerprint string `json:"fingerprint"`
}

// matchBindings 校验令牌的绑定条件。慢路径(回库)与快路径(缓存命中)都必须走它,
// 保证两条路径的判定完全一致。
func (s *AccessState) matchBindings(host, clientIP, fingerprint string, cfg *accessgate.Config) bool {
// 令牌只在签发它的那个域名上有效。缺了这一条,从 a.com 拿到的 Cookie
// 就能在 b.com 上用——跨域 SSO 的每域独立令牌设计也就失去意义了。
//
// 绑定分两层,缺一不可:
//
// hostCode —— 站点记录的主键,与 Hosts 表 1:1。它是权威判据。
// 只比 host 字符串是不够的:域名归一化(去默认端口、去尾点、去 userinfo)
// 会制造「两个不同站点记录 → 同一个 host 串」的等价类,
// 典型如 oa.x:80(HTTP 站) 与 oa.x:443(HTTPS 站),浏览器两边都发 Host: oa.x。
// 少了这一层,在 A 站点拿到的令牌能直接在 B 站点用。
// host —— 同一个站点记录可以服务多个域名(泛域名 *.oa.x、*:port catch-all),
// 此时 hostCode 相同,必须靠 host 串把各域名隔开。
//
// 【调用约定】host 必须是 accessgate.NormalizeHost 的输出,签发侧(IssueToken)
// 存进库的也必须是同一函数的输出;hostCode 取当前请求路由到的 hostTarget.Host.Code。
func (s *AccessState) matchBindings(host, hostCode, clientIP, fingerprint string, cfg *accessgate.Config) bool {
// 站点记录必须是同一条。存量令牌的 host_code 从功能上线起就一直在写,不存在空值兼容问题;
// 真为空则说明数据被手工改过,按不通过处理(fail-closed)。
if s.HostCode == "" || hostCode == "" || s.HostCode != hostCode {
return false
}
// 令牌只在签发它的那个域名上有效。缺了这一条,泛域名站点下
// 从 a.oa.x 拿到的 Cookie 就能在 b.oa.x 上用。
if !strings.EqualFold(s.Host, host) {
return false
}
Expand Down Expand Up @@ -130,6 +149,8 @@ func (receiver *WafAccessSessionService) CreateSession(acct model.AccessAccount,
//
// 过期时间取 min(会话过期, now+TokenTTL):子令牌绝不能活得比它所属的中心会话久,
// 否则「注销中心会话」就无法真正让所有站点下线。
//
// 【调用约定】host 必须是 accessgate.NormalizeHost 的输出,与 matchBindings 同源。
func (receiver *WafAccessSessionService) IssueToken(sess model.AccessSession, host, hostCode,
clientIP, fingerprint string, cfg *accessgate.Config) (string, error) {

Expand Down Expand Up @@ -178,7 +199,7 @@ func (receiver *WafAccessSessionService) IssueToken(sess model.AccessSession, ho
// 正向缓存 TTL 有 60 秒硬上限,这同时就是「管理端踢下线」的最坏生效延迟——
// 内存缓存 + 优雅升级期间双 Worker 并存时,精确驱逐只能清掉本进程的缓存,
// 另一个进程要等 TTL 到期才会回库发现会话已撤销。不要为了性能把这个上限调大。
func (receiver *WafAccessSessionService) ValidateToken(plain, host, clientIP, fingerprint string,
func (receiver *WafAccessSessionService) ValidateToken(plain, host, hostCode, clientIP, fingerprint string,
cfg *accessgate.Config) *AccessState {

if plain == "" {
Expand All @@ -195,7 +216,7 @@ func (receiver *WafAccessSessionService) ValidateToken(plain, host, clientIP, fi
// 缓存命中也必须重新比对绑定条件。缓存键只有 token_code,
// 若在这里直接返回,攻击者只要先在自己有权的域名上刷一次缓存,
// 60 秒内就能拿同一个 Cookie 访问任意其它站点。
if !cached.matchBindings(host, clientIP, fingerprint, cfg) {
if !cached.matchBindings(host, hostCode, clientIP, fingerprint, cfg) {
return nil
}
return &cached
Expand Down Expand Up @@ -227,14 +248,15 @@ func (receiver *WafAccessSessionService) ValidateToken(plain, host, clientIP, fi
AccountName: sess.AccountName,
ExpireUnix: time.Time(token.ExpireTime).Unix(),
Host: token.Host,
HostCode: token.HostCode,
ClientIP: token.ClientIP,
Fingerprint: token.Fingerprint,
}
// 先写缓存再判绑定:令牌本身是有效的,缓存的是"这个令牌的签发条件",
// 与本次请求是否满足这些条件无关。反过来做会导致合法用户的令牌
// 因为一次跨域探测就无法进入缓存,每请求都打库。
global.GCACHE_WAFCACHE.SetWithTTl(enums.CACHE_ACCESS_TOKEN+code, *st, cfg.CachePositiveTTL)
if !st.matchBindings(host, clientIP, fingerprint, cfg) {
if !st.matchBindings(host, hostCode, clientIP, fingerprint, cfg) {
return nil
}
receiver.touchToken(token, now)
Expand Down
45 changes: 29 additions & 16 deletions service/waf_service/waf_access_session_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ func TestAccessStateMatchBindings(t *testing.T) {
SessionCode: "sess",
AccountName: "u",
Host: "a.example.com",
HostCode: "site-a",
ClientIP: "1.2.3.4",
Fingerprint: "fp-a",
}
Expand All @@ -30,36 +31,48 @@ func TestAccessStateMatchBindings(t *testing.T) {
bindAll := &accessgate.Config{BindIP: true, BindFingerprint: true}

cases := []struct {
name string
host, clientIP, fpr string
cfg *accessgate.Config
want bool
name string
host, code, clientIP, fpr string
cfg *accessgate.Config
want bool
}{
{"完全匹配", "a.example.com", "1.2.3.4", "fp-a", noBind, true},
{"域名大小写不敏感", "A.Example.Com", "1.2.3.4", "fp-a", noBind, true},
{"跨域名必须拒绝(即便未开IP/指纹绑定)", "b.example.com", "1.2.3.4", "fp-a", noBind, false},
{"未开IP绑定时换IP放行", "a.example.com", "9.9.9.9", "fp-a", noBind, true},
{"开了IP绑定后换IP拒绝", "a.example.com", "9.9.9.9", "fp-a", bindAll, false},
{"未开指纹绑定时换设备放行", "a.example.com", "1.2.3.4", "fp-b", noBind, true},
{"开了指纹绑定后换设备拒绝", "a.example.com", "1.2.3.4", "fp-b", bindAll, false},
{"完全匹配", "a.example.com", "site-a", "1.2.3.4", "fp-a", noBind, true},
{"域名大小写不敏感", "A.Example.Com", "site-a", "1.2.3.4", "fp-a", noBind, true},
{"跨域名必须拒绝(即便未开IP/指纹绑定)", "b.example.com", "site-a", "1.2.3.4", "fp-a", noBind, false},
{"未开IP绑定时换IP放行", "a.example.com", "site-a", "9.9.9.9", "fp-a", noBind, true},
{"开了IP绑定后换IP拒绝", "a.example.com", "site-a", "9.9.9.9", "fp-a", bindAll, false},
{"未开指纹绑定时换设备放行", "a.example.com", "site-a", "1.2.3.4", "fp-b", noBind, true},
{"开了指纹绑定后换设备拒绝", "a.example.com", "site-a", "1.2.3.4", "fp-b", bindAll, false},

// 归一化会让「不同站点记录 → 同一个 host 串」,典型是 oa.x:80(HTTP 站)
// 与 oa.x:443(HTTPS 站),浏览器两边都发 Host: oa.x。
// 只比 host 串的话,A 站点的令牌能直接在 B 站点用。
{"host 串相同但站点记录不同,必须拒绝", "a.example.com", "site-b", "1.2.3.4", "fp-a", noBind, false},
{"请求侧拿不到站点码时 fail-closed", "a.example.com", "", "1.2.3.4", "fp-a", noBind, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := st.matchBindings(c.host, c.clientIP, c.fpr, c.cfg); got != c.want {
t.Fatalf("matchBindings(%q,%q,%q) = %v, 期望 %v",
c.host, c.clientIP, c.fpr, got, c.want)
if got := st.matchBindings(c.host, c.code, c.clientIP, c.fpr, c.cfg); got != c.want {
t.Fatalf("matchBindings(%q,%q,%q,%q) = %v, 期望 %v",
c.host, c.code, c.clientIP, c.fpr, got, c.want)
}
})
}

// 令牌侧缺站点码(数据被手工改过)同样 fail-closed
noCode := &AccessState{Host: "a.example.com"}
if noCode.matchBindings("a.example.com", "site-a", "1.2.3.4", "fp", noBind) {
t.Fatal("令牌未记录站点码时不应放行")
}
}

// TestAccessStateMatchBindingsEmptyBinding 令牌签发时若没记录 IP/指纹(老数据、
// 或签发时这两个开关是关的),后来打开开关不应把这些存量会话全部踢掉。
// 空值视为"未绑定",只有非空才比对。
func TestAccessStateMatchBindingsEmptyBinding(t *testing.T) {
st := &AccessState{Host: "a.example.com"}
st := &AccessState{Host: "a.example.com", HostCode: "site-a"}
cfg := &accessgate.Config{BindIP: true, BindFingerprint: true}
if !st.matchBindings("a.example.com", "1.2.3.4", "fp", cfg) {
if !st.matchBindings("a.example.com", "site-a", "1.2.3.4", "fp", cfg) {
t.Fatal("签发时未记录 IP/指纹的存量令牌,不应在开启绑定后立即失效")
}
}
Expand Down
42 changes: 42 additions & 0 deletions wafdb/migrations_core.go
Original file line number Diff line number Diff line change
Expand Up @@ -2219,6 +2219,48 @@ func RunCoreDBMigrations(db *gorm.DB) error {
return tx.Migrator().DropTable(&model.HttpAuthSession{})
},
},
// 迁移: access_config 表增加跨源(CORS)四列。
// access_config 是 202608040001 用 AutoMigrate 一次性建的,存量库不会自动跟着结构体加列,
// 只改结构体的话存量 MySQL 库会直接报 1054 Unknown column。
// 四列全空即「不启用」,存量站点行为完全不变。
{
ID: "202609060001_add_access_config_cors",
Migrate: func(tx *gorm.DB) error {
zlog.Info("迁移 202609060001: 为 access_config 表添加跨源(CORS)字段")
cols := []struct {
col string
field string
}{
{"cors_allow_origins", "CorsAllowOrigins"},
{"cors_allow_methods", "CorsAllowMethods"},
{"cors_allow_headers", "CorsAllowHeaders"},
{"cors_max_age", "CorsMaxAge"},
}
for _, c := range cols {
if tx.Migrator().HasColumn(&model.AccessConfig{}, c.col) {
continue
}
if err := tx.Migrator().AddColumn(&model.AccessConfig{}, c.field); err != nil {
return fmt.Errorf("添加 access_config.%s 失败: %w", c.col, err)
}
}
zlog.Info("access_config 跨源字段添加成功")
return nil
},
Rollback: func(tx *gorm.DB) error {
zlog.Info("回滚 202609060001: 删除 access_config 跨源字段")
for _, col := range []string{"cors_allow_origins", "cors_allow_methods",
"cors_allow_headers", "cors_max_age"} {
if !tx.Migrator().HasColumn(&model.AccessConfig{}, col) {
continue
}
if err := tx.Migrator().DropColumn(&model.AccessConfig{}, col); err != nil {
return err
}
}
return nil
},
},
})

// 执行迁移
Expand Down
Loading
Loading