From baade92bee6e084faefa3cd34ec104394394d8b4 Mon Sep 17 00:00:00 2001 From: samwaf Date: Mon, 7 Sep 2026 16:18:29 +0800 Subject: [PATCH] fix:access cors --- model/access_config.go | 35 ++- model/hosts.go | 16 ++ model/request/waf_access_req.go | 6 + .../waf_service/waf_access_config_service.go | 30 +- .../waf_service/waf_access_session_service.go | 34 ++- .../waf_access_session_service_test.go | 45 +-- wafdb/migrations_core.go | 42 +++ wafenginecore/access_cors_test.go | 144 ++++++++++ wafenginecore/access_gate.go | 87 +++++- wafenginecore/access_login.go | 18 +- wafenginecore/access_ticket.go | 15 +- wafenginecore/accessgate/config.go | 16 +- wafenginecore/accessgate/cors.go | 259 +++++++++++++++++ wafenginecore/accessgate/cors_test.go | 260 ++++++++++++++++++ wafenginecore/accessgate/hostkey.go | 99 +++++++ wafenginecore/accessgate/hostkey_test.go | 101 +++++++ wafupgradenotice/upgrade_notes.yaml | 25 ++ 17 files changed, 1187 insertions(+), 45 deletions(-) create mode 100644 wafenginecore/access_cors_test.go create mode 100644 wafenginecore/accessgate/cors.go create mode 100644 wafenginecore/accessgate/cors_test.go create mode 100644 wafenginecore/accessgate/hostkey.go create mode 100644 wafenginecore/accessgate/hostkey_test.go diff --git a/model/access_config.go b/model/access_config.go index 9a91c0f2..c1279acd 100644 --- a/model/access_config.go +++ b/model/access_config.go @@ -2,6 +2,7 @@ package model import ( "SamWaf/model/baseorm" + "strings" ) // 未认证时的响应方式 @@ -21,7 +22,8 @@ const ( AccessDefaultIdleMin = 0 // 0=不启用空闲超时 AccessDefaultMaxFail = 10 AccessDefaultLockMinutes = 3 - AccessDefaultCachePosTTL = 60 // 正向缓存上限(秒),同时也是踢下线的最坏生效延迟 + AccessDefaultCachePosTTL = 60 // 正向缓存上限(秒),同时也是踢下线的最坏生效延迟 + AccessCorsMaxAgeCap = 7200 // 预检缓存秒数上限,与 accessgate 侧的封顶一致 ) // AccessConfig 是统一访问认证的租户级全局配置,全表只有一行。 @@ -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 @@ -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()) } diff --git a/model/hosts.go b/model/hosts.go index 26e39dab..0ad67fab 100644 --- a/model/hosts.go +++ b/model/hosts.go @@ -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 配置。 @@ -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 } diff --git a/model/request/waf_access_req.go b/model/request/waf_access_req.go index 27b8217d..539cddfb 100644 --- a/model/request/waf_access_req.go +++ b/model/request/waf_access_req.go @@ -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"` diff --git a/service/waf_service/waf_access_config_service.go b/service/waf_service/waf_access_config_service.go index 74f03916..cfdc52ce 100644 --- a/service/waf_service/waf_access_config_service.go +++ b/service/waf_service/waf_access_config_service.go @@ -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) @@ -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, @@ -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) } diff --git a/service/waf_service/waf_access_session_service.go b/service/waf_service/waf_access_session_service.go index 1e36a641..e82d02c6 100644 --- a/service/waf_service/waf_access_session_service.go +++ b/service/waf_service/waf_access_session_service.go @@ -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 } @@ -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) { @@ -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 == "" { @@ -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 @@ -227,6 +248,7 @@ 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, } @@ -234,7 +256,7 @@ func (receiver *WafAccessSessionService) ValidateToken(plain, host, clientIP, fi // 与本次请求是否满足这些条件无关。反过来做会导致合法用户的令牌 // 因为一次跨域探测就无法进入缓存,每请求都打库。 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) diff --git a/service/waf_service/waf_access_session_service_test.go b/service/waf_service/waf_access_session_service_test.go index ba6b193e..ebaab86e 100644 --- a/service/waf_service/waf_access_session_service_test.go +++ b/service/waf_service/waf_access_session_service_test.go @@ -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", } @@ -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/指纹的存量令牌,不应在开启绑定后立即失效") } } diff --git a/wafdb/migrations_core.go b/wafdb/migrations_core.go index 269ff689..2a26712a 100644 --- a/wafdb/migrations_core.go +++ b/wafdb/migrations_core.go @@ -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 + }, + }, }) // 执行迁移 diff --git a/wafenginecore/access_cors_test.go b/wafenginecore/access_cors_test.go new file mode 100644 index 00000000..b8eb7a36 --- /dev/null +++ b/wafenginecore/access_cors_test.go @@ -0,0 +1,144 @@ +package wafenginecore + +import ( + "crypto/tls" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "SamWaf/model" + "SamWaf/wafenginecore/accessgate" +) + +// TestAccessTokenCookieNameFollowsScheme Cookie 名必须跟着 scheme 走。 +// +// 这条和 accessgate.NormalizeHost 的单测是一对:那边验归一化本身, +// 这边验引擎真的把 r.TLS 传进去了。漏传的话所有 HTTPS 站点都会按 +// HTTP 规则归一化,:443 不被去掉,Cookie 名与签发时对不上。 +func TestAccessTokenCookieNameFollowsScheme(t *testing.T) { + cfg := &accessgate.Config{CookiePrefix: "samwaf_ac"} + + plain := httptest.NewRequest("GET", "http://oa.x:443/", nil) + plain.Host = "oa.x:443" + + secure := httptest.NewRequest("GET", "https://oa.x/", nil) + secure.Host = "oa.x" + secure.TLS = &tlsStateStub + + if accessTokenCookieName(cfg, plain) == accessTokenCookieName(cfg, secure) { + t.Fatal("HTTP 的 oa.x:443 与 HTTPS 的 oa.x 派生出了同一个 Cookie 名,令牌会跨站点互通") + } + + // 同主机名不同端口必须分开——这正是「已登录却一直 401」的根因 + a := httptest.NewRequest("GET", "http://oa.x:7013/", nil) + a.Host = "oa.x:7013" + b := httptest.NewRequest("GET", "http://oa.x:7014/", nil) + b.Host = "oa.x:7014" + if accessTokenCookieName(cfg, a) == accessTokenCookieName(cfg, b) { + t.Fatal(":7013 与 :7014 派生出了同一个 Cookie 名,仍会互相覆盖") + } +} + +// TestStripAccessCookiesHandlesDerivedName 分站命名后,剥离逻辑必须照样把它摘掉。 +// +// stripAccessCookies 按 CookiePrefix 前缀匹配,新名字仍以该前缀开头所以无需改动—— +// 本用例就是把这个「无需改动」钉死,免得日后有人改了命名规则却忘了这里: +// 漏剥的后果是会话令牌明文进后端 access log 和我们自己的 web_logs。 +func TestStripAccessCookiesHandlesDerivedName(t *testing.T) { + cfg := &accessgate.Config{CookiePrefix: "samwaf_ac"} + name := accessgate.TokenCookieName(cfg.CookiePrefix, "oa.x:7013") + + r := httptest.NewRequest("GET", "http://oa.x:7013/", nil) + r.Header.Set("Cookie", "biz=1; "+name+"=secret; samwaf_ac_sso=alsosecret; other=2") + + if !stripAccessCookies(r, cfg) { + t.Fatal("应当剥掉了东西") + } + got := r.Header.Get("Cookie") + if got != "biz=1; other=2" { + t.Fatalf("剥离结果不对:%q", got) + } +} + +// TestHostAccessConfigCORSRoundTrip 站点级 CORS 字段必须能从 access_json 里解析出来。 +// +// AccessJSON 是一整串不透明 JSON 从前端透传到引擎的,json tag 写错不会报任何错, +// 只会表现为「前端填了、后端永远读不到」——这一类 bug 光靠编译和人眼看不出来。 +func TestHostAccessConfigCORSRoundTrip(t *testing.T) { + raw := `{"mode":1,"cors_allow_origins":"http://oa.x:7013\nhttps://app.oa.x",` + + `"cors_allow_methods":"GET,POST","cors_allow_headers":"x-token","cors_max_age":300}` + + c := model.ParseAccessConfig(raw) + if c.CorsAllowMethods != "GET,POST" || c.CorsAllowHeaders != "x-token" || c.CorsMaxAge != 300 { + t.Fatalf("站点级 CORS 字段解析不出来:%+v", c) + } + + cfg := &accessgate.Config{} + p := accessCORSPolicy(cfg, c) + if len(p.AllowOrigins) != 2 { + t.Fatalf("Origin 清单应解析出 2 条,got=%v", p.AllowOrigins) + } + if _, ok := accessgate.MatchOrigin("http://oa.x:7013", p.AllowOrigins); !ok { + t.Fatal("白名单里的 Origin 应命中") + } + if _, ok := accessgate.MatchOrigin("http://oa.x:7013.evil.com", p.AllowOrigins); ok { + t.Fatal("后缀拼接的 Origin 绝不能命中") + } + + // 越界的 max_age 归零 = 沿用全局;控制字符必须在解析阶段消掉 + bad := model.ParseAccessConfig(`{"cors_max_age":99999,"cors_allow_methods":"GET\r\nX-Evil: 1"}`) + if bad.CorsMaxAge != 0 { + t.Fatalf("越界 max_age 应归零,got=%d", bad.CorsMaxAge) + } + for _, ch := range []byte{'\r', '\n'} { + for i := 0; i < len(bad.CorsAllowMethods); i++ { + if bad.CorsAllowMethods[i] == ch { + t.Fatalf("methods 里残留控制字符:%q", bad.CorsAllowMethods) + } + } + } + + // 空配置必须落在「未启用」,存量站点行为零变化 + if accessCORSPolicy(&accessgate.Config{}, model.ParseAccessConfig("")).Enabled() { + t.Fatal("两级都没配时必须是关闭状态") + } +} + +// TestHostAccessConfigCORSMarshalable 站点配置要能原样存回去, +// 否则管理端编辑一次就会把 CORS 设置抹掉。 +func TestHostAccessConfigCORSMarshalable(t *testing.T) { + c := model.HostAccessConfig{Mode: 1, CorsAllowOrigins: "http://oa.x:7013", CorsMaxAge: 300} + b, err := json.Marshal(c) + if err != nil { + t.Fatal(err) + } + back := model.ParseAccessConfig(string(b)) + if back.CorsAllowOrigins != c.CorsAllowOrigins || back.CorsMaxAge != c.CorsMaxAge { + t.Fatalf("往返丢字段:%+v", back) + } +} + +// TestAccessUnauthorizedJSONHasNoCORSByDefault 没配白名单时不得回显任何 CORS 头。 +// +// 默认关是硬约束:存量用户升级后,未认证响应必须和现在一模一样。 +func TestAccessUnauthorizedJSONHasNoCORSByDefault(t *testing.T) { + w := httptest.NewRecorder() + writeAccessUnauthorizedJSON(w, "http://sso.x/samwaf_access/login") + + for _, h := range []string{ + "Access-Control-Allow-Origin", + "Access-Control-Allow-Credentials", + "Access-Control-Allow-Methods", + } { + if v := w.Header().Get(h); v != "" { + t.Fatalf("未配白名单时不该出现 %s=%q", h, v) + } + } + if w.Code != http.StatusUnauthorized { + t.Fatalf("状态码=%d", w.Code) + } +} + +// tlsStateStub 只用来把 r.TLS 置为非 nil,内容无关紧要。 +var tlsStateStub tls.ConnectionState diff --git a/wafenginecore/access_gate.go b/wafenginecore/access_gate.go index 35c675a8..48270d39 100644 --- a/wafenginecore/access_gate.go +++ b/wafenginecore/access_gate.go @@ -106,8 +106,12 @@ func (waf *WafEngine) DoAccessGate(w http.ResponseWriter, r *http.Request, return accessHandled } + // 站点身份,Cookie 名派生与令牌绑定共用同一份,两者必须同源, + // 否则会出现「Cookie 找得到、绑定判不过」的死循环。 + nhost := accessNormalizedHost(r) + // 先把令牌取出来,再剥 Cookie:剥离之后请求头里就读不到它了。 - tokenCookie := accessCookieValue(r, cfg.CookieTokenName) + tokenCookie := accessCookieValue(r, accessgate.TokenCookieName(cfg.CookiePrefix, nhost)) // 从这里往下,任何放行都不能把本模块的 Cookie 带给后端: // 会话令牌是 WAF 与浏览器之间的凭据,后端记一行 access log 就等于把它写进了明文日志。 @@ -169,10 +173,43 @@ func (waf *WafEngine) DoAccessGate(w http.ResponseWriter, r *http.Request, return accessPass } + // ⑦' CORS 预检代答。 + // + // 必须给预检开口子:它按规范不携带任何凭据(浏览器行为,withCredentials 也改不了), + // 走到 ⑧ 验票必然失败,于是所有需要预检的跨源请求——带自定义头的、 + // Content-Type 为 application/json 的、PUT/DELETE 的——全军覆没。 + // 业界统一做法(oauth2-proxy --skip-auth-preflight / Pomerium cors_allow_preflight / + // IAP allow_http_options / Cloudflare Access)都是给它单开一条路。 + // + // 我们比它们多收一层:不把 OPTIONS 透传给后端,而是由 WAF 代答 204。 + // 透传的风险在于非标准后端可能把 OPTIONS 和业务方法走同一个 handler, + // 那等于让未认证的人能触发业务逻辑;代答则请求根本到不了后端。 + // Cloudflare 自己也给透传加了限定:只有源站已做 CORS 强制时才该用。 + // + // 位置刻意排在 ⑤⑥⑦ 三条旁路**之后**: + // 那三条本来就会把请求整个放行给后端,预检自然也该由后端自己回答。 + // 抢在它们前面代答,等于 WAF 替一个从未参与过这件事的后端批准了 + // 「带凭据的跨源写」——而免认证路径恰恰是健康检查、webhook 这类 + // 最不该被浏览器脚本碰的入口。排在后面就完全保持了改动前的行为。 + // + // 另外两条硬约束: + // - 只在配了 Origin 白名单且精确命中时才代答,没配就落回原流程(401) —— 默认关 + // - 不查库、不校验会话、不写审计:这是认证之前任何人可达的端点, + // 任何一样都会把它变成免认证的放大器 + if accessgate.IsPreflight(r) { + policy := accessCORSPolicy(cfg, hostCfg) + if allowed, ok := accessgate.MatchOrigin(accessgate.RequestOrigin(r.Header), policy.AllowOrigins); ok { + accessgate.WritePreflightHeaders(w, r, allowed, policy) + w.WriteHeader(http.StatusNoContent) + return accessHandled + } + } + // ⑧ 验票(用剥离前取出的令牌值) if tokenCookie != "" { fingerprint := utils.GenerateFingerprint(r) - if st := accessSessionService.ValidateToken(tokenCookie, r.Host, clientIP, fingerprint, cfg); st != nil { + if st := accessSessionService.ValidateToken(tokenCookie, nhost, hostTarget.Host.Code, + clientIP, fingerprint, cfg); st != nil { if cfg.PassIdentityHeader { // 顺序要紧:⓪ 已经删过客户端可能伪造的同名头,这里才是可信写入。 // 反过来先 Set 再 Del 等于把身份头的控制权交给客户端。 @@ -188,9 +225,29 @@ func (waf *WafEngine) DoAccessGate(w http.ResponseWriter, r *http.Request, // ⑩ 浏览器导航 302,API/WebSocket 401 JSON entry := waf.buildAccessEntryURL(r, cfg) + + // 跨源请求先补 CORS 头。不补的话浏览器只会报一句 + // 「No 'Access-Control-Allow-Origin' header」,前端既读不到 401 + // 也拿不到 login_url,用户完全无从定位到是认证网关拦的。 + // + // 只对白名单精确命中的 Origin 回显,且回显的是白名单里那一条而不是请求头里那一条: + // 比对与输出只要来自两个来源,任何归一化差异都会长成绕过。 + if origin := accessgate.RequestOrigin(r.Header); origin != "" { + policy := accessCORSPolicy(cfg, hostCfg) + if allowed, ok := accessgate.MatchOrigin(origin, policy.AllowOrigins); ok { + accessgate.WriteCORSHeaders(w, allowed) + } else { + // 不命中也要声明随 Origin 变化,命中与不命中的缓存行为才对称 + accessgate.AddVaryOrigin(w) + } + } + if waf.accessShouldReturnJSON(r, cfg, hostCfg) { writeAccessUnauthorizedJSON(w, entry) } else { + // 跳登录页的 302 绝不能被缓存:中间缓存留下它,用户登录之后再访问同一地址 + // 还会被弹回登录页。401 那条路径已经有 no-store,这里补齐。 + w.Header().Set("Cache-Control", "no-store") http.Redirect(w, r, entry, http.StatusFound) } return accessHandled @@ -199,6 +256,32 @@ func (waf *WafEngine) DoAccessGate(w http.ResponseWriter, r *http.Request, // isACMEChallengePath 已移到 acme_challenge.go:请求侧快速通道与本网关共用同一份判定, // 免得两处各写一份、日后只改了其中一处。 +// accessNormalizedHost 本请求所属站点的归一化标识。 +// +// 令牌 Cookie 名的派生与令牌绑定的比对都必须用它,且必须是同一份结果: +// 两边各归一化一次,一旦出现差异就会变成「Cookie 找得到、绑定判不过」的死循环。 +// +// isTLS 传 r.TLS != nil:默认端口的去除必须按 scheme 判定, +// 否则 HTTP 的 oa.x:443 与 HTTPS 的 oa.x 会归一化成同一个值,令牌跨站点互通。 +func accessNormalizedHost(r *http.Request) string { + return accessgate.NormalizeHost(r.Host, r.TLS != nil) +} + +// accessTokenCookieName 本站点的令牌 Cookie 名。 +func accessTokenCookieName(cfg *accessgate.Config, r *http.Request) string { + return accessgate.TokenCookieName(cfg.CookiePrefix, accessNormalizedHost(r)) +} + +// accessCORSPolicy 站点级按字段覆盖全局的跨源策略。 +// +// 每次调用都会重新解析站点级 Origin 清单。这条路径只在「预检」或 +// 「未认证且带 Origin」时才走到,且与既有的 BuildExcludePaths(hostCfg.ExcludePaths) +// 是同一种做法,成本相当。 +func accessCORSPolicy(cfg *accessgate.Config, hostCfg model.HostAccessConfig) accessgate.CORSPolicy { + return accessgate.ResolveCORSPolicy(hostCfg.CorsAllowOrigins, hostCfg.CorsAllowMethods, + hostCfg.CorsAllowHeaders, hostCfg.CorsMaxAge, cfg.CORS) +} + // accessCookieValue 读一个 Cookie 的值,不存在返回空串。 // 必须在 stripAccessCookies 之前调用 —— 剥离之后请求头里就没有它了。 func accessCookieValue(r *http.Request, name string) string { diff --git a/wafenginecore/access_login.go b/wafenginecore/access_login.go index 9b099bae..2da428f7 100644 --- a/wafenginecore/access_login.go +++ b/wafenginecore/access_login.go @@ -87,7 +87,7 @@ func (waf *WafEngine) handleAccessRequest(w http.ResponseWriter, r *http.Request case sub == "logout": waf.accessHandleLogout(w, r, hostTarget, cfg, clientIP) case sub == "status" && r.Method == http.MethodGet: - waf.accessHandleStatus(w, r, cfg, clientIP) + waf.accessHandleStatus(w, r, hostTarget, cfg, clientIP) case sub == "ping": // 恒 204,给用户做健康检查白名单时有个现成的探测点 w.WriteHeader(http.StatusNoContent) @@ -410,10 +410,13 @@ func (waf *WafEngine) accessHandleLogout(w http.ResponseWriter, r *http.Request, } clearAccessCookie(w, cfg.CookieSSOName, secure) } - if ck, err := r.Cookie(cfg.CookieTokenName); err == nil && ck.Value != "" { + tokenName := accessTokenCookieName(cfg, r) + if ck, err := r.Cookie(tokenName); err == nil && ck.Value != "" { accessSessionService.RevokeToken(ck.Value) - clearAccessCookie(w, cfg.CookieTokenName, secure) + clearAccessCookie(w, tokenName, secure) } + // 改造前所有站点共用一个固定名字,升级后它不再被读取,一并清掉免得残留占位 + clearAccessCookie(w, accessgate.LegacyTokenCookieName(cfg.CookiePrefix), secure) accessAuditService.Write(waf_service.AuditEntry{ Event: model.AccessEventLogout, AccountName: accountName, Host: r.Host, @@ -434,11 +437,12 @@ func (waf *WafEngine) accessHandleLogout(w http.ResponseWriter, r *http.Request, // accessHandleStatus 供 SPA 探测登录态。 // 未登录时不返回任何账号信息——这个端点是免认证可达的,不能变成账号枚举入口。 func (waf *WafEngine) accessHandleStatus(w http.ResponseWriter, r *http.Request, - cfg *accessgate.Config, clientIP string) { + hostTarget *wafenginmodel.HostSafe, cfg *accessgate.Config, clientIP string) { fingerprint := utils.GenerateFingerprint(r) - if ck, err := r.Cookie(cfg.CookieTokenName); err == nil && ck.Value != "" { - if st := accessSessionService.ValidateToken(ck.Value, r.Host, clientIP, fingerprint, cfg); st != nil { + if ck, err := r.Cookie(accessTokenCookieName(cfg, r)); err == nil && ck.Value != "" { + if st := accessSessionService.ValidateToken(ck.Value, accessNormalizedHost(r), hostTarget.Host.Code, + clientIP, fingerprint, cfg); st != nil { writeAccessJSON(w, http.StatusOK, map[string]interface{}{ "authenticated": true, "account": st.AccountName, @@ -458,7 +462,7 @@ func (waf *WafEngine) accessCurrentSSOSession(r *http.Request, cfg *accessgate.C if err != nil || ck.Value == "" { return nil } - return accessSessionService.ValidateSSOSession(ck.Value, r.Host, clientIP, fingerprint, cfg) + return accessSessionService.ValidateSSOSession(ck.Value, accessNormalizedHost(r), clientIP, fingerprint, cfg) } // ─────────────────────────── 失败锁定 ─────────────────────────── diff --git a/wafenginecore/access_ticket.go b/wafenginecore/access_ticket.go index f2bd165f..e81eb2ee 100644 --- a/wafenginecore/access_ticket.go +++ b/wafenginecore/access_ticket.go @@ -332,13 +332,22 @@ func (waf *WafEngine) accessIssueTokenCookie(w http.ResponseWriter, r *http.Requ sess model.AccessSession, hostTarget *wafenginmodel.HostSafe, cfg *accessgate.Config, clientIP, fingerprint string) error { - plain, err := accessSessionService.IssueToken(sess, r.Host, hostTarget.Host.Code, + nhost := accessNormalizedHost(r) + plain, err := accessSessionService.IssueToken(sess, nhost, hostTarget.Host.Code, clientIP, fingerprint, cfg) if err != nil { return err } - http.SetCookie(w, buildAccessCookie(cfg.CookieTokenName, plain, - int(cfg.TokenTTL.Seconds()), accessCookieSecure(r, hostTarget, cfg))) + secure := accessCookieSecure(r, hostTarget, cfg) + http.SetCookie(w, buildAccessCookie(accessgate.TokenCookieName(cfg.CookiePrefix, nhost), plain, + int(cfg.TokenTTL.Seconds()), secure)) + + // 顺手清掉改造前那个所有站点共用的固定名字。 + // + // 只在这里(签发时)发删除,绝不在未认证请求上发:否则任意人构造一个请求 + // 就能让受害者浏览器丢弃 Cookie,等于免认证的强制登出。 + // 这条路径只在登录回调走到,不是热路径,多一个响应头无所谓。 + clearAccessCookie(w, accessgate.LegacyTokenCookieName(cfg.CookiePrefix), secure) return nil } diff --git a/wafenginecore/accessgate/config.go b/wafenginecore/accessgate/config.go index c481000c..cd88a3b3 100644 --- a/wafenginecore/accessgate/config.go +++ b/wafenginecore/accessgate/config.go @@ -45,10 +45,13 @@ type Config struct { CenterOrigin string CenterHost string - PathPrefix string // 已归一化:小写、以 / 开头、无尾部 / - CookieSSOName string // 中心会话 Cookie 名,= CookiePrefix + "_sso" - CookieTokenName string // 业务域子令牌 Cookie 名,= CookiePrefix + "_tk" - CookiePrefix string // 剥离 Cookie 时按此前缀匹配 + PathPrefix string // 已归一化:小写、以 / 开头、无尾部 / + CookieSSOName string // 中心会话 Cookie 名,= CookiePrefix + "_sso" + // CookiePrefix 有两个用途:剥离 Cookie 时按它前缀匹配, + // 以及派生每个站点各自的子令牌 Cookie 名(见 hostkey.go 的 TokenCookieName)。 + // 这里刻意不再存一个固定的子令牌名——所有站点共用一个名字正是 + // 「同域名不同端口互相覆盖登录态」的成因。 + CookiePrefix string HmacSecret []byte // rq 签名密钥(已解密) @@ -68,6 +71,10 @@ type Config struct { ServiceTokenHeader string ServiceTokenHashes []string // sha256hex,已小写 + // CORS 是全局跨源策略,站点级用 ResolveCORSPolicy 按字段覆盖它。 + // AllowOrigins 为空即功能未启用 —— 默认关,存量用户升级后行为完全不变。 + CORS CORSPolicy + UnauthAction string PassIdentityHeader bool ForceSecureCookie bool @@ -84,7 +91,6 @@ var disabledDefault = &Config{ GlobalEnable: false, PathPrefix: "/samwaf_access", CookieSSOName: "samwaf_ac_sso", - CookieTokenName: "samwaf_ac_tk", CookiePrefix: "samwaf_ac", UnauthAction: "auto", MaxFailCount: 10, diff --git a/wafenginecore/accessgate/cors.go b/wafenginecore/accessgate/cors.go new file mode 100644 index 00000000..6fd55869 --- /dev/null +++ b/wafenginecore/accessgate/cors.go @@ -0,0 +1,259 @@ +package accessgate + +import ( + "net/http" + "strconv" + "strings" +) + +// cors.go 是「统一访问认证」的跨源策略判定与响应头构造。 +// +// 只在两个地方被用到,都在**认证通过之前**: +// - CORS 预检代答(WAF 自己回 204,请求不落到后端) +// - 未认证响应(401/302)补头,让跨源前端能读到干净的 401 而不是一句 CORS 错 +// +// 认证通过后放行的响应一律不补头:后端自己会回,WAF 再补一份会出现两个 +// Access-Control-Allow-Origin,浏览器直接判失败。 + +const ( + corsDefaultMethods = "GET,POST,PUT,PATCH,DELETE,OPTIONS" + corsDefaultMaxAge = 600 + corsMaxAgeCap = 7200 + + // 白名单是热路径上的 O(n) 遍历,条数与长度都要封顶,避免被配置成 CPU 放大器 + corsMaxOrigins = 64 + corsMaxOriginLen = 512 + // 回显给客户端的头值上限 + corsMaxHeaderLen = 1024 +) + +// CORSPolicy 是「这个站点此刻的跨源策略」,由站点级按字段覆盖全局后得到。 +// AllowOrigins 为空即视为功能未启用——默认关,存量用户零影响。 +type CORSPolicy struct { + AllowOrigins []string // 已小写、已校验、已去重 + AllowMethods string + AllowHeaders string // 空 = 回显请求的 Access-Control-Request-Headers + MaxAge int +} + +// Enabled 未配置任何允许的 Origin 就是没开这个功能。 +func (p CORSPolicy) Enabled() bool { return len(p.AllowOrigins) > 0 } + +// BuildAllowOrigins 把用户填的多行文本解析成允许的 Origin 清单。 +// +// 非法条目直接丢弃而不报错:这份配置在热路径上使用,宁可少放行一条, +// 也不能因为一行写歪就让整站跨源全挂或全开。 +func BuildAllowOrigins(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + replaced := strings.NewReplacer("\r\n", "\n", "\r", "\n", ",", "\n").Replace(raw) + var out []string + seen := make(map[string]struct{}) + for _, line := range strings.Split(replaced, "\n") { + o := normalizeOrigin(line) + if o == "" { + continue + } + if _, dup := seen[o]; dup { + continue + } + seen[o] = struct{}{} + out = append(out, o) + if len(out) >= corsMaxOrigins { + break + } + } + return out +} + +// normalizeOrigin 校验并归一化单个 Origin,非法返回空串。 +// +// 显式拒绝两类值: +// - "null":来自 sandbox iframe / data: / file://,允许它等于对任意攻击页面开放 +// - "*":规范禁止「带凭据 + ACAO 通配」,不该由我们产出这种组合 +// +// 端口一律保留。抹掉端口就等于让 :7013 的白名单顺手放行 :9999 —— +// 而端口正是这次事故的核心边界。 +func normalizeOrigin(item string) string { + o := strings.ToLower(strings.TrimSpace(item)) + if o == "" || o == "*" || o == "null" { + return "" + } + if len(o) > corsMaxOriginLen { + return "" + } + if strings.ContainsAny(o, "\r\n\t ") { + return "" + } + var rest string + switch { + case strings.HasPrefix(o, "http://"): + rest = o[len("http://"):] + case strings.HasPrefix(o, "https://"): + rest = o[len("https://"):] + default: + return "" + } + // Origin 按定义只有 scheme://host[:port],出现路径/查询/片段说明用户填错了。 + // 丢弃比「猜他想要什么」安全:猜错的方向通常是放宽。 + if rest == "" || strings.ContainsAny(rest, "/?#\\") { + return "" + } + return o +} + +// RequestOrigin 取请求的 Origin;出现零个或多个 Origin 头都判为无效。 +// +// 多头必须拒绝而不是取第一个:前置代理与本网关若各取一个, +// 判定所依据的值就和实际生效的值不是同一个,那本身就是绕过。 +func RequestOrigin(h http.Header) string { + v := h["Origin"] + if len(v) != 1 { + return "" + } + return strings.TrimSpace(v[0]) +} + +// MatchOrigin 判断 Origin 是否在白名单里,命中返回**白名单里的那一条**。 +// +// 两个刻意的设计: +// - 整串精确比对。任何前缀/后缀/包含式匹配都能被 +// http://oa.x:7013.evil.com 或 http://evil.com/?http://oa.x:7013 命中。 +// - 返回白名单侧的值,而不是把请求头原样回显。比对与输出只要来自两个来源, +// 任何归一化差异都会长成绕过;这里从结构上杜绝。 +func MatchOrigin(origin string, allow []string) (string, bool) { + if origin == "" || len(allow) == 0 { + return "", false + } + o := strings.ToLower(strings.TrimSpace(origin)) + if o == "" || o == "null" || o == "*" { + return "", false + } + for _, item := range allow { + if item == o { + return item, true + } + } + return "", false +} + +// IsPreflight 判断是不是 CORS 预检。三个条件缺一不可 —— +// 少判一个就会把普通的 OPTIONS 请求也当成预检代答掉,而后者可能是业务接口。 +func IsPreflight(r *http.Request) bool { + return r.Method == http.MethodOptions && + RequestOrigin(r.Header) != "" && + strings.TrimSpace(r.Header.Get("Access-Control-Request-Method")) != "" +} + +// WriteCORSHeaders 给未认证响应补 CORS 头。 +// +// allowedOrigin 必须是 MatchOrigin 的返回值(白名单侧的那一条)。 +// +// Access-Control-Allow-Credentials 固定 true 且不给开关:带凭据是这个功能存在的 +// 唯一理由,而规范禁止带凭据时 ACAO 为 *,所以必须回显具体 Origin, +// 也就必须有白名单 —— 两件事绑死,给开关只会造出无意义的组合。 +// +// Vary 用 Add 不用 Set:这条响应可能已经带了别的 Vary,覆盖掉会让缓存串味。 +func WriteCORSHeaders(w http.ResponseWriter, allowedOrigin string) { + h := w.Header() + h.Set("Access-Control-Allow-Origin", allowedOrigin) + h.Set("Access-Control-Allow-Credentials", "true") + h.Set("Access-Control-Expose-Headers", "X-SamWaf-Access") + h.Add("Vary", "Origin") +} + +// WritePreflightHeaders 构造预检代答的响应头(不含状态码,由调用方写 204)。 +func WritePreflightHeaders(w http.ResponseWriter, r *http.Request, allowedOrigin string, p CORSPolicy) { + WriteCORSHeaders(w, allowedOrigin) + h := w.Header() + + methods := p.AllowMethods + if methods == "" { + methods = corsDefaultMethods + } + h.Set("Access-Control-Allow-Methods", methods) + + allowHeaders := p.AllowHeaders + if allowHeaders == "" { + // 用户没指定就回显请求想用的头。该值完全由客户端控制, + // 必须过滤控制字符并封顶长度:Go 的 ResponseWriter 虽会把 CR/LF 换成空格、 + // 不至于头分割,但不该把边界押在运行时兜底上,超长值本身也是放大器。 + allowHeaders = SanitizeHeaderValue(r.Header.Get("Access-Control-Request-Headers")) + } + if allowHeaders != "" { + h.Set("Access-Control-Allow-Headers", allowHeaders) + } + + maxAge := p.MaxAge + if maxAge <= 0 { + maxAge = corsDefaultMaxAge + } + if maxAge > corsMaxAgeCap { + maxAge = corsMaxAgeCap + } + h.Set("Access-Control-Max-Age", strconv.Itoa(maxAge)) + // 回显了请求头清单,缓存就必须按它分桶 + h.Add("Vary", "Access-Control-Request-Headers") +} + +// AddVaryOrigin 只加 Vary 不加别的。 +// +// 未命中白名单的响应同样要声明「本响应随 Origin 变化」,否则共享缓存 +// 可能把带 CORS 头的那份和不带的那份混着发。命中与不命中都加,行为才对称。 +func AddVaryOrigin(w http.ResponseWriter) { + w.Header().Add("Vary", "Origin") +} + +// SanitizeHeaderValue 丢掉控制字符并按上限截断。 +// 两处用到:回显客户端可控的 Access-Control-Request-Headers, +// 以及管理端填进来的 methods/headers 配置值。 +func SanitizeHeaderValue(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + if len(v) > corsMaxHeaderLen { + v = v[:corsMaxHeaderLen] + } + var b strings.Builder + b.Grow(len(v)) + for i := 0; i < len(v); i++ { + c := v[i] + if c < 0x20 || c == 0x7f { + continue + } + b.WriteByte(c) + } + return strings.TrimSpace(b.String()) +} + +// ResolveCORSPolicy 站点级按字段覆盖全局。 +// +// 按字段而不是整体覆盖,是为了让用户只改 Origin 清单就能生效, +// 继续沿用全局配好的方法/头/缓存时长。 +func ResolveCORSPolicy(hostOrigins, hostMethods, hostHeaders string, hostMaxAge int, g CORSPolicy) CORSPolicy { + p := g + switch strings.TrimSpace(hostOrigins) { + case "": + // 沿用全局 + case "-": + // 显式关闭。没有这个哨兵的话,为某一个站点配的 Origin 会在所有继承全局的 + // 站点上一并生效,而站点侧没有任何办法把它关掉。 + p.AllowOrigins = nil + default: + // 站点填了就以站点为准,哪怕全部条目非法解析成空也不回落全局: + // 用户的意图是「这个站点单独管」,回落全局等于悄悄放宽。 + p.AllowOrigins = BuildAllowOrigins(hostOrigins) + } + if m := SanitizeHeaderValue(hostMethods); m != "" { + p.AllowMethods = m + } + if hd := SanitizeHeaderValue(hostHeaders); hd != "" { + p.AllowHeaders = hd + } + if hostMaxAge > 0 { + p.MaxAge = hostMaxAge + } + return p +} diff --git a/wafenginecore/accessgate/cors_test.go b/wafenginecore/accessgate/cors_test.go new file mode 100644 index 00000000..ad29e825 --- /dev/null +++ b/wafenginecore/accessgate/cors_test.go @@ -0,0 +1,260 @@ +package accessgate + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestNormalizeOriginRejects 非法 Origin 必须在解析阶段就被丢掉。 +// +// 其中 null 与 * 是安全项而非健壮性项: +// null 来自 sandbox iframe / data: / file://,放进白名单等于对任意攻击页面开放; +// * 与 Allow-Credentials:true 是规范禁止的组合。 +func TestNormalizeOriginRejects(t *testing.T) { + bad := []string{ + "", " ", "*", "null", "NULL", + "oa.x:7013", + "ftp://oa.x", + "http://oa.x/", + "http://oa.x/api", + "http://oa.x?a=1", + "http://oa.x#f", + "http://oa.x\\evil.com", + "http://oa.x\r\nX-Evil: 1", + "http://" + strings.Repeat("a", 600), + } + for _, item := range bad { + if got := normalizeOrigin(item); got != "" { + t.Errorf("normalizeOrigin(%q) = %q,期望被丢弃", item, got) + } + } + good := map[string]string{ + "http://oa.x:7013": "http://oa.x:7013", + " HTTPS://OA.X ": "https://oa.x", + "https://a.b.oa.x": "https://a.b.oa.x", + "http://[::1]:8080": "http://[::1]:8080", + } + for in, want := range good { + if got := normalizeOrigin(in); got != want { + t.Errorf("normalizeOrigin(%q) = %q,期望 %q", in, got, want) + } + } +} + +// TestMatchOriginIsExact 是本次改造最重要的一条回归。 +// +// 任何前缀/后缀/包含式匹配都能被下面这些构造命中, +// 命中即意味着任意站点拿到了对受保护接口的带凭据跨源读权。 +func TestMatchOriginIsExact(t *testing.T) { + allow := BuildAllowOrigins("http://oa.x:7013\nhttps://app.oa.x") + + attack := []string{ + "http://oa.x:7013.evil.com", + "http://evil.com/?http://oa.x:7013", + "http://oa.x:7013evil.com", + "http://evil.http://oa.x:7013", + "http://oa.x", + "http://oa.x:9999", + "https://oa.x:7013", + "http://xoa.x:7013", + "null", + "*", + "", + } + for _, o := range attack { + if got, ok := MatchOrigin(o, allow); ok { + t.Errorf("MatchOrigin(%q) 命中了 %q,白名单是精确比对不该命中", o, got) + } + } + + // 命中时必须返回白名单侧的值,而不是把请求头原样回显 + got, ok := MatchOrigin("HTTP://OA.X:7013", allow) + if !ok || got != "http://oa.x:7013" { + t.Fatalf("命中后应返回白名单侧的值,got=%q ok=%v", got, ok) + } +} + +// TestBuildAllowOriginsLimits 条数上限与去重:白名单是热路径 O(n) 遍历, +// 不封顶就能被配置成 CPU 放大器。 +func TestBuildAllowOriginsLimits(t *testing.T) { + var b strings.Builder + for i := 0; i < corsMaxOrigins*3; i++ { + b.WriteString("http://h") + b.WriteString(strings.Repeat("a", i%5+1)) + b.WriteString(".x:1\n") + } + if n := len(BuildAllowOrigins(b.String())); n > corsMaxOrigins { + t.Fatalf("白名单条数 %d 超过上限 %d", n, corsMaxOrigins) + } + if got := BuildAllowOrigins("http://a.x\nhttp://a.x\nHTTP://A.X"); len(got) != 1 { + t.Fatalf("重复条目应去重,got=%v", got) + } + if got := BuildAllowOrigins(" \n\n , ,"); got != nil { + t.Fatalf("全空输入应返回 nil,got=%v", got) + } +} + +// TestRequestOriginMultiHeader 多个 Origin 头必须判无效,不能取第一个。 +func TestRequestOriginMultiHeader(t *testing.T) { + h := http.Header{} + if RequestOrigin(h) != "" { + t.Fatal("无 Origin 头应返回空") + } + h.Add("Origin", "http://oa.x:7013") + if RequestOrigin(h) != "http://oa.x:7013" { + t.Fatal("单个 Origin 头应正常返回") + } + h.Add("Origin", "http://evil.com") + if got := RequestOrigin(h); got != "" { + t.Fatalf("多个 Origin 头必须判无效,got=%q", got) + } +} + +// TestIsPreflight 三个条件缺一不可,否则普通 OPTIONS 业务请求会被误代答。 +func TestIsPreflight(t *testing.T) { + mk := func(method string, headers map[string]string) *http.Request { + r := httptest.NewRequest(method, "http://oa.x:7014/api", nil) + for k, v := range headers { + r.Header.Set(k, v) + } + return r + } + cases := []struct { + name string + req *http.Request + want bool + }{ + {"完整预检", mk("OPTIONS", map[string]string{ + "Origin": "http://oa.x:7013", "Access-Control-Request-Method": "POST"}), true}, + {"缺 ACRM 的裸 OPTIONS", mk("OPTIONS", map[string]string{ + "Origin": "http://oa.x:7013"}), false}, + {"缺 Origin", mk("OPTIONS", map[string]string{ + "Access-Control-Request-Method": "POST"}), false}, + {"GET 不是预检", mk("GET", map[string]string{ + "Origin": "http://oa.x:7013", "Access-Control-Request-Method": "POST"}), false}, + } + for _, c := range cases { + if got := IsPreflight(c.req); got != c.want { + t.Errorf("%s: IsPreflight = %v,期望 %v", c.name, got, c.want) + } + } +} + +// TestSanitizeHeaderValue 回显的是客户端完全可控的值,必须去控制字符并截断。 +func TestSanitizeHeaderValue(t *testing.T) { + if got := SanitizeHeaderValue("content-type\r\nX-Evil: 1"); strings.ContainsAny(got, "\r\n") { + t.Fatalf("CR/LF 未被清除:%q", got) + } + if got := SanitizeHeaderValue(strings.Repeat("a", corsMaxHeaderLen*2)); len(got) > corsMaxHeaderLen { + t.Fatalf("超长值未截断,len=%d", len(got)) + } + if got := SanitizeHeaderValue(" x-token , content-type "); got != "x-token , content-type" { + t.Fatalf("正常值不该被改动,got=%q", got) + } +} + +// TestWriteCORSHeaders 未认证响应补头:ACAO 必须是具体值,且带 Credentials 与 Vary。 +func TestWriteCORSHeaders(t *testing.T) { + w := httptest.NewRecorder() + w.Header().Add("Vary", "Accept-Encoding") // 已有的 Vary 不能被覆盖 + WriteCORSHeaders(w, "http://oa.x:7013") + + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://oa.x:7013" { + t.Fatalf("ACAO=%q", got) + } + if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "true" { + t.Fatalf("ACAC=%q", got) + } + vary := w.Header().Values("Vary") + if len(vary) != 2 || vary[0] != "Accept-Encoding" || vary[1] != "Origin" { + t.Fatalf("Vary 应追加而不是覆盖,got=%v", vary) + } +} + +// TestWritePreflightHeaders 未指定 Allow-Headers 时回显请求的 ACRH,并封顶 Max-Age。 +func TestWritePreflightHeaders(t *testing.T) { + r := httptest.NewRequest("OPTIONS", "http://oa.x:7014/api", nil) + r.Header.Set("Origin", "http://oa.x:7013") + r.Header.Set("Access-Control-Request-Method", "POST") + r.Header.Set("Access-Control-Request-Headers", "content-type, x-token") + + w := httptest.NewRecorder() + WritePreflightHeaders(w, r, "http://oa.x:7013", CORSPolicy{MaxAge: 999999}) + + if got := w.Header().Get("Access-Control-Allow-Headers"); got != "content-type, x-token" { + t.Fatalf("ACAH 应回显请求头清单,got=%q", got) + } + if got := w.Header().Get("Access-Control-Allow-Methods"); got != corsDefaultMethods { + t.Fatalf("ACAM 应回落默认值,got=%q", got) + } + if got := w.Header().Get("Access-Control-Max-Age"); got != "7200" { + t.Fatalf("Max-Age 应被封顶到 7200,got=%q", got) + } + + // 显式配置时不回显 + w2 := httptest.NewRecorder() + WritePreflightHeaders(w2, r, "http://oa.x:7013", CORSPolicy{AllowHeaders: "x-only"}) + if got := w2.Header().Get("Access-Control-Allow-Headers"); got != "x-only" { + t.Fatalf("显式配置应优先,got=%q", got) + } +} + +// TestResolveCORSPolicy 站点级按字段覆盖全局,没填的字段继续用全局。 +func TestResolveCORSPolicy(t *testing.T) { + g := CORSPolicy{ + AllowOrigins: BuildAllowOrigins("https://global.x"), + AllowMethods: "GET", + MaxAge: 100, + } + p := ResolveCORSPolicy("http://oa.x:7013", "", "", 0, g) + if len(p.AllowOrigins) != 1 || p.AllowOrigins[0] != "http://oa.x:7013" { + t.Fatalf("站点级 Origin 应覆盖全局,got=%v", p.AllowOrigins) + } + if p.AllowMethods != "GET" || p.MaxAge != 100 { + t.Fatalf("站点没填的字段应沿用全局,got=%+v", p) + } + if !p.Enabled() { + t.Fatal("有 Origin 就该是启用状态") + } + if ResolveCORSPolicy("", "", "", 0, CORSPolicy{}).Enabled() { + t.Fatal("两级都没配时必须是关闭状态(默认关,存量零影响)") + } +} + +// TestResolveCORSPolicySiteOverride 站点级覆盖的三种语义。 +// +// 关键是「-」哨兵:没有它的话,为某一个站点配的 Origin 会在所有继承全局的站点上 +// 一并生效,而站点侧没有任何办法关掉——等于一处配置放宽了全部站点。 +func TestResolveCORSPolicySiteOverride(t *testing.T) { + g := CORSPolicy{AllowOrigins: BuildAllowOrigins("https://global.x"), AllowMethods: "GET"} + + if p := ResolveCORSPolicy("", "", "", 0, g); len(p.AllowOrigins) != 1 { + t.Fatal("站点留空应沿用全局") + } + if p := ResolveCORSPolicy("-", "", "", 0, g); p.Enabled() { + t.Fatal("站点填 - 应显式关闭,不得继承全局清单") + } + if p := ResolveCORSPolicy(" - ", "", "", 0, g); p.Enabled() { + t.Fatal("哨兵两侧的空白应被忽略") + } + // 站点填了但全是非法值:以站点为准(空清单),不回落全局。 + // 回落等于用户想收紧、系统却悄悄放宽。 + if p := ResolveCORSPolicy("not-an-origin\n*", "", "", 0, g); p.Enabled() { + t.Fatal("站点填了非法值应落到空清单,不得回落全局") + } + if p := ResolveCORSPolicy("http://site.x:8080", "", "", 0, g); len(p.AllowOrigins) != 1 || + p.AllowOrigins[0] != "http://site.x:8080" { + t.Fatal("站点填了合法值应以站点为准") + } +} + +// TestAddVaryOrigin 命中与不命中都要带 Vary,缓存行为才对称。 +func TestAddVaryOrigin(t *testing.T) { + w := httptest.NewRecorder() + AddVaryOrigin(w) + if got := w.Header().Values("Vary"); len(got) != 1 || got[0] != "Origin" { + t.Fatalf("Vary=%v", got) + } +} diff --git a/wafenginecore/accessgate/hostkey.go b/wafenginecore/accessgate/hostkey.go new file mode 100644 index 00000000..3c30942f --- /dev/null +++ b/wafenginecore/accessgate/hostkey.go @@ -0,0 +1,99 @@ +package accessgate + +import ( + "crypto/sha256" + "encoding/hex" + "strings" +) + +// hostkey.go 负责把 r.Host 归一化成「站点身份」,并由它派生令牌 Cookie 名。 +// +// 这里是整个 Access 模块的安全支点,两个下游共用同一份结果: +// - 令牌 Cookie 名的派生(TokenCookieName) +// - 令牌绑定的比对(AccessState.matchBindings) +// 两者若各归一化一次,一旦出现差异就会变成「Cookie 找得到、绑定判不过」的死循环。 + +// NormalizeHost 归一化 host[:port]。isTLS 决定默认端口怎么算。 +// +// 默认端口的去除必须按 scheme 判定:若不看 scheme 一律去掉 :80 与 :443, +// HTTP 站点的 oa.x:443 与 HTTPS 站点的 oa.x 会归一化成同一个字符串, +// 令牌于是在两个不同站点之间互通。 +func NormalizeHost(host string, isTLS bool) string { + h := strings.ToLower(strings.TrimSpace(host)) + if h == "" { + return "" + } + // Host 头不该带用户信息,带了就取 @ 之后的部分, + // 免得 a@oa.x 与 oa.x 各占一个 Cookie 槽位。 + if i := strings.LastIndex(h, "@"); i >= 0 { + h = h[i+1:] + } + hostPart, port := splitHostPort(h) + // oa.x. 是 oa.x 的 FQDN 绝对形式,同一个站点 + hostPart = strings.TrimSuffix(hostPart, ".") + if hostPart == "" { + return "" + } + if (isTLS && port == "443") || (!isTLS && port == "80") { + port = "" + } + if port == "" { + return hostPart + } + return hostPart + ":" + port +} + +// splitHostPort 拆主机与端口,IPv6 的方括号原样保留。 +// +// 不用 net.SplitHostPort:它在没有端口时返回错误,而这里「没有端口」是常态。 +// 未加方括号的裸 IPv6(如 ::1)按 RFC 7230 本就不是合法 Host, +// 这里按「整串都是主机」处理,不去猜哪个冒号是端口分隔符。 +func splitHostPort(h string) (string, string) { + if strings.HasPrefix(h, "[") { + i := strings.Index(h, "]") + if i < 0 { + return h, "" + } + rest := h[i+1:] + if strings.HasPrefix(rest, ":") { + return h[:i+1], rest[1:] + } + return h[:i+1], "" + } + if strings.Count(h, ":") == 1 { + i := strings.Index(h, ":") + return h[:i], h[i+1:] + } + return h, "" +} + +// TokenCookieName 派生业务域子令牌的 Cookie 名:prefix + "_tk_" + sha256(host)[:8]。 +// +// 为什么不能所有站点共用一个名字:Cookie 存储不区分端口(RFC 6265), +// oa.x:7013 与 oa.x:7014 这两个独立站点会争抢同一个槽位,后登录的覆盖先登录的; +// 而令牌又是按 host 绑定的,被覆盖的那个站点从此永远验不过。 +// +// 取 8 字节而不是 4:碰撞不构成越权(绑定仍按 host 严格比对),只退化成上面那个 +// 覆盖问题,但加宽的成本是零。 +// +// 新名字仍以 CookiePrefix 开头,所以 stripAccessCookies 的前缀剥离无需改动。 +func TokenCookieName(prefix, normalizedHost string) string { + if prefix == "" { + prefix = disabledDefault.CookiePrefix + } + if normalizedHost == "" { + // 正常路径走不到:能进网关说明请求已按 Host 匹配到站点。 + // 真走到了就回退到旧名字,保持可用而不是把人锁在外面。 + return prefix + "_tk" + } + sum := sha256.Sum256([]byte(normalizedHost)) + return prefix + "_tk_" + hex.EncodeToString(sum[:8]) +} + +// LegacyTokenCookieName 是改造前所有站点共用的固定名字,只用于升级后清理残留。 +func LegacyTokenCookieName(prefix string) string { + if prefix == "" { + prefix = disabledDefault.CookiePrefix + } + return prefix + "_tk" +} diff --git a/wafenginecore/accessgate/hostkey_test.go b/wafenginecore/accessgate/hostkey_test.go new file mode 100644 index 00000000..75786d6a --- /dev/null +++ b/wafenginecore/accessgate/hostkey_test.go @@ -0,0 +1,101 @@ +package accessgate + +import "testing" + +// TestNormalizeHostDefaultPortByScheme 是本次改造最危险的一条。 +// +// 若默认端口的去除不看 scheme、一律去掉 :80 与 :443, +// HTTP 站点的 oa.x:443 与 HTTPS 站点的 oa.x 会归一化成同一个字符串, +// 于是它们的令牌互通 —— 比这次要修的 bug 严重得多。 +func TestNormalizeHostDefaultPortByScheme(t *testing.T) { + if a, b := NormalizeHost("oa.x:443", false), NormalizeHost("oa.x", true); a == b { + t.Fatalf("HTTP 的 oa.x:443 与 HTTPS 的 oa.x 归一化成了同一个值 %q,令牌会跨站点互通", a) + } + if a, b := NormalizeHost("oa.x:80", true), NormalizeHost("oa.x", false); a == b { + t.Fatalf("HTTPS 的 oa.x:80 与 HTTP 的 oa.x 归一化成了同一个值 %q", a) + } + // 与自身 scheme 匹配的默认端口才去掉 + if got := NormalizeHost("oa.x:80", false); got != "oa.x" { + t.Fatalf("HTTP 下 :80 应去掉,got=%q", got) + } + if got := NormalizeHost("oa.x:443", true); got != "oa.x" { + t.Fatalf("HTTPS 下 :443 应去掉,got=%q", got) + } +} + +func TestNormalizeHostCases(t *testing.T) { + cases := []struct { + in string + isTLS bool + want string + }{ + {"OA.X:7013", false, "oa.x:7013"}, + {" oa.x:7014 ", false, "oa.x:7014"}, + {"oa.x.", false, "oa.x"}, + {"oa.x.:7013", false, "oa.x:7013"}, + {"user@oa.x:7013", false, "oa.x:7013"}, + {"[::1]:8080", false, "[::1]:8080"}, + {"[::1]", false, "[::1]"}, + {"[::1]:80", false, "[::1]"}, + {"::1", false, "::1"}, + {"", false, ""}, + {" ", false, ""}, + } + for _, c := range cases { + if got := NormalizeHost(c.in, c.isTLS); got != c.want { + t.Errorf("NormalizeHost(%q,%v) = %q,期望 %q", c.in, c.isTLS, got, c.want) + } + } +} + +// TestTokenCookieNamePerSite 同主机名不同端口必须派生出不同的 Cookie 名。 +// 这正是「已登录却一直 401」的根因:Cookie 不区分端口,同名即互相覆盖。 +func TestTokenCookieNamePerSite(t *testing.T) { + a := TokenCookieName("samwaf_ac", NormalizeHost("oa.x:7013", false)) + b := TokenCookieName("samwaf_ac", NormalizeHost("oa.x:7014", false)) + if a == b { + t.Fatalf("两个端口派生出了同一个 Cookie 名 %q,仍会互相覆盖", a) + } + // 稳定性:同一站点每次派生必须一致,否则用户随机掉线 + if a != TokenCookieName("samwaf_ac", NormalizeHost("OA.X:7013 ", false)) { + t.Fatal("同一站点的 Cookie 名必须稳定") + } + // 前缀不变,stripAccessCookies 的剥离逻辑才不用改 + const want = "samwaf_ac_tk_" + if len(a) != len(want)+16 || a[:len(want)] != want { + t.Fatalf("Cookie 名格式不符:%q", a) + } + // 派生不出来时回退旧名,保持可用而不是把人锁在外面 + if got := TokenCookieName("samwaf_ac", ""); got != "samwaf_ac_tk" { + t.Fatalf("空 host 应回退到旧名,got=%q", got) + } + if got := LegacyTokenCookieName(""); got != "samwaf_ac_tk" { + t.Fatalf("LegacyTokenCookieName 空前缀应回落默认,got=%q", got) + } +} + +// TestNormalizeHostCollapsesDefaultPorts 记录一条**有意为之但很危险**的性质: +// +// 浏览器访问 http://oa.x/ 与 https://oa.x/ 都只发 Host: oa.x, +// 而 SamWaf 里 oa.x:80 与 oa.x:443 是两条独立的站点记录。 +// 归一化之后它们的 host 串必然相同——这不是 bug,是 host 串本身 +// 承载不了「是哪一条站点记录」这个信息。 +// +// 所以令牌绑定绝不能只靠 host 串:真正区分站点的是 AccessToken.HostCode, +// 见 waf_access_session_service.go 的 matchBindings。 +// 这条用例存在的意义是:如果哪天有人想「顺手」用 host 串当站点身份, +// 先来看看这里。 +func TestNormalizeHostCollapsesDefaultPorts(t *testing.T) { + httpSite := NormalizeHost("oa.x", false) // 浏览器访问 http://oa.x/ + httpsSite := NormalizeHost("oa.x", true) // 浏览器访问 https://oa.x/ + explicit80 := NormalizeHost("oa.x:80", false) + explicit443 := NormalizeHost("oa.x:443", true) + + if httpSite != httpsSite || httpSite != explicit80 || httpSite != explicit443 { + t.Fatalf("四种写法应当归一化到同一个 host 串,got %q/%q/%q/%q", + httpSite, httpsSite, explicit80, explicit443) + } + if TokenCookieName("samwaf_ac", httpSite) != TokenCookieName("samwaf_ac", httpsSite) { + t.Fatal("同一 host 串必然派生同一 Cookie 名") + } +} diff --git a/wafupgradenotice/upgrade_notes.yaml b/wafupgradenotice/upgrade_notes.yaml index 4218dec5..75745aaa 100644 --- a/wafupgradenotice/upgrade_notes.yaml +++ b/wafupgradenotice/upgrade_notes.yaml @@ -567,3 +567,28 @@ notes: effect_on: With the upstream proxy's own address (or simply private in containers), the console judges the allowlist and login lockout by the real client IP, and login history and security audit record the real source. effect_off: With 0.0.0.0/0 or an empty value the proxy header is not honoured and every request is recorded as the same proxy or gateway address - the IP allowlist can no longer tell anyone apart, and a single brute-force burst can lock everyone out at once. revert: Clearing the setting restores the "direct peer only" behaviour. + + - id: v1_3_25_access_cookie_per_site + version: v1.3.25 + kind: notice + level: low + page: '' + doc: https://doc.samwaf.com/quickstart/Update.html + apply: + type: none + zh: + title: 统一访问认证的登录凭据改为按站点区分 + detail: >- + 只影响开启了「统一访问认证」的部署,其余部署无感。凭据 Cookie 过去所有站点共用一个名字, + 而浏览器保存 Cookie 时不区分端口,于是同一域名下不同端口的两个站点会互相覆盖对方的登录状态—— + 表现为「明明登录过,换个端口的站点却一直要求重新登录」。现在按站点各自命名,不再冲突。 + 升级后所有人需要重新认证一次,浏览器访问时会自动跳转完成,无需人工操作。 + en: + title: Unified Access credentials are now scoped per site + detail: >- + This only affects deployments with Unified Access enabled; everything else is unchanged. The credential + cookie previously used one shared name for every site, and browsers do not scope cookies by port, so two + sites on the same hostname but different ports overwrote each other's login state - which looked like + "I am logged in, yet this other port keeps asking me to log in again". Each site now gets its own cookie + name, so they no longer collide. Everyone has to authenticate once more after the upgrade; browsers + complete that automatically on the next page load, with nothing to do by hand.