增加waf模块
This commit is contained in:
1 parent
fbccc5d909
commit
202f2804b2
22 files changed
+1097
-17
No files matched your search
@@ -0,0 +1,264 @@
|
||||
// Package engine 封装 coraza WAF,提供规则加载、热加载、规则列举与单条输入检测。
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/corazawaf/coraza/v3"
|
||||
"github.com/corazawaf/coraza/v3/types"
|
||||
)
|
||||
|
||||
// Engine 持有当前生效的 WAF 实例,支持通过 Reload 热替换。
|
||||
type Engine struct {
|
||||
mu sync.RWMutex
|
||||
waf coraza.WAF
|
||||
cfg *Config
|
||||
}
|
||||
|
||||
// Config 引擎配置。
|
||||
type Config struct {
|
||||
// RulesDir 规则根目录,主入口文件为 <RulesDir>/waf.conf
|
||||
RulesDir string
|
||||
// ErrorCallback 规则加载/解析错误回调
|
||||
ErrorCallback func(mr types.MatchedRule)
|
||||
}
|
||||
|
||||
// New 创建引擎并首次加载规则。
|
||||
func New(cfg *Config) (*Engine, error) {
|
||||
e := &Engine{cfg: cfg}
|
||||
if err := e.Reload(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// Reload 重新加载规则文件,原子替换内部 WAF 实例。
|
||||
// 加载顺序:
|
||||
// 1. <RulesDir>/waf.conf 基础配置
|
||||
// 2. <RulesDir>/local/*.conf 本地自定义规则(按文件名字母序)
|
||||
// 3. <RulesDir>/crs/coraza.conf OWASP CRS 入口(若存在)
|
||||
// 4. <RulesDir>/crs/rules/*.conf OWASP CRS 规则(按文件名字母序)
|
||||
//
|
||||
// 不使用 coraza 的 Include 指令(其路径相对于 cwd 解析易出错),
|
||||
// 改为 engine 自己扫描目录逐个 WithDirectivesFromFile 加载。
|
||||
// 失败时保留旧实例不动,确保服务可用。
|
||||
func (e *Engine) Reload() error {
|
||||
mainFile := filepath.Join(e.cfg.RulesDir, "waf.conf")
|
||||
if _, err := os.Stat(mainFile); err != nil {
|
||||
return fmt.Errorf("主规则文件不存在: %w", err)
|
||||
}
|
||||
|
||||
builder := coraza.NewWAFConfig()
|
||||
if e.cfg.ErrorCallback != nil {
|
||||
builder = builder.WithErrorCallback(e.cfg.ErrorCallback)
|
||||
}
|
||||
|
||||
// 1. 主配置
|
||||
builder = builder.WithDirectivesFromFile(mainFile)
|
||||
|
||||
// 2. 本地规则(local/*.conf,按字母序)
|
||||
localFiles, _ := filepath.Glob(filepath.Join(e.cfg.RulesDir, "local", "*.conf"))
|
||||
for _, f := range localFiles {
|
||||
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 {
|
||||
builder = builder.WithDirectivesFromFile(crsEntry)
|
||||
crsRuleFiles, _ := filepath.Glob(filepath.Join(e.cfg.RulesDir, "crs", "rules", "*.conf"))
|
||||
for _, f := range crsRuleFiles {
|
||||
builder = builder.WithDirectivesFromFile(f)
|
||||
}
|
||||
}
|
||||
|
||||
newWAF, err := coraza.NewWAF(builder)
|
||||
if err != nil {
|
||||
return fmt.Errorf("规则加载失败: %w", err)
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
e.waf = newWAF
|
||||
e.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewTransaction 创建一个事务,由调用方 Close。
|
||||
func (e *Engine) NewTransaction() types.Transaction {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
return e.waf.NewTransaction()
|
||||
}
|
||||
|
||||
// CheckInput 用当前规则测试单个输入字符串,返回命中的规则 ID 列表。
|
||||
// 极客 CLI 用:waf -check "/?id=1' UNION SELECT * from users"
|
||||
// 输入按原始未编码字符串处理,内部对 query 做 URL 编码以符合 coraza ParseRequestURI 要求。
|
||||
func (e *Engine) CheckInput(input string) []int {
|
||||
tx := e.NewTransaction()
|
||||
defer tx.Close()
|
||||
|
||||
uri := input
|
||||
if !strings.HasPrefix(uri, "/") {
|
||||
uri = "/" + uri
|
||||
}
|
||||
|
||||
// 分离 path 与 query,对 query 做合法 URL 编码(空格等非法字符需转义)
|
||||
// 这样 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, "&")
|
||||
}
|
||||
|
||||
// 照搬 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.AddRequestHeader("Host", "localhost")
|
||||
tx.SetServerName("localhost")
|
||||
if it := tx.ProcessRequestHeaders(); it != nil {
|
||||
return []int{it.RuleID}
|
||||
}
|
||||
if it, _ := tx.ProcessRequestBody(); it != nil {
|
||||
return []int{it.RuleID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListRules 扫描规则目录,返回所有 SecRule 的 ID 与原始行。
|
||||
// 仅做文本扫描,不解析语义——极客用 grep 友好。
|
||||
// 处理 `\` 续行:以 `\` 结尾的行与下一行合并后再抽取 id。
|
||||
func (e *Engine) ListRules() ([]Rule, error) {
|
||||
var rules []Rule
|
||||
err := filepath.Walk(e.cfg.RulesDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
if !strings.HasSuffix(path, ".conf") {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(e.cfg.RulesDir, path)
|
||||
|
||||
// 先合并续行,保留每条逻辑行在原文件的起始行号
|
||||
type logicalLine struct {
|
||||
startLine int
|
||||
text string
|
||||
}
|
||||
var lines []logicalLine
|
||||
rawLines := strings.Split(string(data), "\n")
|
||||
for i := 0; i < len(rawLines); i++ {
|
||||
start := i + 1
|
||||
cur := strings.TrimRight(rawLines[i], "\r")
|
||||
// 合并以 \ 结尾的续行
|
||||
for strings.HasSuffix(cur, "\\") && i+1 < len(rawLines) {
|
||||
cur = cur[:len(cur)-1] + " " + strings.TrimRight(rawLines[i+1], "\r")
|
||||
i++
|
||||
}
|
||||
lines = append(lines, logicalLine{startLine: start, text: cur})
|
||||
}
|
||||
|
||||
for _, ll := range lines {
|
||||
line := strings.TrimSpace(ll.text)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if id, ok := extractRuleID(line); ok {
|
||||
rules = append(rules, Rule{
|
||||
ID: id,
|
||||
File: rel,
|
||||
Line: ll.startLine,
|
||||
Source: line,
|
||||
})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return rules, err
|
||||
}
|
||||
|
||||
// Rule 描述一条 SecRule 的位置信息。
|
||||
type Rule struct {
|
||||
ID int `json:"id"`
|
||||
File string `json:"file"`
|
||||
Line int `json:"line"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// extractRuleID 从一行 SecRule 中抠出 id:nnnn 字段。
|
||||
// 形如: SecRule REQUEST_URI "@rx (?i)union.*select" "id:100001,phase:1,deny"
|
||||
func extractRuleID(line string) (int, bool) {
|
||||
idx := strings.Index(line, "id:")
|
||||
if idx < 0 {
|
||||
return 0, false
|
||||
}
|
||||
rest := line[idx+3:]
|
||||
end := 0
|
||||
for end < len(rest) {
|
||||
c := rest[end]
|
||||
if c < '0' || c > '9' {
|
||||
break
|
||||
}
|
||||
end++
|
||||
}
|
||||
if end == 0 {
|
||||
return 0, false
|
||||
}
|
||||
var id int
|
||||
fmt.Sscanf(rest[:end], "%d", &id)
|
||||
return id, true
|
||||
}
|
||||
|
||||
// TestFile 仅尝试加载单个规则文件,验证语法。
|
||||
// 通过临时拼一个 Include 实现,目标文件转绝对路径以适配 coraza 解析目录。
|
||||
func TestFile(file string) error {
|
||||
abs, err := filepath.Abs(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(abs); err != nil {
|
||||
return fmt.Errorf("文件不存在: %w", err)
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp("", "waf-test-*.conf")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
_, err = tmp.WriteString(fmt.Sprintf("Include %s\n", abs))
|
||||
tmp.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
builder := coraza.NewWAFConfig().WithDirectivesFromFile(tmp.Name())
|
||||
_, err = coraza.NewWAF(builder)
|
||||
if err != nil {
|
||||
return fmt.Errorf("语法错误: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogError 默认错误回调,输出到 stderr。
|
||||
func LogError(mr types.MatchedRule) {
|
||||
fmt.Fprintf(os.Stderr, "[coraza] rule_id=%d severity=%s msg=%s\n",
|
||||
mr.Rule().ID(), mr.Rule().Severity(), mr.ErrorLog())
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Package proxy 封装到 gateway 的反向代理,并在请求/响应阶段接入 WAF 事务。
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/micro_servers/waf/internal/engine"
|
||||
)
|
||||
|
||||
// Proxy 持有 ReverseProxy 与 WAF 引擎引用。
|
||||
type Proxy struct {
|
||||
rp *httputil.ReverseProxy
|
||||
waf *engine.Engine
|
||||
}
|
||||
|
||||
// New 创建代理。upstream 形如 "http://127.0.0.1:8443"。
|
||||
func New(upstream string, waf *engine.Engine) (*Proxy, error) {
|
||||
target, err := url.Parse(upstream)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rp := httputil.NewSingleHostReverseProxy(target)
|
||||
p := &Proxy{rp: rp, waf: waf}
|
||||
|
||||
// 自定义 Director:保留原 Host / X-Forwarded-For
|
||||
origDirector := rp.Director
|
||||
rp.Director = func(req *http.Request) {
|
||||
origDirector(req)
|
||||
req.Header.Set("X-Forwarded-For", req.RemoteAddr)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
}
|
||||
// 透传错误,不暴露内部堆栈
|
||||
rp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
log.Printf("[proxy] upstream_error ip=%s path=%s err=%v", r.RemoteAddr, r.URL.Path, err)
|
||||
http.Error(w, "upstream unavailable", http.StatusBadGateway)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ServeHTTP 实现 http.Handler。
|
||||
// 流程:ProcessRequest → 命中则 403 → 否则转发 → ProcessResponseHeaders。
|
||||
func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
tx := p.waf.NewTransaction()
|
||||
defer func() {
|
||||
tx.ProcessLogging()
|
||||
_ = tx.Close()
|
||||
}()
|
||||
|
||||
// 解析 RemoteAddr 中的 IP 与端口
|
||||
client := r.RemoteAddr
|
||||
cport := 0
|
||||
if idx := strings.LastIndexByte(r.RemoteAddr, ':'); idx != -1 {
|
||||
client = r.RemoteAddr[:idx]
|
||||
cport, _ = strconv.Atoi(r.RemoteAddr[idx+1:])
|
||||
}
|
||||
|
||||
// 请求阶段:照搬 coraza/http/middleware.go 的 processRequest 流程
|
||||
tx.ProcessConnection(client, cport, "", 0)
|
||||
tx.ProcessURI(r.URL.String(), r.Method, r.Proto)
|
||||
for k, vs := range r.Header {
|
||||
for _, v := range vs {
|
||||
tx.AddRequestHeader(k, v)
|
||||
}
|
||||
}
|
||||
if r.Host != "" {
|
||||
tx.AddRequestHeader("Host", r.Host)
|
||||
tx.SetServerName(r.Host)
|
||||
}
|
||||
if it := tx.ProcessRequestHeaders(); it != nil {
|
||||
p.block(w, r, it.RuleID, start)
|
||||
return
|
||||
}
|
||||
|
||||
// 请求体:读全量喂给 coraza,再用 buffer 恢复 r.Body 给 proxy 转发
|
||||
if r.Body != nil && r.Body != http.NoBody {
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
if err == nil {
|
||||
_, _, _ = tx.ReadRequestBodyFrom(bytes.NewReader(bodyBytes))
|
||||
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
}
|
||||
if it, _ := tx.ProcessRequestBody(); it != nil {
|
||||
p.block(w, r, it.RuleID, start)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 包装 ResponseWriter 以捕获状态码与响应头
|
||||
rw := &responseRecorder{ResponseWriter: w, status: 200}
|
||||
p.rp.ServeHTTP(rw, r)
|
||||
|
||||
// 响应阶段:把 upstream 的 header 喂给 WAF
|
||||
for k, vs := range rw.headers {
|
||||
for _, v := range vs {
|
||||
tx.AddResponseHeader(k, v)
|
||||
}
|
||||
}
|
||||
tx.ProcessResponseHeaders(rw.status, "HTTP/1.1")
|
||||
|
||||
log.Printf("[proxy] pass ip=%s method=%s path=%s status=%d rule=- cost=%s",
|
||||
r.RemoteAddr, r.Method, r.URL.Path, rw.status, time.Since(start))
|
||||
}
|
||||
|
||||
// block 命中规则时统一返回 403,并打 JSON 日志便于 jq 分析。
|
||||
func (p *Proxy) block(w http.ResponseWriter, r *http.Request, ruleID int, start time.Time) {
|
||||
rec := map[string]any{
|
||||
"ts": time.Now().UTC().Format(time.RFC3339),
|
||||
"action": "block",
|
||||
"rule_id": ruleID,
|
||||
"ip": r.RemoteAddr,
|
||||
"method": r.Method,
|
||||
"path": r.URL.Path,
|
||||
"query": r.URL.RawQuery,
|
||||
"ua": r.UserAgent(),
|
||||
"cost_ms": time.Since(start).Milliseconds(),
|
||||
}
|
||||
if raw, err := json.Marshal(rec); err == nil {
|
||||
log.Println(string(raw))
|
||||
}
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte("blocked by WAF\n"))
|
||||
}
|
||||
|
||||
// responseRecorder 记录状态码与响应头,供响应阶段 WAF 检测使用。
|
||||
type responseRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
headers http.Header
|
||||
}
|
||||
|
||||
func (r *responseRecorder) WriteHeader(code int) {
|
||||
r.status = code
|
||||
if r.headers == nil {
|
||||
r.headers = http.Header{}
|
||||
}
|
||||
// 拷贝上游响应头
|
||||
for k, vs := range r.Header() {
|
||||
r.headers[k] = vs
|
||||
}
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (r *responseRecorder) Flush() {
|
||||
if f, ok := r.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user