feat:per-site cookie security hardening

#844
This commit is contained in:
samwaf
2026-06-30 14:38:21 +08:00
parent 0329deb711
commit cda3bd8fa4
7 changed files with 266 additions and 0 deletions
+28
View File
@@ -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"` // 最大空闲连接数
+2
View File
@@ -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 {
+2
View File
@@ -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
+26
View File
@@ -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 {
+62
View File
@@ -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
}
+125
View File
@@ -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")
}
}
+21
View File
@@ -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 {