fix(webui): Server 读侧超时(防 Slowloris)+ 反向判据守住流式不被腰斩

http.Server 原本**一个超时都没设**(只有 Handler)。后果是 Slowloris:
攻击者只占连接不发完整请求头,每个连接挂几 KB。MaxHeaderBytes 限的是
头部**大小**,「慢慢发」不占大小、不受它约束,几百个连接就能耗尽 fd。

## 为什么不是「把超时都设上」

webui 有一条长连接 SSE(/api/v1/chat/events)与可跑 300s 的流式
/v1/chat/completions。WriteTimeout 是「从请求开始到响应写完」的**总预算**,
会把它们腰斩 —— 表现为 SSE 每隔一段时间断一次、前端疯狂重连。而这类
回归在功能测试里很难立刻发现。

所以只设读侧三项,各管一段:

  ReadHeaderTimeout 20s —— 请求头必须按时发完,Slowloris 的正解
  ReadTimeout       60s —— 读完整请求(含 body)的预算,防慢速上传
  IdleTimeout      120s —— keep-alive 空闲连接(另两项都管不到)
  WriteTimeout        0 —— **刻意不设**(见上)

## 判据(3 条,含一条反向判据)

- TestServerHasReadSideTimeouts:三个读侧超时都必须 > 0
- TestServerHasNoWriteTimeout:**反向**钉住 WriteTimeout 必须保持 0,
  防止将来有人「顺手补全超时」把 SSE 弄坏
- TestSSEConnectionSurvivesBeyondReadTimeout:SSE 连接确实活过读侧窗口

反向判据看着琐碎,但它守的正是「这次没做的那件事」——
不加 WriteTimeout 是个**决定**,不是疏漏,所以要用判据把决定固定下来。

变异验证:补上 WriteTimeout:30s → 反向判据判红;
去掉 ReadHeaderTimeout → 前向判据判红。

测试脚手架注意:newServerForTest 绑 127.0.0.1:0(内核分配空闲端口),
绝不用 :8080 —— 那是生产端口(见 a752ae1)。

全量:35 包全绿。
This commit is contained in:
JianFeeeee
2026-09-26 10:00:27 +08:00
parent 35df6f4366
commit 3273c507b3
2 changed files with 129 additions and 1 deletions

View File

@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
@ -324,7 +325,27 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
if _, port, err := net.SplitHostPort(ln.Addr().String()); err == nil {
p.handler.hostPort = ":" + port
}
p.server = &http.Server{Handler: p.handler.Handler()}
// 超时配置。为什么不"全设上"(读侧全设、写侧全不设):
//
// 读侧必须有超时,否则 Slowloris —— 攻击者只占连接不发完整请求头,
// 每个连接挂几 KB。MaxHeaderBytes 限的是头部**大小**,"慢慢发"不占大小,
// 因此不受它约束;几百个连接就能耗尽 fd。这里三个读侧超时分别覆盖:
// ReadHeaderTimeout —— 请求头必须在此时间内发完(Slowloris 的正解)
// ReadTimeout —— 读完整请求(含 body)的预算,防慢速上传
// IdleTimeout —— keep-alive 空闲连接(另外两个都管不到)
//
// 写侧**刻意不设**:webui 有长连接 SSE(/api/v1/chat/events)与可跑
// 300s 的流式 /v1/chat/completions。WriteTimeout 是"从请求开始到响应
// 写完"的**总预算**,会把它们腰斩(表现为 SSE 每隔一段时间断一次、
// 前端疯狂重连)—— 这类回归很难在功能测试里立刻发现,所以有专门
// 的反向判据钉住它必须保持为 0。
p.server = &http.Server{
Handler: p.handler.Handler(),
ReadHeaderTimeout: 20 * time.Second,
ReadTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
// WriteTimeout 保持 0(见上方说明)
}
go func() {
if err := p.server.Serve(ln); err != nil && err != http.ErrServerClosed {
log.Printf("[webui] server error: %v", err)