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:
1 parent
4e190953c0
commit
81ae84851d
3 files changed
+92
-16
No files matched your search
+27
-1
@@ -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
|
||||||
|
|||||||
@@ -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 后自动进行双向字节流转发
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
statusSent bool
|
||||||
headers http.Header
|
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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user