fix(waf): 修复P3安全审计缺陷
P3-1: logclient 复用 TCP 长连接,失败时重建,消除 TIME_WAIT 堆积 P3-2: TLS 显式限定 CipherSuites + CurvePreferences,排除弱加密套件 P3-3: CheckInput query 编码改用 url.Parse + Query().Encode() 修复率 76% -> 94%(P3 全部清零,仅剩 P0-3 为设计依赖)
This commit is contained in:
1 parent
dd9f8e51ff
commit
00ce3eac6a
5 files changed
+86
-33
No files matched your search
@@ -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% |
|
| P0 | 3 | 2 | 1 | 67% |
|
||||||
| P1 | 5 | 5 | 0 | 100% |
|
| P1 | 5 | 5 | 0 | 100% |
|
||||||
| P2 | 6 | 6 | 0 | 100% |
|
| P2 | 6 | 6 | 0 | 100% |
|
||||||
| P3 | 3 | 0 | 3 | 0% |
|
| P3 | 3 | 3 | 0 | 100% |
|
||||||
| **合计** | **17** | **13** | **4** | **76%** |
|
| **合计** | **17** | **16** | **1** | **94%** |
|
||||||
|
|
||||||
### 逐条验证
|
### 逐条验证
|
||||||
|
|
||||||
@@ -365,9 +365,9 @@ ServeHTTP 中 defer 只关闭了 WAF 事务 `tx`,但没有显式关闭/耗尽
|
|||||||
| P2-4 | ✅ 已修复 | `proxy.go` L254-269 完整实现 `Hijack()` |
|
| P2-4 | ✅ 已修复 | `proxy.go` L254-269 完整实现 `Hijack()` |
|
||||||
| P2-5 | ✅ 已修复 | `logclient.go` L233 `fmt.Fprintf(os.Stderr, ...)` |
|
| P2-5 | ✅ 已修复 | `logclient.go` L233 `fmt.Fprintf(os.Stderr, ...)` |
|
||||||
| P2-6 | ✅ 已修复 | `proxy.go` L93-97 defer 中 drain + close |
|
| P2-6 | ✅ 已修复 | `proxy.go` L93-97 defer 中 drain + close |
|
||||||
| P3-1 | ❌ 未修复 | `logclient.go` 每条日志 `net.DialTimeout` 短连接(TIME_WAIT) |
|
| P3-1 | ✅ 已修复 | `logclient.go` pymsgCall 改为方法,复用长连接,失败时重建一次 |
|
||||||
| P3-2 | ❌ 未修复 | `main.go` L120-122 仅设置 MinVersion,未限定 CipherSuites |
|
| P3-2 | ✅ 已修复 | `main.go` L120-142 显式限定 CipherSuites + CurvePreferences |
|
||||||
| P3-3 | ❌ 未修复 | `engine.go` CheckInput 编码逻辑未变(CLI 工具,不影响线上) |
|
| P3-3 | ✅ 已修复 | `engine.go` CheckInput 改用 `url.Parse` + `Query().Encode()` 重构编码 |
|
||||||
|
|
||||||
### 二次修复说明(2026-08-08)
|
### 二次修复说明(2026-08-08)
|
||||||
|
|
||||||
@@ -381,4 +381,10 @@ ServeHTTP 中 defer 只关闭了 WAF 事务 `tx`,但没有显式关闭/耗尽
|
|||||||
- **P2-2**:logclient db 地址支持环境变量 `LOG_DB_ADDR`(默认 `127.0.0.1:8005`),非标准部署可配置。
|
- **P2-2**:logclient db 地址支持环境变量 `LOG_DB_ADDR`(默认 `127.0.0.1:8005`),非标准部署可配置。
|
||||||
- **P2-5**:日志 INSERT 失败由 `fmt.Printf`(stdout)改为 `fmt.Fprintf(os.Stderr, ...)`,带时间戳/级别可被守护进程捕获。
|
- **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、嵌套结构等边界情况。
|
||||||
+7
-2
@@ -337,15 +337,20 @@ WAF 支持透明代理 WebSocket 连接(如 aibox 的 WebSocket 接口)。
|
|||||||
- **响应体检测未启用**(未 `ProcessResponseBody`,缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。
|
- **响应体检测未启用**(未 `ProcessResponseBody`,缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。
|
||||||
- **WebSocket 帧内容不检测**:升级后二进制帧直接转发,不经过 WAF 规则引擎(适用于内部可信服务如 aibox)。
|
- **WebSocket 帧内容不检测**:升级后二进制帧直接转发,不经过 WAF 规则引擎(适用于内部可信服务如 aibox)。
|
||||||
- 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。
|
- 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。
|
||||||
- TLS 仅设 `MinVersion: TLS1.2`,未限定 cipher suites,允许弱加密套件。
|
|
||||||
- `X-Forwarded-For` 直接覆盖原值(`Header.Set`),未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。
|
- `X-Forwarded-For` 直接覆盖原值(`Header.Set`),未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。
|
||||||
- CSP 仍含 `'unsafe-inline'`(为兼容 forgot.html 与 games/*.html 的内联脚本),待前端清理后可收紧。
|
- CSP 仍含 `'unsafe-inline'`(为兼容 forgot.html 与 games/*.html 的内联脚本),待前端清理后可收紧。
|
||||||
- HSTS 已启用(半年期,不含子域),站点全量 HTTPS 覆盖后可按需收紧。
|
- HSTS 已启用(半年期,不含子域),站点全量 HTTPS 覆盖后可按需收紧。
|
||||||
- logclient 日志推送每次新建 TCP 短连接,高并发拦截场景可能产生较多 TIME_WAIT(已支持通过环境变量 `LOG_DB_ADDR` 配置 db 地址)。
|
|
||||||
- **默认规则全部注释**:`rules/local/custom.conf` 中自定义规则默认注释,需手动取消注释或拉取 OWASP CRS 后才有实际拦截效果。启动时无 warning 提示。**注意:CRS 规则目录不入库,需先在部署机执行 `_fetch_crs.sh`;未拉取 CRS 时 WAF 开箱无拦截能力。**
|
- **默认规则全部注释**:`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 安全修复
|
### v1.4(2026-08-08)— P1/P2 安全修复
|
||||||
|
|
||||||
- **fix(P1-1)**: IPv6 地址解析改用 `net.SplitHostPort`,修复 `[::1]:54321` 等 IPv6 地址在 X-Forwarded-For 中带方括号导致 gateway 限流失效
|
- **fix(P1-1)**: IPv6 地址解析改用 `net.SplitHostPort`,修复 `[::1]:54321` 等 IPv6 地址在 X-Forwarded-For 中带方括号导致 gateway 限流失效
|
||||||
|
|||||||
@@ -136,28 +136,16 @@ func (e *Engine) CheckInput(input string) []int {
|
|||||||
uri = "/" + uri
|
uri = "/" + uri
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分离 path 与 query,对 query 做合法 URL 编码(空格等非法字符需转义)
|
// 用 net/url 解析并重新编码 query,正确处理无值 key、重复 key、特殊字符
|
||||||
// 这样 coraza ProcessURI 内部的 url.ParseRequestURI 不会失败,ARGS 才能被正确提取
|
// 这样 coraza ProcessURI 内部的 url.ParseRequestURI 不会失败,ARGS 才能被正确提取
|
||||||
fullURI := uri
|
if u, err := url.Parse(uri); err == nil && u.RawQuery != "" {
|
||||||
if idx := strings.Index(uri, "?"); idx >= 0 {
|
u.RawQuery = u.Query().Encode()
|
||||||
path := uri[:idx]
|
uri = u.String()
|
||||||
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, "&")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 照搬 coraza/http/middleware.go 的 processRequest 流程
|
// 照搬 coraza/http/middleware.go 的 processRequest 流程
|
||||||
tx.ProcessConnection("127.0.0.1", 8080, "127.0.0.1", 12345)
|
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.AddRequestHeader("Host", "localhost")
|
||||||
tx.SetServerName("localhost")
|
tx.SetServerName("localhost")
|
||||||
if it := tx.ProcessRequestHeaders(); it != nil {
|
if it := tx.ProcessRequestHeaders(); it != nil {
|
||||||
|
|||||||
@@ -63,12 +63,14 @@ type dbV2Response struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Client 异步批量日志推送客户端。
|
// Client 异步批量日志推送客户端。
|
||||||
|
// send 仅在 flushLoop 单 goroutine 中调用,conn 复用无需额外加锁。
|
||||||
type Client struct {
|
type Client struct {
|
||||||
ch chan Entry
|
ch chan Entry
|
||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
stopped bool
|
stopped bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
conn net.Conn // 复用的 pymsg 长连接,失败时重建
|
||||||
}
|
}
|
||||||
|
|
||||||
// global 全局单例。
|
// global 全局单例。
|
||||||
@@ -222,8 +224,8 @@ func (c *Client) sendOne(entry Entry, ts string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// pymsg 协议:4 字节 big-endian 长度 + UTF-8 载荷
|
// pymsg 协议:4 字节 big-endian 长度 + UTF-8 载荷(复用长连接,失败时重连一次)
|
||||||
resp, err := pymsgCall(data)
|
resp, err := c.pymsgCall(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return // fail-safe
|
return // fail-safe
|
||||||
}
|
}
|
||||||
@@ -234,14 +236,24 @@ func (c *Client) sendOne(entry Entry, ts string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// pymsgCall 发送一条 pymsg 请求并读取响应(短连接模式)。
|
// pymsgCall 复用长连接发送一条 pymsg 请求并读取响应。
|
||||||
func pymsgCall(payload []byte) ([]byte, error) {
|
// 首次失败时关闭旧连接重建一次,避免高并发拦截场景下大量 TIME_WAIT 短连接。
|
||||||
conn, err := net.DialTimeout("tcp", dbAddr, socketTimeout)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
conn.SetDeadline(time.Now().Add(socketTimeout))
|
conn.SetDeadline(time.Now().Add(socketTimeout))
|
||||||
|
|
||||||
// 发送:4 字节长度前缀 + 载荷
|
// 发送:4 字节长度前缀 + 载荷
|
||||||
@@ -270,6 +282,27 @@ func pymsgCall(payload []byte) ([]byte, error) {
|
|||||||
return resp, nil
|
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() {
|
func (c *Client) flush() {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
if c.stopped {
|
if c.stopped {
|
||||||
@@ -281,4 +314,5 @@ func (c *Client) flush() {
|
|||||||
|
|
||||||
close(c.stop)
|
close(c.stop)
|
||||||
c.wg.Wait()
|
c.wg.Wait()
|
||||||
|
c.closeConn()
|
||||||
}
|
}
|
||||||
+20
@@ -119,6 +119,26 @@ func main() {
|
|||||||
MaxHeaderBytes: 1 << 20, // 1MB
|
MaxHeaderBytes: 1 << 20, // 1MB
|
||||||
TLSConfig: &tls.Config{
|
TLSConfig: &tls.Config{
|
||||||
MinVersion: tls.VersionTLS12,
|
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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user