diff --git a/docs/security/waf_SECURITY_AUDIT_20260801.md b/docs/security/waf_SECURITY_AUDIT_20260801.md index fde766c..8114a91 100644 --- a/docs/security/waf_SECURITY_AUDIT_20260801.md +++ b/docs/security/waf_SECURITY_AUDIT_20260801.md @@ -334,7 +334,7 @@ ServeHTTP 中 defer 只关闭了 WAF 事务 `tx`,但没有显式关闭/耗尽 ## 代码验证记录 -**验证日期**: 2026-08-03 +**验证日期**: 2026-08-08(二次验证,P1/P2 全量修复) **验证方式**: 逐条对照审计报告与当前代码 ### 验证结果汇总 @@ -342,29 +342,43 @@ ServeHTTP 中 defer 只关闭了 WAF 事务 `tx`,但没有显式关闭/耗尽 | 等级 | 总数 | 已修复 | 未修复 | 修复率 | |------|------|--------|--------|--------| | P0 | 3 | 2 | 1 | 67% | -| P1 | 5 | 1 | 4 | 20% | -| P2 | 6 | 3 | 3 | 50% | +| P1 | 5 | 5 | 0 | 100% | +| P2 | 6 | 6 | 0 | 100% | | P3 | 3 | 0 | 3 | 0% | -| **合计** | **17** | **6** | **11** | **35%** | +| **合计** | **17** | **13** | **4** | **76%** | ### 逐条验证 | 编号 | 状态 | 代码位置 | |------|------|----------| | P0-1 | ✅ 已修复 | `proxy.go` L22-23 `maxRequestBodySize`,L128-151 `io.LimitReader` + 413 | -| P0-2 | ✅ 已修复 | `main.go` L111-122 五项超时/大小限制已设置 | -| **P0-3** | ❌ 未修复 | `custom.conf` 全部 6 条规则仍被 `#` 注释 | -| **P1-1** | ❌ 未修复 | `proxy.go` L47-48 仍用 `LastIndexByte(':')`,未用 `net.SplitHostPort` | -| **P1-2** | ❌ 未修复 | `proxy.go` L194 `ProcessResponseHeaders` 返回值被丢弃 | +| P0-2 | ✅ 已修复 | `main.go` L112-123 五项超时/大小限制已设置 | +| **P0-3** | ❌ 未修复 | `custom.conf` 全部 6 条规则仍被 `#` 注释(依赖拉取 CRS 生效) | +| P1-1 | ✅ 已修复 | `proxy.go` L45-49、L102-105 改用 `net.SplitHostPort`,正确处理 IPv6 | +| P1-2 | ✅ 已修复 | `proxy.go` L194-204 捕获 `ProcessResponseHeaders` 返回值,命中记录 WARNING 告警 | | P1-3 | ✅ 已修复 | `proxy.go` L130-135 读取错误已记录日志并返回 | -| **P1-4** | ❌ 未修复 | `main.go` L99-109 只处理 SIGHUP,无 SIGINT/SIGTERM | -| **P1-5** | ❌ 未修复 | `main.go` L89 `log.SetOutput(io.Discard)` 仍在 | -| **P2-1** | ❌ 未修复 | `engine.go` L157 `os.ReadFile` 无前置大小检查 | -| **P2-2** | ❌ 未修复 | `logclient.go` L24 `const dbAddr = "127.0.0.1:8005"` 硬编码 | +| P1-4 | ✅ 已修复 | `main.go` L125-152 SIGINT/SIGTERM 优雅关闭 + `logclient.Flush()` | +| P1-5 | ✅ 已修复 | `main.go` L89-90 `log.SetOutput(os.Stderr)`,保留 panic 堆栈 | +| P2-1 | ✅ 已修复 | `engine.go` L18-31 `checkRuleFileSize`(1 MiB 上限),Reload/TestFile/ListRules 均校验 | +| P2-2 | ✅ 已修复 | `logclient.go` L25-40 支持 `LOG_DB_ADDR` 环境变量 | | P2-3 | ✅ 已修复 | `proxy.go` L194 改为 `r.Proto` | | P2-4 | ✅ 已修复 | `proxy.go` L254-269 完整实现 `Hijack()` | -| **P2-5** | ❌ 未修复 | `logclient.go` L223 `fmt.Printf` 到 stdout | +| P2-5 | ✅ 已修复 | `logclient.go` L233 `fmt.Fprintf(os.Stderr, ...)` | | P2-6 | ✅ 已修复 | `proxy.go` L93-97 defer 中 drain + close | -| P3-1 | ❌ 未修复 | `logclient.go` L229 每条日志 `net.DialTimeout` 短连接 | -| P3-2 | ❌ 未修复 | `main.go` L119-121 仅设置 MinVersion,未限定 CipherSuites | -| P3-3 | ❌ 未修复 | `engine.go` L103-129 CheckInput 编码逻辑未变 | +| P3-1 | ❌ 未修复 | `logclient.go` 每条日志 `net.DialTimeout` 短连接(TIME_WAIT) | +| P3-2 | ❌ 未修复 | `main.go` L120-122 仅设置 MinVersion,未限定 CipherSuites | +| P3-3 | ❌ 未修复 | `engine.go` CheckInput 编码逻辑未变(CLI 工具,不影响线上) | + +### 二次修复说明(2026-08-08) + +本轮修复 P1 全部 4 项 + P2 剩余 3 项,P1/P2 清零,修复率 35% → 76%。 + +- **P1-1**:`X-Forwarded-For` 与 `ProcessConnection` 的 IP 解析统一改用 `net.SplitHostPort`,IPv6 地址(如 `[::1]:54321`)不再带方括号,gateway RateLimiter 限流恢复正确。 +- **P1-2**:`ProcessResponseHeaders` 返回值不再被丢弃。受限于响应头已发送无法撤销状态码,命中后仅记录 WARNING 告警日志(含 rule_id/ip/method/path/status),不执行拦截。 +- **P1-4**:新增 SIGINT/SIGTERM 监听,`server.Shutdown(ctx)` 给进行中请求 10s 宽限期,退出前 `logclient.Flush()` 刷新队列日志,避免日志丢失。 +- **P1-5**:标准 `log` 输出由 `io.Discard` 改为 `os.Stderr`,net/http 的 panic 堆栈与 TLS 握手错误可被 journalctl 捕获。 +- **P2-1**:新增 `checkRuleFileSize`(1 MiB 上限),`Reload` 中主配置/local/CRS 每个文件加载前校验,超限返回错误并保留旧实例;`ListRules` 跳过超大文件不中断列举;`TestFile` 校验目标文件。 +- **P2-2**:logclient db 地址支持环境变量 `LOG_DB_ADDR`(默认 `127.0.0.1:8005`),非标准部署可配置。 +- **P2-5**:日志 INSERT 失败由 `fmt.Printf`(stdout)改为 `fmt.Fprintf(os.Stderr, ...)`,带时间戳/级别可被守护进程捕获。 + +剩余未修复:P0-3(默认规则注释,设计上依赖拉取 CRS)、P3-1/2/3(低优先级优化)。 diff --git a/waf/README.md b/waf/README.md index ee5dbed..22132bc 100644 --- a/waf/README.md +++ b/waf/README.md @@ -333,21 +333,30 @@ WAF 支持透明代理 WebSocket 连接(如 aibox 的 WebSocket 接口)。 ## 已知限制 -- 响应头检测(`ProcessResponseHeaders`)当前仅做审计日志,命中后不执行拦截(响应头已发送无法撤销状态码)。如需响应头拦截需引入 buffered response writer。 +- 响应头检测(`ProcessResponseHeaders`)命中后仅记录 WARNING 告警日志,不执行拦截(响应头已发送无法撤销状态码)。如需响应头拦截需引入 buffered response writer。 - **响应体检测未启用**(未 `ProcessResponseBody`,缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。 - **WebSocket 帧内容不检测**:升级后二进制帧直接转发,不经过 WAF 规则引擎(适用于内部可信服务如 aibox)。 - 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。 - TLS 仅设 `MinVersion: TLS1.2`,未限定 cipher suites,允许弱加密套件。 -- `X-Forwarded-For` 直接覆盖原值(`Header.Set`),未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。注意:IPv6 地址在 X-Forwarded-For 中可能带方括号(待修复,使用 `LastIndexByte` 无法正确处理 IPv6 格式)。 +- `X-Forwarded-For` 直接覆盖原值(`Header.Set`),未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。 - CSP 仍含 `'unsafe-inline'`(为兼容 forgot.html 与 games/*.html 的内联脚本),待前端清理后可收紧。 - HSTS 已启用(半年期,不含子域),站点全量 HTTPS 覆盖后可按需收紧。 -- logclient 日志推送每次新建 TCP 短连接,高并发拦截场景可能产生较多 TIME_WAIT;db 地址硬编码 `127.0.0.1:8005`,不支持环境变量配置。 +- logclient 日志推送每次新建 TCP 短连接,高并发拦截场景可能产生较多 TIME_WAIT(已支持通过环境变量 `LOG_DB_ADDR` 配置 db 地址)。 - **默认规则全部注释**:`rules/local/custom.conf` 中自定义规则默认注释,需手动取消注释或拉取 OWASP CRS 后才有实际拦截效果。启动时无 warning 提示。**注意:CRS 规则目录不入库,需先在部署机执行 `_fetch_crs.sh`;未拉取 CRS 时 WAF 开箱无拦截能力。** -- 缺少 SIGINT/SIGTERM 优雅关闭,进程终止时进行中的请求被暴力中断,logclient 队列中未发送的日志可能丢失。 -- 标准日志(`log.SetOutput`)被静默到 `io.Discard`,net/http 的 panic 堆栈和 TLS 错误不可见。 ## 版本日志 +### v1.4(2026-08-08)— P1/P2 安全修复 + +- **fix(P1-1)**: IPv6 地址解析改用 `net.SplitHostPort`,修复 `[::1]:54321` 等 IPv6 地址在 X-Forwarded-For 中带方括号导致 gateway 限流失效 +- **fix(P1-2)**: `ProcessResponseHeaders` 返回值不再被丢弃,命中规则时记录 WARNING 告警日志(响应头已发送无法拦截状态码) +- **fix(P1-4)**: 增加 SIGINT/SIGTERM 优雅关闭,`server.Shutdown` 等待进行中请求完成(10s 宽限期),退出前 `logclient.Flush()` 刷新队列日志 +- **fix(P1-5)**: 标准日志输出到 stderr(`log.SetOutput(os.Stderr)`),保留 net/http 的 panic 堆栈与 TLS 错误,便于 journalctl 排查 +- **fix(P2-1)**: 规则文件加载前校验大小(上限 1 MiB),防止超大 `.conf` 文件导致 OOM +- **fix(P2-2)**: logclient db 地址支持环境变量 `LOG_DB_ADDR`(默认 `127.0.0.1:8005`) +- **fix(P2-5)**: logclient 日志失败输出到 stderr 而非 stdout +- 修复率从 35% 提升至 76%(P1 全部修复、P2 全部修复) + ### v1.3(2026-08-04)— 启用 OWASP CRS 异常评分制自动拦截 - **feat**: 启用 OWASP CRS v4.25.0 异常评分制,`SecRuleEngine On` 自动拦截评分达阈值的请求(规则 949110) diff --git a/waf/internal/engine/engine.go b/waf/internal/engine/engine.go index 000e941..ae46f80 100644 --- a/waf/internal/engine/engine.go +++ b/waf/internal/engine/engine.go @@ -15,6 +15,21 @@ import ( "github.com/micro_servers/waf/internal/logclient" ) +// maxRuleFileSize 单个规则文件大小上限(1 MiB),防止超大文件导致 OOM +const maxRuleFileSize = 1 << 20 + +// checkRuleFileSize 校验单个规则文件大小是否超限,超限返回错误。 +func checkRuleFileSize(path string) error { + info, err := os.Stat(path) + if err != nil { + return err + } + if info.Size() > maxRuleFileSize { + return fmt.Errorf("规则文件过大: %s (%d bytes, 上限 %d)", path, info.Size(), maxRuleFileSize) + } + return nil +} + // Engine 持有当前生效的 WAF 实例,支持通过 Reload 热替换。 type Engine struct { mu sync.RWMutex @@ -54,6 +69,9 @@ func (e *Engine) Reload() error { if _, err := os.Stat(mainFile); err != nil { return fmt.Errorf("主规则文件不存在: %w", err) } + if err := checkRuleFileSize(mainFile); err != nil { + return err + } builder := coraza.NewWAFConfig() if e.cfg.ErrorCallback != nil { @@ -66,15 +84,24 @@ func (e *Engine) Reload() error { // 2. 本地规则(local/*.conf,按字母序) localFiles, _ := filepath.Glob(filepath.Join(e.cfg.RulesDir, "local", "*.conf")) for _, f := range localFiles { + if err := checkRuleFileSize(f); err != nil { + return err + } builder = builder.WithDirectivesFromFile(f) } // 3. OWASP CRS(可选,crs/coraza.conf + crs/rules/*.conf) crsEntry := filepath.Join(e.cfg.RulesDir, "crs", "coraza.conf") if _, err := os.Stat(crsEntry); err == nil { + if err := checkRuleFileSize(crsEntry); err != nil { + return err + } builder = builder.WithDirectivesFromFile(crsEntry) crsRuleFiles, _ := filepath.Glob(filepath.Join(e.cfg.RulesDir, "crs", "rules", "*.conf")) for _, f := range crsRuleFiles { + if err := checkRuleFileSize(f); err != nil { + return err + } builder = builder.WithDirectivesFromFile(f) } } @@ -154,6 +181,10 @@ func (e *Engine) ListRules() ([]Rule, error) { if !strings.HasSuffix(path, ".conf") { return nil } + // 超大规则文件跳过,不中断整个列举 + if fi, err := os.Stat(path); err == nil && fi.Size() > maxRuleFileSize { + return nil + } data, err := os.ReadFile(path) if err != nil { return err @@ -239,6 +270,9 @@ func TestFile(file string) error { if _, err := os.Stat(abs); err != nil { return fmt.Errorf("文件不存在: %w", err) } + if err := checkRuleFileSize(abs); err != nil { + return err + } tmp, err := os.CreateTemp("", "waf-test-*.conf") if err != nil { diff --git a/waf/internal/logclient/logclient.go b/waf/internal/logclient/logclient.go index 87854bf..1faa267 100644 --- a/waf/internal/logclient/logclient.go +++ b/waf/internal/logclient/logclient.go @@ -16,12 +16,13 @@ import ( "fmt" "io" "net" + "os" "sync" "time" ) const ( - dbAddr = "127.0.0.1:8005" + defaultDBAddr = "127.0.0.1:8005" batchSize = 50 flushInterval = 2 * time.Second socketTimeout = 5 * time.Second @@ -29,6 +30,15 @@ const ( maxMsgSize = 64 * 1024 * 1024 ) +// dbAddr 通过环境变量 LOG_DB_ADDR 配置(默认 127.0.0.1:8005),支持非标准部署场景。 +var dbAddr = defaultDBAddr + +func init() { + if v := os.Getenv("LOG_DB_ADDR"); v != "" { + dbAddr = v + } +} + // Entry 单条日志。 type Entry struct { Service string `json:"service"` @@ -220,7 +230,7 @@ func (c *Client) sendOne(entry Entry, ts string) { var r dbV2Response if json.Unmarshal(resp, &r) != nil || !r.Success { - fmt.Printf("[logclient] INSERT failed: %s\n", r.Error) + fmt.Fprintf(os.Stderr, "[logclient] INSERT failed: %s\n", r.Error) } } diff --git a/waf/internal/proxy/proxy.go b/waf/internal/proxy/proxy.go index 8270b05..f701ec6 100644 --- a/waf/internal/proxy/proxy.go +++ b/waf/internal/proxy/proxy.go @@ -11,7 +11,6 @@ import ( "net/http/httputil" "net/url" "strconv" - "strings" "time" "github.com/micro_servers/waf/internal/engine" @@ -41,13 +40,13 @@ func New(upstream string, waf *engine.Engine) (*Proxy, error) { origDirector := rp.Director rp.Director = func(req *http.Request) { origDirector(req) - // RemoteAddr 形如 "1.2.3.4:54321",剥离端口只保留 IP, - // 否则 gateway RateLimiter 会把同 IP 不同端口的连接当成不同客户端 - clientIP := req.RemoteAddr - if idx := strings.LastIndexByte(req.RemoteAddr, ':'); idx != -1 { - clientIP = req.RemoteAddr[:idx] + // 剥离 RemoteAddr 端口只保留 IP,否则 gateway RateLimiter 会把同 IP 不同端口的连接当成不同客户端 + // 用 net.SplitHostPort 正确处理 IPv6(如 [::1]:54321 / 2001:db8::1:54321) + if host, _, err := net.SplitHostPort(req.RemoteAddr); err == nil { + req.Header.Set("X-Forwarded-For", host) + } else { + req.Header.Set("X-Forwarded-For", req.RemoteAddr) } - req.Header.Set("X-Forwarded-For", clientIP) req.Header.Set("X-Forwarded-Proto", "https") } // 透传错误,不暴露内部堆栈 @@ -97,12 +96,12 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { } }() - // 解析 RemoteAddr 中的 IP 与端口 + // 解析 RemoteAddr 中的 IP 与端口(用 net.SplitHostPort 正确处理 IPv6) client := r.RemoteAddr cport := 0 - if idx := strings.LastIndexByte(r.RemoteAddr, ':'); idx != -1 { - client = r.RemoteAddr[:idx] - cport, _ = strconv.Atoi(r.RemoteAddr[idx+1:]) + if host, port, err := net.SplitHostPort(r.RemoteAddr); err == nil { + client = host + cport, _ = strconv.Atoi(port) } // 请求阶段:照搬 coraza/http/middleware.go 的 processRequest 流程 @@ -186,12 +185,23 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // 响应阶段:把 upstream 的 header 喂给 WAF + // 注:响应头已发送无法撤销状态码,此处仅记录命中告警,不执行拦截 for k, vs := range rw.headers { for _, v := range vs { tx.AddResponseHeader(k, v) } } - tx.ProcessResponseHeaders(rw.status, r.Proto) + if it := tx.ProcessResponseHeaders(rw.status, r.Proto); it != nil { + logclient.Warning("waf", fmt.Sprintf("response_header_hit rule_id=%d %s %s", + it.RuleID, r.Method, r.URL.Path), + map[string]any{ + "rule_id": it.RuleID, + "ip": r.RemoteAddr, + "method": r.Method, + "path": r.URL.Path, + "status": rw.status, + }) + } } // block 命中规则时统一返回 403,并将关键输入推送到日志库。 diff --git a/waf/main.go b/waf/main.go index 9e7fea7..2511f29 100644 --- a/waf/main.go +++ b/waf/main.go @@ -12,11 +12,11 @@ package main import ( + "context" "crypto/tls" "encoding/json" "flag" "fmt" - "io" "log" "net/http" "os" @@ -62,8 +62,8 @@ func main() { } eng, err := engine.New(&engine.Config{ - RulesDir: cfg.RulesDir, - ErrorCallback: engine.LogError, + RulesDir: cfg.RulesDir, + ErrorCallback: engine.LogError, }) if err != nil { log.Fatalf("引擎初始化失败: %v", err) @@ -86,7 +86,8 @@ func main() { // 服务模式 logclient.Init() - log.SetOutput(io.Discard) // 静默标准 log + // 标准日志输出到 stderr(systemd/journalctl 可捕获),保留 net/http 的 panic 堆栈与 TLS 错误 + log.SetOutput(os.Stderr) p, err := proxy.New(cfg.Upstream, eng) if err != nil { @@ -96,10 +97,10 @@ func main() { } // SIGHUP 热加载 - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGHUP) + hupCh := make(chan os.Signal, 1) + signal.Notify(hupCh, syscall.SIGHUP) go func() { - for range sigCh { + for range hupCh { if err := eng.Reload(); err != nil { logclient.Error("waf", fmt.Sprintf("热加载失败,保留旧规则: %v", err), nil) } else { @@ -121,13 +122,33 @@ func main() { }, } + // SIGINT/SIGTERM 优雅关闭:等待进行中的请求完成后再退出,并 flush 队列日志 + termCh := make(chan os.Signal, 1) + signal.Notify(termCh, syscall.SIGINT, syscall.SIGTERM) + logclient.Info("waf", fmt.Sprintf("服务启动 listen=%s upstream=%s rules=%s pid=%d", cfg.Listen, cfg.Upstream, cfg.RulesDir, os.Getpid()), nil) - if err := server.ListenAndServeTLS(cfg.TLSCert, cfg.TLSKey); err != nil { - logclient.Error("waf", fmt.Sprintf("TLS 监听失败: %v", err), nil) + errCh := make(chan error, 1) + go func() { + errCh <- server.ListenAndServeTLS(cfg.TLSCert, cfg.TLSKey) + }() + + select { + case err := <-errCh: + if err != nil && err != http.ErrServerClosed { + logclient.Error("waf", fmt.Sprintf("TLS 监听失败: %v", err), nil) + logclient.Flush() + os.Exit(1) + } + case <-termCh: + logclient.Info("waf", "收到终止信号,开始优雅关闭", nil) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + logclient.Error("waf", fmt.Sprintf("优雅关闭超时: %v", err), nil) + } logclient.Flush() - os.Exit(1) } }