diff --git a/model/hosts.go b/model/hosts.go index 065b77c..e5627ee 100644 --- a/model/hosts.go +++ b/model/hosts.go @@ -49,6 +49,7 @@ type Hosts struct { CustomHeadersJSON string `gorm:"type:text" json:"custom_headers_json"` //自定义头信息配置 json CustomResponseHeadersJSON string `gorm:"type:text" json:"custom_response_headers_json"` //自定义响应头信息配置 json ResponseCompressJSON string `gorm:"type:text" json:"response_compress_json"` //响应压缩配置 json(Gzip/Brotli/Zstd) + CookieSecurityJSON string `gorm:"type:text" json:"cookie_security_json"` //Cookie安全保护配置 json(HttpOnly/Secure/SameSite) IPMode string `gorm:"size:20" json:"ip_mode"` //IP提取模式: "nic" 网卡模式 或 "proxy" 代理模式 } @@ -187,6 +188,33 @@ type ResponseCompressConfig struct { CompressWhenStaticAssist int `json:"compress_when_static_assist"` // 1 时对静态协助响应也读体压缩 } +// CookieSecurityConfig Cookie 安全保护(应答方向给后端下发的 Set-Cookie 补齐安全属性) +// 设计原则:缺失才补——仅在对应属性缺失时追加,绝不覆盖应用已设的值,保留原 cookie 全部内容。 +type CookieSecurityConfig struct { + IsEnable int `json:"is_enable"` // 1 开启 0 关闭(默认0,老站点不受影响) + HttpOnly int `json:"http_only"` // 1 缺失时补 HttpOnly / 0 不动(默认1) + Secure int `json:"secure"` // 0 不动 / 1 强制补 / 2 仅 HTTPS 自动补(默认2) + SameSite string `json:"same_site"` // "" 不动 / Lax / Strict / None(默认Lax) + ExcludeCookies string `json:"exclude_cookies"` // 排除的 cookie 名,逗号分隔(如三方/SSO cookie 原样放过) +} + +// ParseCookieSecurityConfig 解析 Cookie 安全保护配置;空 JSON 给安全默认值(但默认关闭) +func ParseCookieSecurityConfig(jsonStr string) CookieSecurityConfig { + c := CookieSecurityConfig{ + IsEnable: 0, + HttpOnly: 1, + Secure: 2, + SameSite: "Lax", + } + if jsonStr == "" { + return c + } + if err := json.Unmarshal([]byte(jsonStr), &c); err != nil { + return CookieSecurityConfig{IsEnable: 0, HttpOnly: 1, Secure: 2, SameSite: "Lax"} + } + return c +} + // TransportConfig 传输配置 type TransportConfig struct { MaxIdleConns int `json:"max_idle_conns"` // 最大空闲连接数 diff --git a/model/request/waf_host_req.go b/model/request/waf_host_req.go index 1cc4b54..7dc0885 100644 --- a/model/request/waf_host_req.go +++ b/model/request/waf_host_req.go @@ -43,6 +43,7 @@ type WafHostAddReq struct { CustomHeadersJSON string `json:"custom_headers_json"` //自定义头信息配置 json CustomResponseHeadersJSON string `json:"custom_response_headers_json"` //自定义响应头信息配置 json ResponseCompressJSON string `json:"response_compress_json"` //响应压缩配置 json + CookieSecurityJSON string `json:"cookie_security_json"` //Cookie安全保护配置 json IPMode string `json:"ip_mode"` //IP提取模式: "nic" 网卡模式 或 "proxy" 代理模式 } @@ -95,6 +96,7 @@ type WafHostEditReq struct { CustomHeadersJSON string `json:"custom_headers_json"` //自定义头信息配置 json CustomResponseHeadersJSON string `json:"custom_response_headers_json"` //自定义响应头信息配置 json ResponseCompressJSON string `json:"response_compress_json"` //响应压缩配置 json + CookieSecurityJSON string `json:"cookie_security_json"` //Cookie安全保护配置 json IPMode string `json:"ip_mode"` //IP提取模式: "nic" 网卡模式 或 "proxy" 代理模式 } type WafHostGuardStatusReq struct { diff --git a/service/waf_service/waf_host.go b/service/waf_service/waf_host.go index b3e4279..77161e8 100644 --- a/service/waf_service/waf_host.go +++ b/service/waf_service/waf_host.go @@ -101,6 +101,7 @@ func (receiver *WafHostService) AddApi(wafHostAddReq request.WafHostAddReq) (str CustomHeadersJSON: wafHostAddReq.CustomHeadersJSON, CustomResponseHeadersJSON: wafHostAddReq.CustomResponseHeadersJSON, ResponseCompressJSON: wafHostAddReq.ResponseCompressJSON, + CookieSecurityJSON: wafHostAddReq.CookieSecurityJSON, IPMode: wafHostAddReq.IPMode, } global.GWAF_LOCAL_DB.Create(wafHost) @@ -166,6 +167,7 @@ func (receiver *WafHostService) ModifyApi(wafHostEditReq request.WafHostEditReq) "CustomHeadersJSON": wafHostEditReq.CustomHeadersJSON, "CustomResponseHeadersJSON": wafHostEditReq.CustomResponseHeadersJSON, "ResponseCompressJSON": wafHostEditReq.ResponseCompressJSON, + "CookieSecurityJSON": wafHostEditReq.CookieSecurityJSON, "IPMode": wafHostEditReq.IPMode, } err := global.GWAF_LOCAL_DB.Debug().Model(model.Hosts{}).Where("CODE=?", wafHostEditReq.CODE).Updates(hostMap).Error diff --git a/wafdb/migrations_core.go b/wafdb/migrations_core.go index d7d5021..c688aed 100644 --- a/wafdb/migrations_core.go +++ b/wafdb/migrations_core.go @@ -1037,6 +1037,32 @@ func RunCoreDBMigrations(db *gorm.DB) error { return nil }, }, + { + ID: "202606300001_add_hosts_cookie_security_json", + Migrate: func(tx *gorm.DB) error { + zlog.Info("迁移 202606300001: 为 hosts 表添加 cookie_security_json 字段") + if tx.Migrator().HasColumn(&model.Hosts{}, "cookie_security_json") { + zlog.Info("cookie_security_json 字段已存在,跳过添加") + return nil + } + if err := tx.Migrator().AddColumn(&model.Hosts{}, "cookie_security_json"); err != nil { + return fmt.Errorf("添加 cookie_security_json 字段失败: %w", err) + } + defaultJSON := `{"is_enable":0,"http_only":1,"secure":2,"same_site":"Lax","exclude_cookies":""}` + if err := tx.Exec("UPDATE hosts SET cookie_security_json = ? WHERE cookie_security_json IS NULL OR cookie_security_json = ''", defaultJSON).Error; err != nil { + zlog.Warn("设置 cookie_security_json 默认值失败", "error", err.Error()) + } + zlog.Info("cookie_security_json 字段添加成功") + return nil + }, + Rollback: func(tx *gorm.DB) error { + zlog.Info("回滚 202606300001: 删除 hosts 表的 cookie_security_json 字段") + if tx.Migrator().HasColumn(&model.Hosts{}, "cookie_security_json") { + return tx.Migrator().DropColumn(&model.Hosts{}, "cookie_security_json") + } + return nil + }, + }, { ID: "202606290001_add_rbac_and_password_policy", Migrate: func(tx *gorm.DB) error { diff --git a/wafenginecore/cookie_security.go b/wafenginecore/cookie_security.go new file mode 100644 index 0000000..119019b --- /dev/null +++ b/wafenginecore/cookie_security.go @@ -0,0 +1,62 @@ +package wafenginecore + +import ( + "SamWaf/model" + "strings" +) + +// secureSetCookie 对单条 Set-Cookie 头按策略「缺失才补」安全属性。 +// 保留原 Set-Cookie 的全部内容(Path/Domain/Max-Age/Expires/未知属性),仅在缺失时追加: +// - HttpOnly:cfg.HttpOnly==1 且原值无 httponly 时追加 +// - Secure :cfg.Secure==1 强制;==2 仅当 isHTTPS;SameSite=None 也强制(None 要求 Secure) +// - SameSite:cfg.SameSite 非空且原值无 samesite 时追加 +// +// 安全要点:HTTP 下 Secure==2 不追加 Secure,否则 cookie 会失效。 +func secureSetCookie(raw string, isHTTPS bool, cfg model.CookieSecurityConfig) string { + if strings.TrimSpace(raw) == "" { + return raw + } + + // 提取 cookie 名(第一个 '=' 前的串),命中排除名单则原样返回 + name := raw + if idx := strings.Index(raw, "="); idx >= 0 { + name = raw[:idx] + } + name = strings.TrimSpace(name) + if isExcludedCookie(name, cfg.ExcludeCookies) { + return raw + } + + lower := strings.ToLower(raw) + + if cfg.HttpOnly == 1 && !strings.Contains(lower, "httponly") { + raw += "; HttpOnly" + lower = strings.ToLower(raw) + } + + needSecure := cfg.Secure == 1 || (cfg.Secure == 2 && isHTTPS) || cfg.SameSite == "None" + if needSecure && !strings.Contains(lower, "secure") { + raw += "; Secure" + lower = strings.ToLower(raw) + } + + if cfg.SameSite != "" && !strings.Contains(lower, "samesite") { + raw += "; SameSite=" + cfg.SameSite + } + + return raw +} + +// isExcludedCookie 判断 cookie 名是否在排除名单(逗号分隔,大小写不敏感) +func isExcludedCookie(name, excludeCookies string) bool { + if excludeCookies == "" { + return false + } + for _, ex := range strings.Split(excludeCookies, ",") { + ex = strings.TrimSpace(ex) + if ex != "" && strings.EqualFold(ex, name) { + return true + } + } + return false +} diff --git a/wafenginecore/cookie_security_test.go b/wafenginecore/cookie_security_test.go new file mode 100644 index 0000000..f41fb5e --- /dev/null +++ b/wafenginecore/cookie_security_test.go @@ -0,0 +1,125 @@ +package wafenginecore + +import ( + "SamWaf/model" + "strings" + "testing" +) + +func TestSecureSetCookie(t *testing.T) { + full := model.CookieSecurityConfig{IsEnable: 1, HttpOnly: 1, Secure: 2, SameSite: "Lax"} + + tests := []struct { + name string + raw string + isHTTPS bool + cfg model.CookieSecurityConfig + wantContain []string + wantAbsent []string + }{ + { + name: "https adds all when missing", + raw: "sid=abc; Path=/", + isHTTPS: true, + cfg: full, + wantContain: []string{"sid=abc", "Path=/", "HttpOnly", "Secure", "SameSite=Lax"}, + }, + { + name: "http secure auto (mode2) does NOT add Secure", + raw: "sid=abc; Path=/", + isHTTPS: false, + cfg: full, + wantContain: []string{"HttpOnly", "SameSite=Lax"}, + wantAbsent: []string{"Secure"}, + }, + { + name: "secure force (mode1) adds even on http", + raw: "sid=abc", + isHTTPS: false, + cfg: model.CookieSecurityConfig{IsEnable: 1, Secure: 1}, + wantContain: []string{"Secure"}, + }, + { + name: "secure off (mode0) never adds", + raw: "sid=abc", + isHTTPS: true, + cfg: model.CookieSecurityConfig{IsEnable: 1, Secure: 0}, + wantAbsent: []string{"Secure"}, + }, + { + name: "does not duplicate existing httponly (case-insensitive)", + raw: "sid=abc; httponly", + isHTTPS: true, + cfg: model.CookieSecurityConfig{IsEnable: 1, HttpOnly: 1}, + wantContain: []string{"httponly"}, + wantAbsent: []string{"HttpOnly"}, // 不应再追加首字母大写的副本 + }, + { + name: "does not override existing samesite", + raw: "sid=abc; SameSite=Strict", + isHTTPS: true, + cfg: model.CookieSecurityConfig{IsEnable: 1, SameSite: "Lax"}, + wantContain: []string{"SameSite=Strict"}, + wantAbsent: []string{"SameSite=Lax"}, + }, + { + name: "samesite none forces secure", + raw: "sid=abc", + isHTTPS: false, + cfg: model.CookieSecurityConfig{IsEnable: 1, Secure: 2, SameSite: "None"}, + wantContain: []string{"SameSite=None", "Secure"}, + }, + { + name: "excluded cookie untouched", + raw: "_ga=GA1.2.3; Path=/", + isHTTPS: true, + cfg: model.CookieSecurityConfig{IsEnable: 1, HttpOnly: 1, Secure: 1, SameSite: "Lax", ExcludeCookies: "_ga, sso_token"}, + wantContain: []string{"_ga=GA1.2.3; Path=/"}, + wantAbsent: []string{"HttpOnly", "Secure", "SameSite"}, + }, + { + name: "preserves original and unknown attributes", + raw: "sid=abc; Path=/app; Domain=example.com; Max-Age=3600; Priority=High", + isHTTPS: true, + cfg: full, + wantContain: []string{"Path=/app", "Domain=example.com", "Max-Age=3600", "Priority=High", "HttpOnly", "Secure"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := secureSetCookie(tt.raw, tt.isHTTPS, tt.cfg) + for _, c := range tt.wantContain { + if !strings.Contains(got, c) { + t.Errorf("expected result to contain %q, got %q", c, got) + } + } + for _, a := range tt.wantAbsent { + if strings.Contains(got, a) { + t.Errorf("expected result to NOT contain %q, got %q", a, got) + } + } + }) + } +} + +func TestSecureSetCookieEmptyRaw(t *testing.T) { + if got := secureSetCookie("", true, model.CookieSecurityConfig{IsEnable: 1, HttpOnly: 1}); got != "" { + t.Errorf("empty raw should stay empty, got %q", got) + } +} + +func TestIsExcludedCookie(t *testing.T) { + if !isExcludedCookie("_ga", "_ga, foo") { + t.Error("_ga should be excluded") + } + if !isExcludedCookie("FOO", "_ga, foo") { + t.Error("case-insensitive match expected") + } + if isExcludedCookie("bar", "_ga, foo") { + t.Error("bar should not be excluded") + } + if isExcludedCookie("bar", "") { + t.Error("empty exclude list excludes nothing") + } +} diff --git a/wafenginecore/wafengine.go b/wafenginecore/wafengine.go index 12a2c60..a42d85b 100644 --- a/wafenginecore/wafengine.go +++ b/wafenginecore/wafengine.go @@ -1018,6 +1018,27 @@ func (waf *WafEngine) modifyResponse() func(*http.Response) error { } } + // Cookie 安全保护:缺失才补 HttpOnly/Secure/SameSite(按站点配置) + if wafCtx, ok := r.Context().Value("waf_context").(innerbean.WafHttpContextData); ok { + host := waf.rt().HostCode[wafCtx.HostCode] + if hostTarget, exists := waf.rt().HostTarget[host]; exists { + cookieCfg := model.ParseCookieSecurityConfig(hostTarget.Host.CookieSecurityJSON) + if cookieCfg.IsEnable == 1 { + if lines := resp.Header.Values("Set-Cookie"); len(lines) > 0 { + isHTTPS := r.TLS != nil + secured := make([]string, 0, len(lines)) + for _, l := range lines { + secured = append(secured, secureSetCookie(l, isHTTPS, cookieCfg)) + } + resp.Header.Del("Set-Cookie") + for _, l := range secured { + resp.Header.Add("Set-Cookie", l) + } + } + } + } + } + // 检查是否为WebSocket协议切换 if resp.StatusCode == http.StatusSwitchingProtocols {