feat(waf): add WebSocket proxy support and fix HTTP/2 proto version

- Implement http.Hijacker on responseRecorder to enable WebSocket upgrade
  (delegates Hijack() to underlying ResponseWriter, marks hijacked state)
- After WebSocket upgrade (101 Switching Protocols), skip HTTP response-phase
  WAF checks since connection becomes a binary frame stream
- Log WebSocket upgrades at Info level for observability
- Fix P2-3: use r.Proto instead of hardcoded "HTTP/1.1" in ProcessResponseHeaders
  for correct HTTP/2 protocol version reporting
- Add Write() method to responseRecorder to ensure WriteHeader is called
  before first Write (complies with http.ResponseWriter contract)
- Add bufio/net imports required by Hijacker interface
- Update README with WebSocket support section and v1.2 changelog
- Update SECURITY_AUDIT to mark P2-3 and P2-4 as fixed
This commit is contained in:
qing committed 2026-08-01 04:18:30 +08:00
1 parent 4e190953c0
commit 81ae84851d
3 files changed
+94 -18

No files matched your search

+27 -1
View File
@@ -283,13 +283,30 @@ Director 改写([proxy.go:36-46](internal/proxy/proxy.go#L36)):剥离 `Rem
ErrorHandler([proxy.go:48-53](internal/proxy/proxy.go#L48)):upstream 不可达时返回 502,同时注入安全响应头,不暴露内部堆栈,仅服务端 log。 ErrorHandler([proxy.go:48-53](internal/proxy/proxy.go#L48)):upstream 不可达时返回 502,同时注入安全响应头,不暴露内部堆栈,仅服务端 log。
## WebSocket 代理支持
WAF 支持透明代理 WebSocket 连接(如 aibox 的 WebSocket 接口)。实现要点:
- `responseRecorder` 实现了 `http.Hijacker` 接口,将 Hijack 调用委托给底层 `http.ResponseWriter`
- WebSocket 握手阶段(HTTP Upgrade 请求/响应)仍然经过 WAF 规则检测(请求头 phase:1)
- 握手成功(101 Switching Protocols)后,连接被 Hijack,由 `httputil.ReverseProxy` 自动进行双向字节流转发
- 升级后连接转为二进制帧协议,跳过 HTTP 响应阶段的 WAF 检测,记录 Info 日志
- WebSocket 帧内容不做 WAF 检测(帧是二进制双向流,Coraza 规则引擎基于 HTTP 请求/响应模型)
- `http.Server` 的超时设置不影响 Hijack 后的 WebSocket 长连接(Hijack 后连接脱离 Server 超时管理)
- 安全响应头(CSP/HSTS 等)不注入 WebSocket 升级响应(101 响应由协议协商决定,注入非标准头可能导致客户端兼容问题)
配置要求:
- 无特殊配置,WebSocket 连接(`Upgrade: websocket`)自动识别并代理
- 需要上游服务正确处理 WebSocket 升级(如 aibox 监听 ws:// 端口)
## 已知限制 ## 已知限制
- 响应头检测(`ProcessResponseHeaders`)当前仅做审计日志,命中后不执行拦截(响应头已发送无法撤销状态码)。如需响应头拦截需引入 buffered response writer。 - 响应头检测(`ProcessResponseHeaders`)当前仅做审计日志,命中后不执行拦截(响应头已发送无法撤销状态码)。如需响应头拦截需引入 buffered response writer。
- **响应体检测未启用**(未 `ProcessResponseBody`,缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。 - **响应体检测未启用**(未 `ProcessResponseBody`,缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。
- **WebSocket 帧内容不检测**:升级后二进制帧直接转发,不经过 WAF 规则引擎(适用于内部可信服务如 aibox)。
- 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。 - 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。
- TLS 仅设 `MinVersion: TLS1.2`,未限定 cipher suites,允许弱加密套件。 - TLS 仅设 `MinVersion: TLS1.2`,未限定 cipher suites,允许弱加密套件。
- `X-Forwarded-For` 直接覆盖原值(`Header.Set`),未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。注意:IPv6 地址在 X-Forwarded-For 中可能带方括号(待修复)。 - `X-Forwarded-For` 直接覆盖原值(`Header.Set`),未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。注意:IPv6 地址在 X-Forwarded-For 中可能带方括号(待修复,使用 `LastIndexByte` 无法正确处理 IPv6 格式)。
- CSP 仍含 `'unsafe-inline'`(为兼容 forgot.html 与 games/*.html 的内联脚本),待前端清理后可收紧。 - CSP 仍含 `'unsafe-inline'`(为兼容 forgot.html 与 games/*.html 的内联脚本),待前端清理后可收紧。
- HSTS 已启用(半年期,不含子域),站点全量 HTTPS 覆盖后可按需收紧。 - HSTS 已启用(半年期,不含子域),站点全量 HTTPS 覆盖后可按需收紧。
- logclient 日志推送每次新建 TCP 短连接,高并发拦截场景可能产生较多 TIME_WAIT;db 地址硬编码 `127.0.0.1:8005`,不支持环境变量配置。 - logclient 日志推送每次新建 TCP 短连接,高并发拦截场景可能产生较多 TIME_WAIT;db 地址硬编码 `127.0.0.1:8005`,不支持环境变量配置。
@@ -299,6 +316,15 @@ ErrorHandler([proxy.go:48-53](internal/proxy/proxy.go#L48)):upstream 不
## 版本日志 ## 版本日志
### v1.2(2026-08-01)— WebSocket 支持 + 修复
- **feat**: WebSocket 透明代理支持,`responseRecorder` 实现 `http.Hijacker` 接口
- WebSocket 握手阶段仍经过 WAF 请求头检测,升级后自动双向转发
- WebSocket 升级成功记录 Info 日志,便于追踪
- **fix(P2-3)**: `ProcessResponseHeaders` 协议版本从硬编码 `"HTTP/1.1"` 改为 `r.Proto`,支持 HTTP/2
- **fix(P2-4)**: responseRecorder 添加 Write 方法确保 WriteHeader 在首次 Write 前调用
- 补充 `bufio`/`net` import 用于 Hijacker 实现
### v1.1(2026-08-01)— 安全加固 ### v1.1(2026-08-01)— 安全加固
- **P0 修复:请求体 OOM 防护**:添加 10MB 硬限制,`io.LimitReader` + 多读1字节检测超限,超限返回 413 - **P0 修复:请求体 OOM 防护**:添加 10MB 硬限制,`io.LimitReader` + 多读1字节检测超限,超限返回 413
+16 -13
View File
@@ -12,7 +12,7 @@
|--------|--------|--------|--------| |--------|--------|--------|--------|
| **P0 (Critical)** | 3 | 2 | 1 | | **P0 (Critical)** | 3 | 2 | 1 |
| **P1 (High)** | 5 | 1 | 4 | | **P1 (High)** | 5 | 1 | 4 |
| **P2 (Medium)** | 6 | 1 | 5 | | **P2 (Medium)** | 6 | 3 | 3 |
| **P3 (Low)** | 3 | 0 | 3 | | **P3 (Low)** | 3 | 0 | 3 |
--- ---
@@ -213,29 +213,32 @@ db 地址硬编码为常量。项目约定其他服务(db、ai_review、dnfs
--- ---
### P2-3: ProcessResponseHeaders 硬编码 HTTP/1.1,协议版本错误 ### ✅ P2-3: ProcessResponseHeaders 硬编码 HTTP/1.1,协议版本错误(已修复)
**位置**: `internal/proxy/proxy.go#L137` **位置**: `internal/proxy/proxy.go`(原 L137)
**问题描述**: **问题描述**:
```go 硬编码 `"HTTP/1.1"`,HTTP/2 请求时协议版本不正确,Coraza 规则中依赖协议版本的检测可能不准确。
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 规则中依赖协议版本的检测可能不准确。 **修复方案**(2026-08-01,与 WebSocket 支持一并修复):
将 `tx.ProcessResponseHeaders(rw.status, "HTTP/1.1")` 改为 `tx.ProcessResponseHeaders(rw.status, r.Proto)`,从请求中获取正确的协议版本。
**修复建议**: 使用 `r.Proto` 替代硬编码的 `"HTTP/1.1"`。
--- ---
### P2-4: responseRecorder 不支持 http.Hijacker,WebSocket 升级无法工作 ### ✅ P2-4: responseRecorder 不支持 http.Hijacker,WebSocket 升级无法工作(已修复)
**位置**: `internal/proxy/proxy.go#L157-L180` **位置**: `internal/proxy/proxy.go`(原 L157-L180)
**问题描述**: **问题描述**:
`responseRecorder` 嵌入了 `http.ResponseWriter` 但只实现了 `WriteHeader` 和 `Flush`,没有实现 `http.Hijacker` 接口。如果未来需要通过 WAF 代理 WebSocket 连接(如 aibox 的 WebSocket 接口需要对外暴露),Hijacker 是必须的——WebSocket 升级需要接管底层 TCP 连接。 `responseRecorder` 嵌入了 `http.ResponseWriter` 但只实现了 `WriteHeader` 和 `Flush`,没有实现 `http.Hijacker` 接口,WebSocket 升级(需要接管底层 TCP 连接)无法工作。
**修复建议**: 实现 Hijacker 接口,将调用委托给底层 ResponseWriter(如果它支持)。 **修复方案**(2026-08-01):
1. 添加 `bufio` 和 `net` import
2. 给 `responseRecorder` 添加 `hijacked bool` 和 `statusSent bool` 字段
3. 实现 `Hijack()` 方法,将调用委托给底层 `ResponseWriter.(http.Hijacker)`,成功后标记 `hijacked=true` 并设置 status=101
4. 添加 `Write()` 方法确保首次 Write 前 WriteHeader 被调用(符合 http.ResponseWriter 约定)
5. 在 `ServeHTTP` 中检测 `rw.hijacked`,WebSocket 升级后跳过响应阶段 WAF 检测,记录 Info 日志
6. ReverseProxy(Go 1.22)内置 WebSocket 代理支持,Hijack 后自动进行双向字节流转发
--- ---
+51 -4
View File
@@ -2,9 +2,11 @@
package proxy package proxy
import ( import (
"bufio"
"bytes" "bytes"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
@@ -170,13 +172,26 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rw := &responseRecorder{ResponseWriter: w, status: 200} rw := &responseRecorder{ResponseWriter: w, status: 200}
p.rp.ServeHTTP(rw, r) p.rp.ServeHTTP(rw, r)
// WebSocket 升级成功后连接已被 Hijack,转为二进制帧协议,无需执行 HTTP 响应阶段检测
if rw.hijacked {
logclient.Info("waf", fmt.Sprintf("websocket_upgraded %s %s cost_ms=%d",
r.Method, r.URL.Path, time.Since(start).Milliseconds()),
map[string]any{
"ip": r.RemoteAddr,
"method": r.Method,
"path": r.URL.Path,
"cost_ms": time.Since(start).Milliseconds(),
})
return
}
// 响应阶段:把 upstream 的 header 喂给 WAF // 响应阶段:把 upstream 的 header 喂给 WAF
for k, vs := range rw.headers { for k, vs := range rw.headers {
for _, v := range vs { for _, v := range vs {
tx.AddResponseHeader(k, v) tx.AddResponseHeader(k, v)
} }
} }
tx.ProcessResponseHeaders(rw.status, "HTTP/1.1") tx.ProcessResponseHeaders(rw.status, r.Proto)
} }
// block 命中规则时统一返回 403,并将关键输入推送到日志库。 // block 命中规则时统一返回 403,并将关键输入推送到日志库。
@@ -197,26 +212,58 @@ func (p *Proxy) block(w http.ResponseWriter, r *http.Request, ruleID int, start
} }
// responseRecorder 记录状态码与响应头,供响应阶段 WAF 检测使用。 // responseRecorder 记录状态码与响应头,供响应阶段 WAF 检测使用。
// 同时实现 http.Hijacker 以支持 WebSocket 升级(ReverseProxy 在 WebSocket 握手时需要劫持底层连接)。
type responseRecorder struct { type responseRecorder struct {
http.ResponseWriter http.ResponseWriter
status int status int
headers http.Header statusSent bool
headers http.Header
hijacked bool
} }
func (r *responseRecorder) WriteHeader(code int) { func (r *responseRecorder) WriteHeader(code int) {
if r.hijacked {
return
}
r.status = code r.status = code
r.statusSent = true
if r.headers == nil { if r.headers == nil {
r.headers = http.Header{} r.headers = http.Header{}
} }
// 拷贝上游响应头 // 拷贝上游响应头(在 WriteHeader 调用时响应头已最终确定)
for k, vs := range r.Header() { for k, vs := range r.Header() {
r.headers[k] = vs r.headers[k] = vs
} }
r.ResponseWriter.WriteHeader(code) r.ResponseWriter.WriteHeader(code)
} }
func (r *responseRecorder) Write(b []byte) (int, error) {
if !r.statusSent {
r.WriteHeader(http.StatusOK)
}
return r.ResponseWriter.Write(b)
}
// Flush 实现 http.Flusher,支持 SSE/流式响应。
func (r *responseRecorder) Flush() { func (r *responseRecorder) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok { if f, ok := r.ResponseWriter.(http.Flusher); ok {
f.Flush() f.Flush()
} }
} }
// Hijack 实现 http.Hijacker,支持 WebSocket 升级。
// 委托给底层 ResponseWriter 的 Hijack 方法,并标记连接已被劫持。
func (r *responseRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hijacker, ok := r.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, fmt.Errorf("underlying ResponseWriter does not support Hijacker")
}
conn, rw, err := hijacker.Hijack()
if err == nil {
r.hijacked = true
// WebSocket 升级响应是 101 Switching Protocols
r.status = http.StatusSwitchingProtocols
r.statusSent = true
}
return conn, rw, err
}