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:
1 parent
bcec529761
commit
dd9f8e51ff
6 files changed
+143
-45
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,并将关键输入推送到日志库。
|
||||
|
||||
Reference in new issue
Block a user