fix(waf): 修复P1/P2安全审计缺陷

P1-1: IPv6 地址解析改用 net.SplitHostPort,修复 XFF 带方括号致限流失效
P1-2: 捕获 ProcessResponseHeaders 返回值,命中记录 WARNING 告警
P1-4: 新增 SIGINT/SIGTERM 优雅关闭 + logclient.Flush()
P1-5: 标准日志输出到 stderr,保留 panic 堆栈
P2-1: 规则文件加载前校验大小(1 MiB 上限)防 OOM
P2-2: logclient db 地址支持 LOG_DB_ADDR 环境变量
P2-5: 日志失败输出到 stderr 而非 stdout

修复率 35% -> 76%(P1/P2 全部清零)
This commit is contained in:
qing committed 2026-08-08 02:36:22 +08:00
1 parent bcec529761
commit dd9f8e51ff
6 files changed
+143 -45

No files matched your search

+14 -5
View File
@@ -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)
+34
View File
@@ -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 {
+12 -2
View File
@@ -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)
}
}
+22 -12
View File
@@ -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,并将关键输入推送到日志库。
+31 -10
View File
@@ -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)
}
}