diff --git a/docs/security/waf_SECURITY_AUDIT_20260801.md b/docs/security/waf_SECURITY_AUDIT_20260801.md index 8114a91..c16594e 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-08(二次验证,P1/P2 全量修复) +**验证日期**: 2026-08-08(三次验证,P3 全量修复) **验证方式**: 逐条对照审计报告与当前代码 ### 验证结果汇总 @@ -344,8 +344,8 @@ ServeHTTP 中 defer 只关闭了 WAF 事务 `tx`,但没有显式关闭/耗尽 | P0 | 3 | 2 | 1 | 67% | | P1 | 5 | 5 | 0 | 100% | | P2 | 6 | 6 | 0 | 100% | -| P3 | 3 | 0 | 3 | 0% | -| **合计** | **17** | **13** | **4** | **76%** | +| P3 | 3 | 3 | 0 | 100% | +| **合计** | **17** | **16** | **1** | **94%** | ### 逐条验证 @@ -365,9 +365,9 @@ ServeHTTP 中 defer 只关闭了 WAF 事务 `tx`,但没有显式关闭/耗尽 | P2-4 | ✅ 已修复 | `proxy.go` L254-269 完整实现 `Hijack()` | | P2-5 | ✅ 已修复 | `logclient.go` L233 `fmt.Fprintf(os.Stderr, ...)` | | P2-6 | ✅ 已修复 | `proxy.go` L93-97 defer 中 drain + close | -| P3-1 | ❌ 未修复 | `logclient.go` 每条日志 `net.DialTimeout` 短连接(TIME_WAIT) | -| P3-2 | ❌ 未修复 | `main.go` L120-122 仅设置 MinVersion,未限定 CipherSuites | -| P3-3 | ❌ 未修复 | `engine.go` CheckInput 编码逻辑未变(CLI 工具,不影响线上) | +| P3-1 | ✅ 已修复 | `logclient.go` pymsgCall 改为方法,复用长连接,失败时重建一次 | +| P3-2 | ✅ 已修复 | `main.go` L120-142 显式限定 CipherSuites + CurvePreferences | +| P3-3 | ✅ 已修复 | `engine.go` CheckInput 改用 `url.Parse` + `Query().Encode()` 重构编码 | ### 二次修复说明(2026-08-08) @@ -381,4 +381,10 @@ ServeHTTP 中 defer 只关闭了 WAF 事务 `tx`,但没有显式关闭/耗尽 - **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(低优先级优化)。 +### 三次修复说明(2026-08-08) + +本轮修复 P3 全部 3 项,P3 清零,修复率 76% → 94%。仅剩 P0-3(默认规则注释,设计上依赖拉取 CRS 生效,非代码缺陷)。 + +- **P3-1**:`pymsgCall` 由包级函数改为 `Client` 方法,复用 `c.conn` 长连接;首次发送失败时 `closeConn` 后重建一次,避免高并发拦截场景下每条日志新建 TCP 短连接导致 TIME_WAIT 堆积。`send` 仅在 `flushLoop` 单 goroutine 调用,无需额外加锁。 +- **P3-2**:`TLSConfig` 显式限定 `CipherSuites`(TLS 1.3 三套 + TLS 1.2 ECDHE 六套,全部 AEAD + 前向保密)与 `CurvePreferences`(X25519/P256/P384),排除 RC4/3DES/CBC 等弱加密套件。 +- **P3-3**:`CheckInput` 的 query 编码逻辑由手动 `Split("&")` + `Index("=")` 改为 `url.Parse` + `Query().Encode()`,正确处理无值 key、重复 key、嵌套结构等边界情况。 diff --git a/waf/README.md b/waf/README.md index 22132bc..4691d46 100644 --- a/waf/README.md +++ b/waf/README.md @@ -337,15 +337,20 @@ WAF 支持透明代理 WebSocket 连接(如 aibox 的 WebSocket 接口)。 - **响应体检测未启用**(未 `ProcessResponseBody`,缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。 - **WebSocket 帧内容不检测**:升级后二进制帧直接转发,不经过 WAF 规则引擎(适用于内部可信服务如 aibox)。 - 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。 -- TLS 仅设 `MinVersion: TLS1.2`,未限定 cipher suites,允许弱加密套件。 - `X-Forwarded-For` 直接覆盖原值(`Header.Set`),未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。 - CSP 仍含 `'unsafe-inline'`(为兼容 forgot.html 与 games/*.html 的内联脚本),待前端清理后可收紧。 - HSTS 已启用(半年期,不含子域),站点全量 HTTPS 覆盖后可按需收紧。 -- logclient 日志推送每次新建 TCP 短连接,高并发拦截场景可能产生较多 TIME_WAIT(已支持通过环境变量 `LOG_DB_ADDR` 配置 db 地址)。 - **默认规则全部注释**:`rules/local/custom.conf` 中自定义规则默认注释,需手动取消注释或拉取 OWASP CRS 后才有实际拦截效果。启动时无 warning 提示。**注意:CRS 规则目录不入库,需先在部署机执行 `_fetch_crs.sh`;未拉取 CRS 时 WAF 开箱无拦截能力。** ## 版本日志 +### v1.5(2026-08-08)— P3 安全修复 + +- **fix(P3-1)**: logclient 复用 TCP 长连接,失败时重建一次,消除高并发拦截场景下的 TIME_WAIT 短连接堆积 +- **fix(P3-2)**: TLS 显式限定 CipherSuites(TLS 1.3 三套 + TLS 1.2 ECDHE 六套,全部 AEAD + 前向保密)与 CurvePreferences(X25519/P256/P384),排除弱加密套件 +- **fix(P3-3)**: CheckInput 的 query 编码改用 `url.Parse` + `Query().Encode()`,正确处理无值 key、重复 key、嵌套结构 +- 修复率从 76% 提升至 94%(P3 全部修复,仅剩 P0-3 默认规则注释为设计依赖) + ### v1.4(2026-08-08)— P1/P2 安全修复 - **fix(P1-1)**: IPv6 地址解析改用 `net.SplitHostPort`,修复 `[::1]:54321` 等 IPv6 地址在 X-Forwarded-For 中带方括号导致 gateway 限流失效 diff --git a/waf/internal/engine/engine.go b/waf/internal/engine/engine.go index ae46f80..1bdcfe7 100644 --- a/waf/internal/engine/engine.go +++ b/waf/internal/engine/engine.go @@ -136,28 +136,16 @@ func (e *Engine) CheckInput(input string) []int { uri = "/" + uri } - // 分离 path 与 query,对 query 做合法 URL 编码(空格等非法字符需转义) + // 用 net/url 解析并重新编码 query,正确处理无值 key、重复 key、特殊字符 // 这样 coraza ProcessURI 内部的 url.ParseRequestURI 不会失败,ARGS 才能被正确提取 - fullURI := uri - if idx := strings.Index(uri, "?"); idx >= 0 { - path := uri[:idx] - rawQuery := uri[idx+1:] - // 用 net/url 编码 query:保留 & = ,其他特殊字符转义 - var parts []string - for _, kv := range strings.Split(rawQuery, "&") { - eq := strings.Index(kv, "=") - if eq < 0 { - parts = append(parts, url.QueryEscape(kv)) - } else { - parts = append(parts, url.QueryEscape(kv[:eq])+"="+url.QueryEscape(kv[eq+1:])) - } - } - fullURI = path + "?" + strings.Join(parts, "&") + if u, err := url.Parse(uri); err == nil && u.RawQuery != "" { + u.RawQuery = u.Query().Encode() + uri = u.String() } // 照搬 coraza/http/middleware.go 的 processRequest 流程 tx.ProcessConnection("127.0.0.1", 8080, "127.0.0.1", 12345) - tx.ProcessURI(fullURI, "GET", "HTTP/1.1") + tx.ProcessURI(uri, "GET", "HTTP/1.1") tx.AddRequestHeader("Host", "localhost") tx.SetServerName("localhost") if it := tx.ProcessRequestHeaders(); it != nil { diff --git a/waf/internal/logclient/logclient.go b/waf/internal/logclient/logclient.go index 1faa267..8523b60 100644 --- a/waf/internal/logclient/logclient.go +++ b/waf/internal/logclient/logclient.go @@ -63,12 +63,14 @@ type dbV2Response struct { } // Client 异步批量日志推送客户端。 +// send 仅在 flushLoop 单 goroutine 中调用,conn 复用无需额外加锁。 type Client struct { ch chan Entry stop chan struct{} wg sync.WaitGroup stopped bool mu sync.Mutex + conn net.Conn // 复用的 pymsg 长连接,失败时重建 } // global 全局单例。 @@ -222,8 +224,8 @@ func (c *Client) sendOne(entry Entry, ts string) { return } - // pymsg 协议:4 字节 big-endian 长度 + UTF-8 载荷 - resp, err := pymsgCall(data) + // pymsg 协议:4 字节 big-endian 长度 + UTF-8 载荷(复用长连接,失败时重连一次) + resp, err := c.pymsgCall(data) if err != nil { return // fail-safe } @@ -234,14 +236,24 @@ func (c *Client) sendOne(entry Entry, ts string) { } } -// pymsgCall 发送一条 pymsg 请求并读取响应(短连接模式)。 -func pymsgCall(payload []byte) ([]byte, error) { - conn, err := net.DialTimeout("tcp", dbAddr, socketTimeout) +// pymsgCall 复用长连接发送一条 pymsg 请求并读取响应。 +// 首次失败时关闭旧连接重建一次,避免高并发拦截场景下大量 TIME_WAIT 短连接。 +func (c *Client) pymsgCall(payload []byte) ([]byte, error) { + resp, err := c.doPymsg(payload) + if err == nil { + return resp, nil + } + // 旧连接可能已断开(db 重启等),关闭后重连一次 + c.closeConn() + return c.doPymsg(payload) +} + +// doPymsg 在当前连接上执行一次请求-响应交互。 +func (c *Client) doPymsg(payload []byte) ([]byte, error) { + conn, err := c.getConn() if err != nil { return nil, err } - defer conn.Close() - conn.SetDeadline(time.Now().Add(socketTimeout)) // 发送:4 字节长度前缀 + 载荷 @@ -270,6 +282,27 @@ func pymsgCall(payload []byte) ([]byte, error) { return resp, nil } +// getConn 返回当前复用连接,不存在时建立。 +func (c *Client) getConn() (net.Conn, error) { + if c.conn != nil { + return c.conn, nil + } + conn, err := net.DialTimeout("tcp", dbAddr, socketTimeout) + if err != nil { + return nil, err + } + c.conn = conn + return conn, nil +} + +// closeConn 关闭并置空复用连接。 +func (c *Client) closeConn() { + if c.conn != nil { + _ = c.conn.Close() + c.conn = nil + } +} + func (c *Client) flush() { c.mu.Lock() if c.stopped { @@ -281,4 +314,5 @@ func (c *Client) flush() { close(c.stop) c.wg.Wait() + c.closeConn() } diff --git a/waf/main.go b/waf/main.go index 2511f29..d5f95c9 100644 --- a/waf/main.go +++ b/waf/main.go @@ -119,6 +119,26 @@ func main() { MaxHeaderBytes: 1 << 20, // 1MB TLSConfig: &tls.Config{ MinVersion: tls.VersionTLS12, + // 显式限定安全 CipherSuites,排除弱加密套件(RC4/3DES/CBC 等) + CipherSuites: []uint16{ + // TLS 1.3 套件(Go 自动选择,显式列出便于审计) + tls.TLS_AES_128_GCM_SHA256, + tls.TLS_AES_256_GCM_SHA384, + tls.TLS_CHACHA20_POLY1305_SHA256, + // TLS 1.2 ECDHE 套件(前向保密 + AEAD) + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, + }, + // 优先使用高性能且安全的椭圆曲线 + CurvePreferences: []tls.CurveID{ + tls.X25519, + tls.CurveP256, + tls.CurveP384, + }, }, }