mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-10-31 02:01:44 +08:00
@@ -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"` // 最大空闲连接数
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user