fix(waf): harden against OOM and Slowloris DoS attacks
- P0: add 10MB hard request body size limit with io.LimitReader, return 413 Request Entity Too Large when exceeded, prevents OOM - P0: configure http.Server timeouts: ReadHeaderTimeout=10s (Slowloris), ReadTimeout=15s, WriteTimeout=30s, IdleTimeout=120s, MaxHeaderBytes=1MB - P1: properly handle body read errors, log and return on client disconnect - P2: drain and close r.Body in defer to ensure TCP connection reuse - Log errors from ReadRequestBodyFrom and ProcessRequestBody - Inject security headers on 413 responses - Add security audit report (docs/SECURITY_AUDIT_20260801.md) - Update README with timeout/body-limit docs and changelog
This commit is contained in:
1 parent
bd544a4181
commit
b4436b192a
4 files changed
+430
-15
No files matched your search
+46
-7
@@ -248,14 +248,36 @@ kill -HUP $(pgrep waf)
|
||||
|
||||
## 请求处理流程
|
||||
|
||||
[proxy.go:81-138](internal/proxy/proxy.go#L81) `ServeHTTP`:
|
||||
[proxy.go:81-172](internal/proxy/proxy.go#L81) `ServeHTTP`:
|
||||
|
||||
1. `ProcessConnection` + `ProcessURI` + 添加请求头(含 Host)
|
||||
2. `ProcessRequestHeaders` → 命中即 `block` 返回 403
|
||||
3. 读全量请求体 → `ReadRequestBodyFrom` → `ProcessRequestBody` → 命中即 block
|
||||
3. 读取请求体(**10MB 硬限制**,超限返回 413)→ `ReadRequestBodyFrom` → `ProcessRequestBody` → 命中即 block
|
||||
4. `httputil.ReverseProxy.ServeHTTP` 转发到 gateway
|
||||
5. 捕获响应头 → `ProcessResponseHeaders`
|
||||
5. 捕获响应头 → `ProcessResponseHeaders`(仅审计日志,不拦截)
|
||||
6. `ModifyResponse` 统一注入安全响应头(覆盖下游同名头)
|
||||
7. defer 中确保 `r.Body` 耗尽并关闭,保证 TCP 连接复用
|
||||
|
||||
### 请求体大小限制
|
||||
|
||||
WAF 在代码层面对请求体实施 **10MB 硬限制**([proxy.go#L19-L22](internal/proxy/proxy.go#L19-L22) `maxRequestBodySize`),与 `waf.conf` 中的 `SecRequestBodyLimit 10485760` 保持一致:
|
||||
|
||||
- 使用 `io.LimitReader` + 多读 1 字节检测超限
|
||||
- 超限直接返回 `413 Request Entity Too Large`,不经过 Coraza 规则引擎
|
||||
- 读取错误(客户端断开)记录日志后直接返回,不继续处理
|
||||
- 从根本上防止超大请求体导致 OOM 崩溃
|
||||
|
||||
### HTTP 服务器超时防护
|
||||
|
||||
[main.go#L111-L122](main.go#L111) 为 `http.Server` 配置了多层超时,防御 Slowloris 等慢连接 DoS 攻击:
|
||||
|
||||
| 参数 | 值 | 作用 |
|
||||
|------|-----|------|
|
||||
| `ReadHeaderTimeout` | 10s | 读取请求头超时,**直接防御 Slowloris** |
|
||||
| `ReadTimeout` | 15s | 读取整个请求(含 body)超时,防慢 POST |
|
||||
| `WriteTimeout` | 30s | 写入响应超时 |
|
||||
| `IdleTimeout` | 120s | keep-alive 空闲连接超时 |
|
||||
| `MaxHeaderBytes` | 1MB | 请求头大小限制 |
|
||||
|
||||
Director 改写([proxy.go:36-46](internal/proxy/proxy.go#L36)):剥离 `RemoteAddr` 端口,设置 `X-Forwarded-For` 与 `X-Forwarded-Proto: https`,供 gateway 的 RateLimiter 取真实客户端 IP。
|
||||
|
||||
@@ -263,14 +285,31 @@ ErrorHandler([proxy.go:48-53](internal/proxy/proxy.go#L48)):upstream 不
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 请求体全量读入内存检测,10MB 上限(`SecRequestBodyLimit`)。大文件上传场景需调大或加白名单。
|
||||
- **响应头检测已启用**(`ProcessResponseHeaders`),**响应体检测未启用**(未 `ProcessResponseBody`,缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。
|
||||
- 响应头检测(`ProcessResponseHeaders`)当前仅做审计日志,命中后不执行拦截(响应头已发送无法撤销状态码)。如需响应头拦截需引入 buffered response writer。
|
||||
- **响应体检测未启用**(未 `ProcessResponseBody`,缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。
|
||||
- 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。
|
||||
- `http.Server` 未设 `ReadTimeout` / `WriteTimeout` / `IdleTimeout`,存在慢连接攻击风险。
|
||||
- TLS 仅设 `MinVersion: TLS1.2`,未限定 cipher suites,允许弱加密套件。
|
||||
- `X-Forwarded-For` 直接覆盖原值,未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。
|
||||
- `X-Forwarded-For` 直接覆盖原值(`Header.Set`),未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。注意:IPv6 地址在 X-Forwarded-For 中可能带方括号(待修复)。
|
||||
- CSP 仍含 `'unsafe-inline'`(为兼容 forgot.html 与 games/*.html 的内联脚本),待前端清理后可收紧。
|
||||
- HSTS 已启用(半年期,不含子域),站点全量 HTTPS 覆盖后可按需收紧。
|
||||
- logclient 日志推送每次新建 TCP 短连接,高并发拦截场景可能产生较多 TIME_WAIT;db 地址硬编码 `127.0.0.1:8005`,不支持环境变量配置。
|
||||
- **默认规则全部注释**:`rules/local/custom.conf` 中自定义规则默认注释,需手动取消注释或拉取 OWASP CRS 后才有实际拦截效果。启动时无 warning 提示。
|
||||
- 缺少 SIGINT/SIGTERM 优雅关闭,进程终止时进行中的请求被暴力中断,logclient 队列中未发送的日志可能丢失。
|
||||
- 标准日志(`log.SetOutput`)被静默到 `io.Discard`,net/http 的 panic 堆栈和 TLS 错误不可见。
|
||||
|
||||
## 版本日志
|
||||
|
||||
### v1.1(2026-08-01)— 安全加固
|
||||
|
||||
- **P0 修复:请求体 OOM 防护**:添加 10MB 硬限制,`io.LimitReader` + 多读1字节检测超限,超限返回 413
|
||||
- **P0 修复:Slowloris DoS 防护**:`http.Server` 添加 ReadHeaderTimeout(10s)/ReadTimeout(15s)/WriteTimeout(30s)/IdleTimeout(120s)/MaxHeaderBytes(1MB)
|
||||
- **P1 修复:请求体读取错误处理**:`io.ReadAll` 失败时记录日志并返回,不再传递出错 body 给 ReverseProxy;`ReadRequestBodyFrom`/`ProcessRequestBody` 错误均记录日志
|
||||
- **P2 修复:r.Body 连接复用**:defer 中耗尽并关闭 r.Body,确保 TCP 连接可复用
|
||||
- 413 响应同样注入安全响应头
|
||||
|
||||
### v1.0
|
||||
|
||||
- 初始版本:TLS 终止 + Coraza WAF 规则匹配 + 反向代理 + SIGHUP 热加载 + 异步日志推送 + 安全响应头注入
|
||||
|
||||
## 依赖
|
||||
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
# WAF 模块安全审计报告
|
||||
|
||||
**审计日期**: 2026-08-01
|
||||
**审计范围**: `/waf/` 全部代码
|
||||
**代码版本**: 当前工作区版本(dev_qing 分支)
|
||||
|
||||
---
|
||||
|
||||
## 修复进度
|
||||
|
||||
| 优先级 | 总数量 | 已修复 | 待修复 |
|
||||
|--------|--------|--------|--------|
|
||||
| **P0 (Critical)** | 3 | 2 | 1 |
|
||||
| **P1 (High)** | 5 | 1 | 4 |
|
||||
| **P2 (Medium)** | 6 | 1 | 5 |
|
||||
| **P3 (Low)** | 3 | 0 | 3 |
|
||||
|
||||
---
|
||||
|
||||
## 问题总览
|
||||
|
||||
| 优先级 | 数量 | 说明 |
|
||||
|--------|------|------|
|
||||
| **P0 (Critical)** | 3 | 可导致服务崩溃、OOM、DoS,必须立即修复 |
|
||||
| **P1 (High)** | 5 | 安全功能缺失、IP 解析错误、功能与文档不符 |
|
||||
| **P2 (Medium)** | 6 | 健壮性、可观测性、配置灵活性问题 |
|
||||
| **P3 (Low)** | 3 | 代码风格、优化建议 |
|
||||
|
||||
---
|
||||
|
||||
## P0 - Critical 严重问题
|
||||
|
||||
### ✅ P0-1: 请求体无大小限制导致 OOM 崩溃(已修复)
|
||||
|
||||
**位置**: `internal/proxy/proxy.go`(原 L114-L125)
|
||||
|
||||
**问题描述**:
|
||||
`waf.conf` 中配置了 `SecRequestBodyLimit 10485760`(10MB),但这只是 **Coraza 内部的解析限制**,并不能阻止 `io.ReadAll` 将整个请求体读入内存。攻击者可以发送超大请求体导致 OOM。
|
||||
|
||||
**修复方案**(2026-08-01):
|
||||
1. 添加常量 `maxRequestBodySize = 10 * 1024 * 1024`(10MB,与 SecRequestBodyLimit 一致)
|
||||
2. 使用 `io.LimitReader(r.Body, maxRequestBodySize+1)` 包装后再读取
|
||||
3. 多读 1 字节用于判断是否超限,超限返回 `413 Request Entity Too Large`
|
||||
4. 同时修复了 P1-3(请求体读取错误被静默忽略)和 P2-6(r.Body 未关闭耗尽)的问题
|
||||
5. 在 defer 中确保 r.Body 被关闭和耗尽,保证 TCP 连接可复用
|
||||
6. 对 ReadRequestBodyFrom 和 ProcessRequestBody 的返回错误添加了日志记录
|
||||
|
||||
---
|
||||
|
||||
### ✅ P0-2: HTTP 服务器无超时设置,存在 Slowloris 攻击风险(已修复)
|
||||
|
||||
**位置**: `main.go`(原 L110-L116)
|
||||
|
||||
**问题描述**:
|
||||
所有超时均为零值(无限等待):
|
||||
- **Slowloris 攻击**: 攻击者以极慢速度发送请求头(每隔几秒发一个字节),可以长时间占用连接,耗尽文件描述符导致服务不可用
|
||||
- **慢 POST 攻击**: 声明大的 Content-Length 但缓慢发送 body,同样耗尽连接
|
||||
- **空闲连接泄漏**: 无 IdleTimeout,已建立的 keep-alive 连接永不超时
|
||||
- **大 Header 攻击**: MaxHeaderBytes 未限制
|
||||
|
||||
这与 pymsg 模块中修复的 recv_timeout 问题是同一类 DoS 漏洞。
|
||||
|
||||
**修复方案**(2026-08-01):
|
||||
添加以下超时和大小限制:
|
||||
- `ReadTimeout: 15s` - 读取整个请求(包括 body)的超时,防止慢 POST
|
||||
- `ReadHeaderTimeout: 10s` - 读取请求头的超时,直接防御 Slowloris
|
||||
- `WriteTimeout: 30s` - 写入响应的超时
|
||||
- `IdleTimeout: 120s` - keep-alive 连接空闲超时
|
||||
- `MaxHeaderBytes: 1<<20` - 请求头最大 1MB,防止大 Header 攻击
|
||||
|
||||
---
|
||||
|
||||
### P0-3: 规则默认全部注释,WAF 启动后无任何防护
|
||||
|
||||
**位置**: `rules/local/custom.conf`
|
||||
|
||||
**问题描述**:
|
||||
所有本地自定义规则(SQL注入、XSS、路径遍历、命令注入、扫描器UA)**全部被注释掉**了。WAF 启动后:
|
||||
- 引擎正常加载
|
||||
- 流量正常转发
|
||||
- **没有任何攻击会被拦截**
|
||||
- 安全团队以为 WAF 在工作,实际上是"裸奔"状态
|
||||
|
||||
README 中没有明确警示用户需要手动启用规则。
|
||||
|
||||
**修复建议**:
|
||||
1. 至少启用一组保守的默认规则(如扫描器UA拦截、明显的SQL注入特征)
|
||||
2. 或者在启动日志中明确警告:"当前未启用任何拦截规则,WAF 处于检测-only模式"
|
||||
3. 在 README 中以醒目标注提示用户需要取消规则注释
|
||||
|
||||
---
|
||||
|
||||
## P1 - High 高危问题
|
||||
|
||||
### P1-1: IPv6 地址解析错误,X-Forwarded-For 包含多余方括号
|
||||
|
||||
**位置**: `internal/proxy/proxy.go#L40-L43` 和 `internal/proxy/proxy.go#L92-L95`
|
||||
|
||||
**问题描述**:
|
||||
```go
|
||||
clientIP := req.RemoteAddr
|
||||
if idx := strings.LastIndexByte(req.RemoteAddr, ':'); idx != -1 {
|
||||
clientIP = req.RemoteAddr[:idx]
|
||||
}
|
||||
```
|
||||
|
||||
使用 `LastIndexByte(':')` 无法正确处理 IPv6 地址:
|
||||
- 对于 IPv4: `1.2.3.4:54321` → 正确得到 `1.2.3.4`
|
||||
- 对于 IPv6: `[::1]:54321` → 错误得到 `[::1]`(含方括号)
|
||||
- 对于 IPv6: `2001:db8::1:54321` → 错误截断
|
||||
|
||||
Go 标准库提供了 `net.SplitHostPort` 专门处理这个问题。Gateway 的 RateLimiter 如果收到带方括号的 IP,会将 `[::1]`、`[::1]:0` 等当成不同的 IP,导致 IPv6 地址下的限流完全失效。
|
||||
|
||||
**修复建议**: 使用 `net.SplitHostPort(req.RemoteAddr)` 替代手动解析。
|
||||
|
||||
---
|
||||
|
||||
### P1-2: 响应头检测只检测不拦截,与文档描述不符
|
||||
|
||||
**位置**: `internal/proxy/proxy.go#L131-L138`
|
||||
|
||||
**问题描述**:
|
||||
```go
|
||||
// 响应阶段:把 upstream 的 header 喂给 WAF
|
||||
for k, vs := range rw.headers {
|
||||
for _, v := range vs {
|
||||
tx.AddResponseHeader(k, v)
|
||||
}
|
||||
}
|
||||
tx.ProcessResponseHeaders(rw.status, "HTTP/1.1") // ← 返回值被丢弃!
|
||||
```
|
||||
|
||||
`ProcessResponseHeaders` 返回 `(MatchedRule, Intervention)`,命中规则时会返回非 nil 的 MatchedRule。但代码完全忽略了返回值,没有任何拦截动作。README 中声称"响应头检测已启用",但实际上只"检测"不"拦截"。
|
||||
|
||||
**修复建议**: 捕获返回值记录高优先级告警,或明确在文档中说明"响应头检测仅做日志审计,不执行拦截"。
|
||||
|
||||
---
|
||||
|
||||
### ✅ P1-3: 请求体读取错误被静默忽略,Body 状态不一致(已修复)
|
||||
|
||||
**位置**: `internal/proxy/proxy.go`(原 L116-L124)
|
||||
|
||||
**问题描述**:
|
||||
原来的代码中 `io.ReadAll` 失败时错误被完全忽略,r.Body 状态不一致,可能导致代理转发挂起;`ReadRequestBodyFrom` 的返回错误也被忽略。
|
||||
|
||||
**修复方案**(2026-08-01,与 P0-1 一并修复):
|
||||
1. `io.ReadAll` 返回错误时,记录 Info 级别日志并直接返回(客户端断开无需继续处理)
|
||||
2. `ReadRequestBodyFrom` 和 `ProcessRequestBody` 返回错误时记录 Warning 日志
|
||||
3. 不再对出错的 body 继续调用 ProcessRequestBody
|
||||
|
||||
---
|
||||
|
||||
### P1-4: 缺少优雅关闭,SIGINT/SIGTERM 时请求被暴力中断
|
||||
|
||||
**位置**: `main.go#L97-L125`
|
||||
|
||||
**问题描述**:
|
||||
当前只处理了 SIGHUP(热加载),没有处理 SIGINT/SIGTERM。进程被终止时:
|
||||
1. 正在处理的请求被强制中断,客户端收到连接重置错误
|
||||
2. logclient 队列中的日志可能丢失(只有在代理初始化失败/TLS监听失败路径才调用 Flush)
|
||||
3. 没有给正在进行的请求完成的宽限期
|
||||
|
||||
**修复建议**: 监听 SIGINT/SIGTERM,调用 `server.Shutdown(ctx)` 优雅关闭,并在退出前 `logclient.Flush()` 刷新剩余日志。
|
||||
|
||||
---
|
||||
|
||||
### P1-5: 标准日志被完全静默,panic 堆栈和运行时错误丢失
|
||||
|
||||
**位置**: `main.go#L88`
|
||||
|
||||
**问题描述**:
|
||||
```go
|
||||
log.SetOutput(io.Discard) // 静默标准 log
|
||||
```
|
||||
|
||||
这一行把 Go 标准库 `log` 的所有输出都丢弃了,包括:
|
||||
- net/http 服务器捕获的 panic 堆栈(请求处理中 panic 时,服务器用 log.Printf 输出堆栈然后返回 502)
|
||||
- net/http 服务器的内部错误(如 TLS 握手错误、连接错误)
|
||||
- Coraza 库内部可能的 log 输出
|
||||
- 任何第三方库使用标准 log 输出的诊断信息
|
||||
|
||||
出现问题时无法排查根因。
|
||||
|
||||
**修复建议**: 将标准日志输出到 stderr(systemd/journalctl 可捕获),而非完全丢弃。
|
||||
|
||||
---
|
||||
|
||||
## P2 - Medium 中危问题
|
||||
|
||||
### P2-1: 规则文件读取无大小限制,超大规则文件导致 OOM
|
||||
|
||||
**位置**: `internal/engine/engine.go#L58-L80` 和 `internal/engine/engine.go#L157`
|
||||
|
||||
**问题描述**:
|
||||
`WithDirectivesFromFile` 和 `os.ReadFile` 都直接读取整个规则文件到内存。与之前 confs 模块修复的问题一样,如果攻击者或错误配置放入 GB 级的 `.conf` 文件,Reload/ListRules 时会导致 OOM。
|
||||
|
||||
**修复建议**: 加载规则文件前用 `os.Stat` 检查大小,设置合理上限(如 1 MiB)。
|
||||
|
||||
---
|
||||
|
||||
### P2-2: logclient 硬编码 db 地址,不支持环境变量配置
|
||||
|
||||
**位置**: `internal/logclient/logclient.go#L24`
|
||||
|
||||
**问题描述**:
|
||||
```go
|
||||
const dbAddr = "127.0.0.1:8005"
|
||||
```
|
||||
|
||||
db 地址硬编码为常量。项目约定其他服务(db、ai_review、dnfs)都支持通过环境变量配置监听地址/端口,但 WAF 的 logclient 不支持,在非标准部署场景下无法灵活配置。
|
||||
|
||||
**修复建议**: 通过环境变量读取,提供默认值(`LOG_DB_ADDR` 或 `DB_HOST`+`DB_PORT`)。
|
||||
|
||||
---
|
||||
|
||||
### P2-3: ProcessResponseHeaders 硬编码 HTTP/1.1,协议版本错误
|
||||
|
||||
**位置**: `internal/proxy/proxy.go#L137`
|
||||
|
||||
**问题描述**:
|
||||
```go
|
||||
tx.ProcessResponseHeaders(rw.status, "HTTP/1.1")
|
||||
```
|
||||
|
||||
硬编码 `"HTTP/1.1"`。如果原始请求是 HTTP/2(WAF TLS 默认启用 h2),上游 gateway 虽然是 HTTP/1.1,但 WAF 到客户端是 HTTP/2,这里的协议版本应该从请求中获取(`r.Proto`),否则 Coraza 规则中依赖协议版本的检测可能不准确。
|
||||
|
||||
**修复建议**: 使用 `r.Proto` 替代硬编码的 `"HTTP/1.1"`。
|
||||
|
||||
---
|
||||
|
||||
### P2-4: responseRecorder 不支持 http.Hijacker,WebSocket 升级无法工作
|
||||
|
||||
**位置**: `internal/proxy/proxy.go#L157-L180`
|
||||
|
||||
**问题描述**:
|
||||
`responseRecorder` 嵌入了 `http.ResponseWriter` 但只实现了 `WriteHeader` 和 `Flush`,没有实现 `http.Hijacker` 接口。如果未来需要通过 WAF 代理 WebSocket 连接(如 aibox 的 WebSocket 接口需要对外暴露),Hijacker 是必须的——WebSocket 升级需要接管底层 TCP 连接。
|
||||
|
||||
**修复建议**: 实现 Hijacker 接口,将调用委托给底层 ResponseWriter(如果它支持)。
|
||||
|
||||
---
|
||||
|
||||
### P2-5: logclient 在 db 不可用时错误输出到 stdout
|
||||
|
||||
**位置**: `internal/logclient/logclient.go#L223`
|
||||
|
||||
**问题描述**:
|
||||
```go
|
||||
if json.Unmarshal(resp, &r) != nil || !r.Success {
|
||||
fmt.Printf("[logclient] INSERT failed: %s\n", r.Error)
|
||||
}
|
||||
```
|
||||
|
||||
db 插入失败时用 `fmt.Printf` 输出到 stdout。stdout 输出没有时间戳、没有级别,且在 systemd 等守护进程下可能被缓冲或重定向。建议输出到 stderr。
|
||||
|
||||
---
|
||||
|
||||
### ✅ P2-6: 错误处理时未确保 r.Body 耗尽/关闭,影响连接复用(已修复)
|
||||
|
||||
**位置**: `internal/proxy/proxy.go`(原 L81-L138)
|
||||
|
||||
**问题描述**:
|
||||
ServeHTTP 中 defer 只关闭了 WAF 事务 `tx`,但没有显式关闭/耗尽 `r.Body`。在拦截提前返回路径上,TCP 连接可能无法正确复用。
|
||||
|
||||
**修复方案**(2026-08-01,与 P0-1 一并修复):
|
||||
在 defer 中添加 r.Body 耗尽和关闭逻辑:先丢弃最多 1KB 剩余数据(避免大量未读数据),然后 Close。
|
||||
|
||||
---
|
||||
|
||||
## P3 - Low 优化建议
|
||||
|
||||
### P3-1: logclient 每次发送日志新建 TCP 连接,TIME_WAIT 堆积
|
||||
|
||||
**位置**: `internal/logclient/logclient.go#L228-L233`
|
||||
|
||||
每条日志都通过 `net.DialTimeout` 建立新的短连接。在短时间内大量拦截(如攻击风暴)时,会产生大量 TIME_WAIT 状态的 TCP 连接,占用临时端口。考虑复用连接池或保持长连接。
|
||||
|
||||
---
|
||||
|
||||
### P3-2: TLS Cipher Suites 未限定,允许弱加密套件
|
||||
|
||||
**位置**: `main.go#L113-L115`
|
||||
|
||||
当前只设置了 `MinVersion: tls.VersionTLS12`,未设置 `CipherSuites` 和 `CurvePreferences`,允许 Go 默认的所有套件,包括一些较弱的套件。建议显式指定安全的密码套件列表。
|
||||
|
||||
---
|
||||
|
||||
### P3-3: CheckInput 中的 URL 编码逻辑不完整
|
||||
|
||||
**位置**: `internal/engine/engine.go#L115-L129`
|
||||
|
||||
`CheckInput`(CLI `-check` 用)中的 query 字符串编码只处理了简单的 `key=value&key=value` 格式,对于无值的 key、重复 key、复杂嵌套结构编码可能不正确。但这是 CLI 调试工具,不影响线上安全,优先级低。
|
||||
|
||||
---
|
||||
|
||||
## 正面评价(值得肯定的设计)
|
||||
|
||||
审计中也发现了一些优秀的设计实践:
|
||||
|
||||
1. ✅ **热加载 fail-safe**: Reload 失败时保留旧规则实例,服务不中断
|
||||
2. ✅ **规则加载路径确定性**: 不使用 coraza 的 Include 指令(cwd 相对路径易出错),自行扫描目录按固定顺序加载
|
||||
3. ✅ **日志客户端 fail-safe**: db 不可用时静默丢弃日志,绝不阻塞 WAF 主流程
|
||||
4. ✅ **X-Forwarded-For 可信链设计**: WAF 从 TCP 连接获取真实 IP 直接覆盖 XFF,Gateway 侧先剥离伪造头再设置可信值,防止 IP 欺骗
|
||||
5. ✅ **安全头统一在边缘注入**: ModifyResponse 用 `Header.Set()` 覆盖下游同名头,防止下游服务意外覆盖 CSP 等安全头
|
||||
6. ✅ **证书和密钥目录被 .gitignore 排除**: 不会意外提交私钥到仓库
|
||||
7. ✅ **GOTOOLCHAIN=local**: 避免国内环境 Go 自动下载 toolchain 超时
|
||||
8. ✅ **并发安全**: Engine 使用 sync.RWMutex 保护 WAF 实例热替换,读请求用 RLock 不阻塞
|
||||
9. ✅ **upstream 502 错误也注入安全头**: ErrorHandler 中也调用 setSecurityHeaders,避免错误页面泄露信息
|
||||
10. ✅ **静态编译**: CGO_ENABLED=0 编译单二进制,无系统库依赖,部署方便
|
||||
|
||||
---
|
||||
|
||||
## 修复优先级建议
|
||||
|
||||
建议按以下顺序修复:
|
||||
|
||||
**立即修复(P0)**:
|
||||
1. P0-1: 请求体大小限制(OOM 防护)
|
||||
2. P0-2: HTTP 超时设置(Slowloris 防护)
|
||||
3. P0-3: 启用默认规则或明确警告
|
||||
|
||||
**短期修复(P1)**:
|
||||
4. P1-1: IPv6 地址解析(`net.SplitHostPort`)
|
||||
5. P1-4: 优雅关闭
|
||||
6. P1-5: 恢复标准日志输出
|
||||
7. P1-2/P1-3: 响应头处理和 body 读取错误处理
|
||||
|
||||
**中期改进(P2)**:
|
||||
8. 规则文件大小限制、logclient 配置化等
|
||||
@@ -16,6 +16,10 @@ import (
|
||||
"github.com/micro_servers/waf/internal/logclient"
|
||||
)
|
||||
|
||||
// maxRequestBodySize 请求体最大大小(10MB),与 rules/waf.conf 中 SecRequestBodyLimit 保持一致。
|
||||
// 超过此限制的请求直接返回 413 Request Entity Too Large,防止 OOM。
|
||||
const maxRequestBodySize = 10 * 1024 * 1024
|
||||
|
||||
// Proxy 持有 ReverseProxy 与 WAF 引擎引用。
|
||||
type Proxy struct {
|
||||
rp *httputil.ReverseProxy
|
||||
@@ -84,6 +88,11 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
tx.ProcessLogging()
|
||||
_ = tx.Close()
|
||||
// 确保 r.Body 被关闭并耗尽,保证 TCP 连接可复用
|
||||
if r.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(r.Body, 1024))
|
||||
_ = r.Body.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// 解析 RemoteAddr 中的 IP 与端口
|
||||
@@ -111,14 +120,47 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 请求体:读全量喂给 coraza,再用 buffer 恢复 r.Body 给 proxy 转发
|
||||
// 请求体:读取时限制大小(maxRequestBodySize + 1 字节用于判断超限),防止 OOM
|
||||
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))
|
||||
// 限制读取大小:多读 1 字节,若能读到说明超限
|
||||
limitReader := io.LimitReader(r.Body, maxRequestBodySize+1)
|
||||
bodyBytes, err := io.ReadAll(limitReader)
|
||||
if err != nil {
|
||||
// 客户端中途断开或读取错误,直接返回无需继续处理
|
||||
logclient.Info("waf", fmt.Sprintf("body_read_error path=%s err=%v", r.URL.Path, err),
|
||||
map[string]any{"ip": r.RemoteAddr})
|
||||
return
|
||||
}
|
||||
if it, _ := tx.ProcessRequestBody(); it != nil {
|
||||
if len(bodyBytes) > maxRequestBodySize {
|
||||
// 请求体超限,返回 413
|
||||
logclient.Warning("waf", fmt.Sprintf("body_too_large %s %s size=%d", r.Method, r.URL.Path, len(bodyBytes)),
|
||||
map[string]any{
|
||||
"ip": r.RemoteAddr,
|
||||
"method": r.Method,
|
||||
"path": r.URL.Path,
|
||||
"size": len(bodyBytes),
|
||||
"limit": maxRequestBodySize,
|
||||
"cost_ms": time.Since(start).Milliseconds(),
|
||||
})
|
||||
setSecurityHeaders(w.Header())
|
||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||
_, _ = w.Write([]byte("request body too large\n"))
|
||||
return
|
||||
}
|
||||
// 喂给 coraza 进行请求体检测
|
||||
if _, _, err := tx.ReadRequestBodyFrom(bytes.NewReader(bodyBytes)); err != nil {
|
||||
logclient.Warning("waf", fmt.Sprintf("body_parse_error path=%s err=%v", r.URL.Path, err),
|
||||
map[string]any{"ip": r.RemoteAddr})
|
||||
}
|
||||
// 恢复 r.Body 供 ReverseProxy 转发
|
||||
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
it, err := tx.ProcessRequestBody()
|
||||
if err != nil {
|
||||
logclient.Warning("waf", fmt.Sprintf("body_process_error path=%s err=%v", r.URL.Path, err),
|
||||
map[string]any{"ip": r.RemoteAddr})
|
||||
}
|
||||
if it != nil {
|
||||
p.block(w, r, it.RuleID, start)
|
||||
return
|
||||
}
|
||||
|
||||
+8
-2
@@ -22,6 +22,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
@@ -108,8 +109,13 @@ func main() {
|
||||
}()
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.Listen,
|
||||
Handler: p,
|
||||
Addr: cfg.Listen,
|
||||
Handler: p,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20, // 1MB
|
||||
TLSConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user