diff --git a/.gitignore b/.gitignore index dd72a6295..744180fc2 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ workspace.zip /.trellis /AGENTS.md /CLAUDE.md + +# 本地工具链(protoc/LLVM),不提交 +/.tools/ diff --git a/crates/agent-gateway/internal/handler/image_proxy.go b/crates/agent-gateway/internal/handler/image_proxy.go index f8982b45b..0909bbd1d 100644 --- a/crates/agent-gateway/internal/handler/image_proxy.go +++ b/crates/agent-gateway/internal/handler/image_proxy.go @@ -79,6 +79,13 @@ func imageProxyWithClient(client outboundHTTPClient) http.HandlerFunc { w.Header().Set("Cache-Control", "private, max-age=300") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "no-referrer") + if mimeType == "image/svg+xml" { + // SVG 可携带脚本;顶层导航到 SVG 会在网关 origin 执行脚本(WebUI + // 的管理 token 在 localStorage)。sandbox 禁脚本 + 附件式内联处置 + // 把该端点降级为纯图片源。 + w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'") + w.Header().Set("Content-Disposition", `inline; filename="image.svg"`) + } _, _ = w.Write(body) } } diff --git a/crates/agent-gateway/internal/handler/image_proxy_test.go b/crates/agent-gateway/internal/handler/image_proxy_test.go index ff6b6e7f7..022cd1e23 100644 --- a/crates/agent-gateway/internal/handler/image_proxy_test.go +++ b/crates/agent-gateway/internal/handler/image_proxy_test.go @@ -46,6 +46,62 @@ func TestImageProxyServesSupportedImage(t *testing.T) { } } +func TestImageProxyServesSVGWithSandboxCSP(t *testing.T) { + // 顶层导航到 SVG 会在网关 origin 执行脚本(WebUI 管理 token 在 localStorage); + // SVG 响应必须携带 sandbox CSP 与附件式处置,降级为纯图片源。 + client := outboundHTTPClientFunc(func(r *http.Request) (*http.Response, error) { + body := []byte(``) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/plain"}}, + Body: io.NopCloser(strings.NewReader(string(body))), + ContentLength: int64(len(body)), + Request: r, + }, nil + }) + + req := httptest.NewRequest(http.MethodGet, "/image-proxy?url=https://evil.example/x.svg", nil) + rec := httptest.NewRecorder() + imageProxyWithClient(client)(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d body=%q", http.StatusOK, rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Content-Type"); got != "image/svg+xml" { + t.Fatalf("content-type = %q, want image/svg+xml", got) + } + if got := rec.Header().Get("Content-Security-Policy"); got != "sandbox; default-src 'none'" { + t.Fatalf("svg CSP = %q, want sandbox CSP", got) + } + if got := rec.Header().Get("Content-Disposition"); !strings.Contains(got, "inline") { + t.Fatalf("svg content-disposition = %q, want inline attachment-style disposition", got) + } +} + +func TestImageProxyNonSVGHasNoSandboxCSP(t *testing.T) { + client := outboundHTTPClientFunc(func(r *http.Request) (*http.Response, error) { + body := []byte("\x89PNG\r\n\x1a\nliveagent-test") + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"image/png"}}, + Body: io.NopCloser(strings.NewReader(string(body))), + ContentLength: int64(len(body)), + Request: r, + }, nil + }) + + req := httptest.NewRequest(http.MethodGet, "/image-proxy?url=https://images.example/photo.png", nil) + rec := httptest.NewRecorder() + imageProxyWithClient(client)(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d body=%q", http.StatusOK, rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Content-Security-Policy"); got != "" { + t.Fatalf("non-svg CSP = %q, want empty", got) + } +} + func TestImageProxyRefererUsesTargetOrigin(t *testing.T) { targetURL, err := url.Parse("https://example.com:8443/path/photo.png?size=large") if err != nil { diff --git a/crates/agent-gateway/internal/protocol/pbws/browser_conn.go b/crates/agent-gateway/internal/protocol/pbws/browser_conn.go index e3b464706..0249cb694 100644 --- a/crates/agent-gateway/internal/protocol/pbws/browser_conn.go +++ b/crates/agent-gateway/internal/protocol/pbws/browser_conn.go @@ -123,18 +123,16 @@ func (c *browserConn) serve() { defer observability.Usage.V2BrowserConnectionsActive.Add(-1) for { - frame, ok := c.readFrame() + frame, denied, ok := c.readFrame() if !ok { return } c.core.TouchInboundActivity() - // 入站限速:超限丢帧回错,连续违规判定失控客户端、关闭连接。 - if allowed, exceeded := c.rateLimiter.Allow(); !allowed { - if exceeded { - return - } - _ = c.sendLocalError(frame.GetRequestId(), "too many requests") + // 入站限速已在 readFrame 内、反序列化前计数(文本帧同罪);被丢弃的帧 + // 无 request 上下文,回无关联 id 的本地错误,连续违规由 readFrame 判死。 + if denied { + _ = c.sendLocalError("", "too many requests") continue } @@ -168,29 +166,42 @@ func (c *browserConn) serve() { } // readFrame 读取并解码一帧;解码失败说明帧流已破坏,直接关闭连接。 -func (c *browserConn) readFrame() (*gatewayv2.WebClientFrame, bool) { +// 限速在每次 ReadMessage 成功后、proto 反序列化前立即计数:文本帧与大型 +// 二进制帧同样消耗令牌——旧实现把 Allow 放在反序列化之后且文本帧直接 +// continue,洪泛文本帧可零成本绕过限速,大帧的解析 CPU 也无法被约束。 +// denied=true 表示该帧在解析前被限速丢弃、连接仍存活;ok=false 表示连接 +// 应关闭(读错误 / 解码失败 / 连续违规超阈值判定失控客户端)。 +func (c *browserConn) readFrame() (frame *gatewayv2.WebClientFrame, denied bool, ok bool) { for { messageType, data, err := c.conn.ReadMessage() if err != nil { - return nil, false + return nil, false, false + } + if allowed, exceeded := c.rateLimiter.Allow(); !allowed { + if exceeded { + return nil, false, false + } + return nil, true, true } if messageType != websocket.BinaryMessage { - // v2 链路上文本帧无意义;容忍并忽略(仍计入存活)。 + // v2 链路上文本帧无意义;计入限速(上方)后忽略(仍计入存活)。 c.core.TouchInboundActivity() continue } - var frame gatewayv2.WebClientFrame - if err := proto.Unmarshal(data, &frame); err != nil { - return nil, false + var decoded gatewayv2.WebClientFrame + if err := proto.Unmarshal(data, &decoded); err != nil { + return nil, false, false } - return &frame, true + return &decoded, false, true } } // handshake 处理首帧 hello;失败时写出失败应答并关闭。 func (c *browserConn) handshake() bool { - frame, ok := c.readFrame() - if !ok { + // 首帧即被限速(前置洪泛耗尽突发额度)说明对端行为异常,直接关闭; + // 正常客户端的 hello 是第一帧,不会命中限速。 + frame, denied, ok := c.readFrame() + if !ok || denied { return false } hello := frame.GetHello() diff --git a/crates/agent-gateway/internal/protocol/pbws/browser_conn_test.go b/crates/agent-gateway/internal/protocol/pbws/browser_conn_test.go new file mode 100644 index 000000000..a4488836d --- /dev/null +++ b/crates/agent-gateway/internal/protocol/pbws/browser_conn_test.go @@ -0,0 +1,128 @@ +package pbws + +// 主链路读路径与终端链路同形态的限速绑定回归:文本帧计入限速、限速先于 +// 反序列化(旧实现 Allow 在 Unmarshal 之后且文本帧直接 continue,可被 +// 零成本文本帧洪泛绕过,大帧的解析 CPU 也无法被约束)。 + +import ( + "testing" + "time" + + "github.com/gorilla/websocket" + "google.golang.org/protobuf/proto" + + gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" + "github.com/liveagent/agent-gateway/internal/transport/wscore" +) + +// newReadFrameTestConn 构造仅够驱动 readFrame 的轻量 browserConn: +// 读路径只触达 conn / rateLimiter / core.TouchInboundActivity。 +func newReadFrameTestConn(conn *websocket.Conn, limiter *wscore.InboundRateLimiter) *browserConn { + return &browserConn{ + conn: conn, + core: wscore.NewConn(conn, wscore.Config{}), + rateLimiter: limiter, + } +} + +func mustMarshalWebClientFrame(t *testing.T) []byte { + t.Helper() + data, err := proto.Marshal(&gatewayv2.WebClientFrame{}) + if err != nil { + t.Fatalf("marshal web client frame: %v", err) + } + return data +} + +// 文本帧必须与二进制帧同罪计入限速:两帧文本耗尽突发额度后,合法二进制帧 +// 必须被限速丢弃;旧实现中文本帧不计数,该帧会被放行。 +func TestBrowserReadFrameCountsTextFramesAgainstRateLimit(t *testing.T) { + results := make(chan frameReadResult, 3) + limiter := wscore.NewInboundRateLimiter(0.0001, 2, 3) + client := wsTestPair(t, func(conn *websocket.Conn) { + defer func() { _ = conn.Close() }() + c := newReadFrameTestConn(conn, limiter) + for i := 0; i < 3; i++ { + frame, denied, ok := c.readFrame() + results <- frameReadResult{gotFrame: frame != nil, denied: denied, ok: ok} + if !ok { + return + } + } + }) + + valid := mustMarshalWebClientFrame(t) + writes := []struct { + messageType int + data []byte + }{ + {websocket.TextMessage, []byte("junk-one")}, + {websocket.TextMessage, []byte("junk-two")}, + {websocket.BinaryMessage, valid}, + {websocket.BinaryMessage, valid}, + {websocket.BinaryMessage, valid}, + } + for _, write := range writes { + if err := client.WriteMessage(write.messageType, write.data); err != nil { + t.Fatalf("write frame: %v", err) + } + } + + first := <-results + if !first.denied || !first.ok || first.gotFrame { + t.Fatalf("first read = %+v, want denied-but-alive (text frames must consume budget)", first) + } + second := <-results + if !second.denied || !second.ok || second.gotFrame { + t.Fatalf("second read = %+v, want denied-but-alive", second) + } + third := <-results + if third.ok { + t.Fatalf("third read = %+v, want connection closed after 3 consecutive violations", third) + } +} + +// 限速必须发生在 proto 反序列化之前:超限的非法帧在解析前即被丢弃、连接 +// 存活;旧顺序(先解析后限速)下非法帧直接破坏帧流、关闭连接,第三帧 +// 永远读不到。 +func TestBrowserReadFrameLimitsBeforeUnmarshal(t *testing.T) { + results := make(chan frameReadResult, 3) + limiter := wscore.NewInboundRateLimiter(100, 1, 10) + client := wsTestPair(t, func(conn *websocket.Conn) { + defer func() { _ = conn.Close() }() + c := newReadFrameTestConn(conn, limiter) + for i := 0; i < 3; i++ { + frame, denied, ok := c.readFrame() + results <- frameReadResult{gotFrame: frame != nil, denied: denied, ok: ok} + if !ok { + return + } + } + }) + + valid := mustMarshalWebClientFrame(t) + if err := client.WriteMessage(websocket.BinaryMessage, valid); err != nil { + t.Fatalf("write valid frame: %v", err) + } + if err := client.WriteMessage(websocket.BinaryMessage, []byte{0xff, 0xff, 0xff}); err != nil { + t.Fatalf("write invalid frame: %v", err) + } + // 等限速器回填后第三帧必须仍然可读、可解析。 + time.Sleep(30 * time.Millisecond) + if err := client.WriteMessage(websocket.BinaryMessage, valid); err != nil { + t.Fatalf("write third frame: %v", err) + } + + first := <-results + if !first.gotFrame || !first.ok || first.denied { + t.Fatalf("first read = %+v, want parsed frame", first) + } + second := <-results + if !second.denied || !second.ok || second.gotFrame { + t.Fatalf("second read = %+v, want denied-but-alive (rate limit must precede unmarshal)", second) + } + third := <-results + if !third.gotFrame || !third.ok || third.denied { + t.Fatalf("third read = %+v, want parsed frame after refill", third) + } +} diff --git a/crates/agent-gateway/internal/protocol/pbws/guard.go b/crates/agent-gateway/internal/protocol/pbws/guard.go index 15bbfb24c..bba4e1299 100644 --- a/crates/agent-gateway/internal/protocol/pbws/guard.go +++ b/crates/agent-gateway/internal/protocol/pbws/guard.go @@ -73,7 +73,9 @@ func vetAgentRequest(sm session.AgentView, env *gatewayv2.GatewayEnvelope) error // ---- 带功能门控 / 限额的直通臂 ---- case *gatewayv2.GatewayEnvelope_GitRequest: - action := strings.TrimSpace(payload.GitRequest.GetAction()) + // 大小写归一:桌面侧对 action 做 to_ascii_lowercase 后执行,若此处不归一, + // CLONE_START/Push 等变体可绕过写操作门控(判为读操作)却在桌面端执行真实写操作。 + action := strings.ToLower(strings.TrimSpace(payload.GitRequest.GetAction())) if gitActionIsWrite(action) && !sm.WebGitEnabled() { return errors.New("web git is disabled in desktop Remote settings") } diff --git a/crates/agent-gateway/internal/protocol/pbws/terminal_conn.go b/crates/agent-gateway/internal/protocol/pbws/terminal_conn.go index 37b19dc32..0f4c64cfe 100644 --- a/crates/agent-gateway/internal/protocol/pbws/terminal_conn.go +++ b/crates/agent-gateway/internal/protocol/pbws/terminal_conn.go @@ -16,6 +16,7 @@ import ( gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" "github.com/liveagent/agent-gateway/internal/protocol/shared" "github.com/liveagent/agent-gateway/internal/session" + "github.com/liveagent/agent-gateway/internal/transport/wscore" ) // 终端数据面(/ws/v2/terminal):两端共用一条路径,角色由 hello 区分。浏览器 @@ -24,6 +25,11 @@ import ( const terminalWriteQueueSize = 1024 +// 握手阶段的绝对时间边界:配合逐帧计数限速(见 readTerminalFrame),空连接、 +// 慢速字节流与文本帧洪泛都不能无限占住 terminal connection slot。 +// var 而非 const:仅用于测试缩短。 +var terminalHandshakeTimeout = 10 * time.Second + // TerminalHandler 返回 /ws/v2/terminal 的 HTTP 处理器。 func (s *Server) TerminalHandler() http.Handler { upgrader := s.upgrader() @@ -48,11 +54,29 @@ func (s *Server) TerminalHandler() http.Handler { func (s *Server) serveTerminal(conn *websocket.Conn) { defer func() { _ = conn.Close() }() - frame, ok := readTerminalFrame(conn) - if !ok { - return + // 握手限速器自第一帧起生效(每帧在反序列化前计数,文本帧同罪);浏览器角色 + // 握手后沿用同一限速器——攻击者不能借完成握手重置预算。Agent 数据面输出帧 + // 无丢帧语义,不在此限速(见 readTerminalFrame 的 nil 语义)。 + limiter := wscore.NewInboundRateLimiter( + browserInboundFramesPerSecond, browserInboundBurst, browserRateLimitMaxViolations, + ) + _ = conn.SetReadDeadline(time.Now().Add(terminalHandshakeTimeout)) + var hello *gatewayv2.ClientHello + for { + frame, denied, ok := readTerminalFrame(conn, limiter) + if !ok { + return + } + if denied { + // 握手前被限速的帧静默丢弃(尚无协商好的反馈通道),截止时间与 + // 违规阈值保证洪泛很快被判死。 + continue + } + hello = frame.GetHello() + break } - hello := frame.GetHello() + // 握手完成后恢复数据面既有的长连接语义(无读超时)。 + _ = conn.SetReadDeadline(time.Time{}) // 终端路径两端共用:按 hello 声明的角色校验(未声明按浏览器处理)。 wantRole := hello.GetRole() if wantRole == gatewayv2.ClientRole_CLIENT_ROLE_UNSPECIFIED { @@ -147,23 +171,42 @@ func (s *Server) serveTerminal(conn *websocket.Conn) { return } observability.Usage.V2TerminalConnectsTotal.Add(1) - s.serveTerminalBrowser(conn, boundAgentID) + s.serveTerminalBrowser(conn, boundAgentID, limiter) } -func readTerminalFrame(conn *websocket.Conn) (*gatewayv2.TerminalClientFrame, bool) { +// readTerminalFrame 读取并解码一帧。限速器(非 nil 时)在每次 ReadMessage 成功 +// 后、proto 反序列化前立即计数:文本帧与大型二进制帧同样消耗令牌——旧实现把 +// Allow 放在反序列化之后且文本帧直接 continue,洪泛文本帧可零成本绕过限速并 +// 占住连接 slot,大帧的解析 CPU 也无法被约束。denied=true 表示该帧在解析前被 +// 限速丢弃、连接仍存活(由调用方决定是否回 "too many requests");ok=false +// 表示连接应关闭(读错误 / 解码失败 / 连续违规超阈值判定失控客户端)。 +// +// limiter 为 nil 用于 Agent 数据面:输出帧没有可容忍的丢帧语义(静默丢弃会让 +// 终端显示残缺),其入站由 read limit 与会话通道背压兜底,对端是经凭证认证的 +// 桌面端。 +func readTerminalFrame(conn *websocket.Conn, limiter *wscore.InboundRateLimiter) (frame *gatewayv2.TerminalClientFrame, denied bool, ok bool) { for { messageType, data, err := conn.ReadMessage() if err != nil { - return nil, false + return nil, false, false + } + if limiter != nil { + if allowed, exceeded := limiter.Allow(); !allowed { + if exceeded { + return nil, false, false + } + return nil, true, true + } } if messageType != websocket.BinaryMessage { + // v2 终端链路上文本帧无意义:已在上方计入限速,忽略。 continue } - var frame gatewayv2.TerminalClientFrame - if err := proto.Unmarshal(data, &frame); err != nil { - return nil, false + var decoded gatewayv2.TerminalClientFrame + if err := proto.Unmarshal(data, &decoded); err != nil { + return nil, false, false } - return &frame, true + return &decoded, false, true } } @@ -196,7 +239,8 @@ func (s *Server) serveTerminalAgent( }() for { - frame, ok := readTerminalFrame(conn) + // Agent 数据面不限速(nil):输出帧无丢帧语义,见 readTerminalFrame。 + frame, _, ok := readTerminalFrame(conn, nil) if !ok { cancel() return @@ -227,6 +271,17 @@ func (s *Server) writeTerminalFrame(conn *websocket.Conn, frame *gatewayv2.Termi // 浏览器角色 // --------------------------------------------------------------------------- +// terminalBrowserMaxTrackedIDs 限制单连接 attach/detach 跟踪的会话/流 id 数量: +// 无上限时任意长度、任意数量的 session_id 会把网关内存打爆(单帧可达 1 MiB)。 +const terminalBrowserMaxTrackedIDs = 512 + +// terminalBrowserMaxRawIDLen 限制 trim 前的原始字段长度:TrimSpace 返回子切片、 +// 底层数组仍持有整帧缓冲区,必须先按原始长度拒绝再规范化。 +const terminalBrowserMaxRawIDLen = 4 * 1024 + +// terminalBrowserMaxIDLen 限制规范化后单个 session_id / stream_id 的长度上限。 +const terminalBrowserMaxIDLen = 512 + type terminalBrowserConn struct { srv *Server sm *session.Manager @@ -239,21 +294,28 @@ type terminalBrowserConn struct { done chan struct{} once sync.Once + // rateLimiter 与主链路同款入站限速(100 帧/秒、突发 200、3 次违规断连)。 + // 由 serveTerminal 在握手前创建并传入:限速自第一帧起生效,浏览器角色握手 + // 后沿用同一预算,攻击者无法借完成握手重置额度。计数点在 ReadMessage 成功 + // 后、反序列化前(见 readTerminalFrame),文本帧与大帧都计入。 + rateLimiter *wscore.InboundRateLimiter + mu sync.RWMutex attached map[string]struct{} streams map[string]struct{} } -func (s *Server) serveTerminalBrowser(conn *websocket.Conn, agentID string) { +func (s *Server) serveTerminalBrowser(conn *websocket.Conn, agentID string, limiter *wscore.InboundRateLimiter) { c := &terminalBrowserConn{ - srv: s, - sm: s.sm, - conn: conn, - agentID: agentID, - out: make(chan []byte, terminalWriteQueueSize), - done: make(chan struct{}), - attached: make(map[string]struct{}), - streams: make(map[string]struct{}), + srv: s, + sm: s.sm, + conn: conn, + agentID: agentID, + out: make(chan []byte, terminalWriteQueueSize), + done: make(chan struct{}), + rateLimiter: limiter, + attached: make(map[string]struct{}), + streams: make(map[string]struct{}), } defer c.close() @@ -261,10 +323,18 @@ func (s *Server) serveTerminalBrowser(conn *websocket.Conn, agentID string) { c.startForwarder() for { - frame, ok := readTerminalFrame(conn) + frame, denied, ok := readTerminalFrame(conn, c.rateLimiter) if !ok { return } + if denied { + // 帧在反序列化前已被丢弃,无 request 上下文可关联,回通用错误帧。 + c.enqueueFrame(terminalErrorFrame( + &gatewayv2.TerminalStreamFrame{Kind: "error"}, + "too many requests", + )) + continue + } streamFrame := frame.GetFrame() if streamFrame == nil { continue @@ -282,7 +352,12 @@ func (c *terminalBrowserConn) handleFrame(frame *gatewayv2.TerminalStreamFrame) switch kind { case "attach": - c.remember(frame.GetSessionId(), frame.GetStreamId()) + // 拒绝的 attach 明确回错误帧且不转发给 Agent:超长原始字段、 + // 规范化后超长或跟踪表已满都不得进入后续链路。 + if message := c.remember(frame.GetSessionId(), frame.GetStreamId()); message != "" { + c.enqueueFrame(terminalErrorFrame(frame, message)) + return + } case "detach": c.forget(frame.GetSessionId(), frame.GetStreamId()) case "input", "resize": @@ -363,20 +438,48 @@ func (c *terminalBrowserConn) shouldForward(frame *gatewayv2.TerminalStreamFrame return c.isAttached(frame.GetSessionId()) } -func (c *terminalBrowserConn) remember(sessionID string, streamID string) { +// remember 登记 attach 的 session/stream id。返回错误信息表示该 attach 必须被 +// 拒绝且不得转发给 Agent(超长原始字段 / 规范化后超长 / 跟踪表已满); +// 返回空串表示登记成功。 +func (c *terminalBrowserConn) remember(sessionID string, streamID string) string { + // 先按原始字段长度拒绝:TrimSpace 返回的是原字符串的子切片,底层数组仍 + // 持有整帧缓冲区(帧上限可达 1 MiB)。若先 trim 再截长度,"接近 1 MiB 的 + // 空白前缀 + 短且唯一的 ID" 会以合法长度进入 map,却让每个 key 保留 + // 接近整帧的内存——这正是要在源头上切断的保留路径。 + if len(sessionID) > terminalBrowserMaxRawIDLen || len(streamID) > terminalBrowserMaxRawIDLen { + return "terminal attach id is too long" + } sessionID = strings.TrimSpace(sessionID) streamID = strings.TrimSpace(streamID) if sessionID == "" && streamID == "" { - return + return "terminal attach id is required" + } + if len(sessionID) > terminalBrowserMaxIDLen || len(streamID) > terminalBrowserMaxIDLen { + return "terminal attach id is too long" + } + // 有界拷贝:切断与整帧缓冲区的引用,map key 的底层内存只保留规范化后的 + // 字节(≤ terminalBrowserMaxIDLen)。 + if sessionID != "" { + sessionID = string([]byte(sessionID)) + } + if streamID != "" { + streamID = string([]byte(streamID)) } c.mu.Lock() + defer c.mu.Unlock() + if sessionID != "" && len(c.attached) >= terminalBrowserMaxTrackedIDs { + return "terminal attach tracking table is full" + } + if streamID != "" && len(c.streams) >= terminalBrowserMaxTrackedIDs { + return "terminal attach tracking table is full" + } if sessionID != "" { c.attached[sessionID] = struct{}{} } if streamID != "" { c.streams[streamID] = struct{}{} } - c.mu.Unlock() + return "" } func (c *terminalBrowserConn) forget(sessionID string, streamID string) { diff --git a/crates/agent-gateway/internal/protocol/pbws/terminal_conn_test.go b/crates/agent-gateway/internal/protocol/pbws/terminal_conn_test.go new file mode 100644 index 000000000..3e2acdaea --- /dev/null +++ b/crates/agent-gateway/internal/protocol/pbws/terminal_conn_test.go @@ -0,0 +1,454 @@ +package pbws + +// 终端数据面浏览器角色的加固:attach/detach 跟踪表有容量与长度上限, +// 超长/超限的 attach 明确拒绝且不转发给 Agent;trim 前按原始长度拒绝并做 +// 有界拷贝,防止 Go 子切片保留整帧缓冲区(接近 1 MiB 的空白前缀 + 短 ID)。 + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "google.golang.org/protobuf/proto" + + "github.com/liveagent/agent-gateway/internal/config" + gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" + "github.com/liveagent/agent-gateway/internal/session" + "github.com/liveagent/agent-gateway/internal/transport/wscore" +) + +func newTestTerminalBrowserConn(sm *session.Manager) *terminalBrowserConn { + if sm == nil { + sm = session.NewManager() + } + return &terminalBrowserConn{ + srv: &Server{sm: sm}, + sm: sm, + agentID: "agent-x", + out: make(chan []byte, terminalWriteQueueSize), + attached: make(map[string]struct{}), + streams: make(map[string]struct{}), + } +} + +func sprintfSessionID(i int) string { + return "session-" + itoa(i) +} + +func sprintfStreamID(i int) string { + return "stream-" + itoa(i) +} + +func itoa(i int) string { + if i == 0 { + return "0" + } + var digits []byte + for i > 0 { + digits = append([]byte{byte('0' + i%10)}, digits...) + i /= 10 + } + return string(digits) +} + +func TestTerminalBrowserRememberCapsTrackedIDs(t *testing.T) { + c := newTestTerminalBrowserConn(nil) + + for i := 0; i < terminalBrowserMaxTrackedIDs; i++ { + if message := c.remember(sprintfSessionID(i), ""); message != "" { + t.Fatalf("remember %d = %q, want accepted", i, message) + } + } + // 容量已满:新 id 必须被明确拒绝,而不是静默丢弃。 + if message := c.remember(sprintfSessionID(terminalBrowserMaxTrackedIDs), ""); message == "" { + t.Fatal("remember over capacity = accepted, want rejection") + } + c.mu.RLock() + attachedCount := len(c.attached) + c.mu.RUnlock() + if attachedCount != terminalBrowserMaxTrackedIDs { + t.Fatalf("attached = %d, want capped at %d", attachedCount, terminalBrowserMaxTrackedIDs) + } + + streamConn := newTestTerminalBrowserConn(nil) + for i := 0; i < terminalBrowserMaxTrackedIDs; i++ { + if message := streamConn.remember("", sprintfStreamID(i)); message != "" { + t.Fatalf("stream remember %d = %q, want accepted", i, message) + } + } + if message := streamConn.remember("", sprintfStreamID(terminalBrowserMaxTrackedIDs)); message == "" { + t.Fatal("stream remember over capacity = accepted, want rejection") + } + streamConn.mu.RLock() + streamsCount := len(streamConn.streams) + streamConn.mu.RUnlock() + if streamsCount != terminalBrowserMaxTrackedIDs { + t.Fatalf("streams = %d, want capped at %d", streamsCount, terminalBrowserMaxTrackedIDs) + } +} + +func TestTerminalBrowserRememberRejectsOversizedIDs(t *testing.T) { + c := newTestTerminalBrowserConn(nil) + + // 空白前缀绕过:trim 前先按原始长度拒绝。trim 后仅 2 字节的短 ID, + // 原始字段却接近帧上限——旧实现会把它作为合法 key 存入 map,并让 + // 子切片保留整帧内存。 + padded := strings.Repeat(" ", terminalBrowserMaxRawIDLen) + "id" + if message := c.remember(padded, ""); message == "" { + t.Fatal("whitespace-padded raw id = accepted, want raw-length rejection") + } + if message := c.remember("", strings.Repeat(" ", terminalBrowserMaxRawIDLen)+"st"); message == "" { + t.Fatal("whitespace-padded raw stream id = accepted, want raw-length rejection") + } + + // 规范化后超长:原始长度合法(512 < 4 KiB)但 trim 后超过 512,同样拒绝。 + if message := c.remember(strings.Repeat("x", terminalBrowserMaxIDLen+1), ""); message == "" { + t.Fatal("trimmed-over-limit id = accepted, want rejection") + } + + c.mu.RLock() + attachedCount := len(c.attached) + streamsCount := len(c.streams) + c.mu.RUnlock() + if attachedCount != 0 || streamsCount != 0 { + t.Fatalf("oversized ids tracked: attached=%d streams=%d", attachedCount, streamsCount) + } + + // 边界长度(原始 512 = 规范化 512)仍然接受。 + if message := c.remember(strings.Repeat("s", terminalBrowserMaxIDLen), ""); message != "" { + t.Fatalf("boundary-length id rejected: %q", message) + } + c.mu.RLock() + attachedCount = len(c.attached) + c.mu.RUnlock() + if attachedCount != 1 { + t.Fatalf("boundary-length id not tracked: attached=%d", attachedCount) + } +} + +// drainTerminalError 从出站队列读取第一条终端帧并返回其 error 文案。 +func drainTerminalError(t *testing.T, c *terminalBrowserConn) string { + t.Helper() + select { + case payload := <-c.out: + var frame gatewayv2.TerminalServerFrame + if err := proto.Unmarshal(payload, &frame); err != nil { + t.Fatalf("unmarshal terminal server frame: %v", err) + } + return frame.GetFrame().GetError() + default: + t.Fatal("expected an error frame on the outbound queue") + return "" + } +} + +func withWebTerminalEnabled(t *testing.T) *session.Manager { + t.Helper() + sm := session.NewManager() + sm.ApplySettingsJSON("agent-x", `{"remote":{"enableWebTerminal":true}}`) + return sm +} + +func TestTerminalBrowserHandleFrameRejectsOversizedAttachWithoutForwarding(t *testing.T) { + sm := withWebTerminalEnabled(t) + c := newTestTerminalBrowserConn(sm) + + // 超长原始字段的 attach:回错误帧、不进跟踪表、不转发给 Agent + // (队列里只有我们的拒绝帧,而不是转发路径的 "agent offline")。 + padded := strings.Repeat(" ", terminalBrowserMaxRawIDLen) + "id" + c.handleFrame(&gatewayv2.TerminalStreamFrame{ + Kind: "attach", + SessionId: padded, + StreamId: "stream-1", + }) + if got := drainTerminalError(t, c); !strings.Contains(got, "too long") { + t.Fatalf("attach error = %q, want length rejection", got) + } + c.mu.RLock() + attachedCount := len(c.attached) + c.mu.RUnlock() + if attachedCount != 0 { + t.Fatalf("oversized attach tracked: attached=%d", attachedCount) + } + + // 对照:合法 attach 走到转发步,因没有 Agent 会话得到 "offline" 错误—— + // 证明合法路径未被误伤。 + c.handleFrame(&gatewayv2.TerminalStreamFrame{ + Kind: "attach", + SessionId: "session-ok", + StreamId: "stream-ok", + }) + if got := drainTerminalError(t, c); !strings.Contains(got, "offline") { + t.Fatalf("valid attach error = %q, want forward-path offline error", got) + } + c.mu.RLock() + attachedCount = len(c.attached) + c.mu.RUnlock() + if attachedCount != 1 { + t.Fatalf("valid attach not tracked: attached=%d", attachedCount) + } +} + +func TestTerminalBrowserHandleFrameRejectsFullTrackingTable(t *testing.T) { + sm := withWebTerminalEnabled(t) + c := newTestTerminalBrowserConn(sm) + + for i := 0; i < terminalBrowserMaxTrackedIDs; i++ { + if message := c.remember(sprintfSessionID(i), ""); message != "" { + t.Fatalf("seed remember %d = %q, want accepted", i, message) + } + } + + c.handleFrame(&gatewayv2.TerminalStreamFrame{ + Kind: "attach", + SessionId: "session-overflow", + StreamId: "", + }) + if got := drainTerminalError(t, c); !strings.Contains(got, "full") { + t.Fatalf("overflow attach error = %q, want capacity rejection", got) + } + if c.isAttached("session-overflow") { + t.Fatal("overflow attach tracked despite full table") + } +} + +// --------------------------------------------------------------------------- +// 入站限速绑定回归:文本帧计数 + 限速先于反序列化 + 握手边界(websocket 级) +// --------------------------------------------------------------------------- + +// frameReadResult 记录一次 readTerminalFrame / readFrame 调用的三态结果。 +type frameReadResult struct { + gotFrame bool + denied bool + ok bool +} + +// wsTestPair 建立一个仅升级的测试服务:服务端 conn 交给 serve(handler 内 +// 同步执行),返回客户端 conn。清理顺序:先关客户端(服务端读循环随之出错 +// 返回),再关 httptest server,避免 Close 等待挂起的 handler。 +func wsTestPair(t *testing.T, serve func(*websocket.Conn)) *websocket.Conn { + t.Helper() + upgrader := websocket.Upgrader{} + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + serve(conn) + })) + t.Cleanup(ts.Close) + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + client, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial test ws: %v", err) + } + t.Cleanup(func() { _ = client.Close() }) + return client +} + +func mustMarshalTerminalClientFrame(t *testing.T) []byte { + t.Helper() + data, err := proto.Marshal(&gatewayv2.TerminalClientFrame{ + Payload: &gatewayv2.TerminalClientFrame_Frame{ + Frame: &gatewayv2.TerminalStreamFrame{Kind: "input"}, + }, + }) + if err != nil { + t.Fatalf("marshal terminal client frame: %v", err) + } + return data +} + +// 文本帧必须与二进制帧同罪计入限速:旧实现中文本帧直接 continue,洪泛零成本。 +func TestReadTerminalFrameCountsTextFramesAgainstRateLimit(t *testing.T) { + results := make(chan frameReadResult, 3) + // 突发 2、几乎不回填、3 次连续违规判死:两帧文本即耗尽全部预算。 + limiter := wscore.NewInboundRateLimiter(0.0001, 2, 3) + client := wsTestPair(t, func(conn *websocket.Conn) { + defer func() { _ = conn.Close() }() + for i := 0; i < 3; i++ { + frame, denied, ok := readTerminalFrame(conn, limiter) + results <- frameReadResult{gotFrame: frame != nil, denied: denied, ok: ok} + if !ok { + return + } + } + }) + + valid := mustMarshalTerminalClientFrame(t) + writes := []struct { + messageType int + data []byte + }{ + {websocket.TextMessage, []byte("junk-one")}, + {websocket.TextMessage, []byte("junk-two")}, + {websocket.BinaryMessage, valid}, + {websocket.BinaryMessage, valid}, + {websocket.BinaryMessage, valid}, + } + for _, write := range writes { + if err := client.WriteMessage(write.messageType, write.data); err != nil { + t.Fatalf("write frame: %v", err) + } + } + + // 两帧文本耗尽突发额度后:第 3 帧(合法二进制)必须被限速丢弃—— + // 旧实现中文本帧不计数,此帧会被放行。 + first := <-results + if !first.denied || !first.ok || first.gotFrame { + t.Fatalf("first read = %+v, want denied-but-alive (text frames must consume budget)", first) + } + second := <-results + if !second.denied || !second.ok || second.gotFrame { + t.Fatalf("second read = %+v, want denied-but-alive", second) + } + // 3 次连续违规判死。 + third := <-results + if third.ok { + t.Fatalf("third read = %+v, want connection closed after 3 consecutive violations", third) + } +} + +// 限速必须发生在 proto 反序列化之前:超限的非法帧在解析前即被丢弃,连接存活; +// 旧顺序(先解析后限速)下非法帧直接破坏帧流、关闭连接。 +func TestReadTerminalFrameLimitsBeforeUnmarshal(t *testing.T) { + results := make(chan frameReadResult, 3) + // 突发 1、100 帧/秒回填(10ms 一令牌)、违规阈值放宽,隔离限速与判死。 + limiter := wscore.NewInboundRateLimiter(100, 1, 10) + client := wsTestPair(t, func(conn *websocket.Conn) { + defer func() { _ = conn.Close() }() + for i := 0; i < 3; i++ { + frame, denied, ok := readTerminalFrame(conn, limiter) + results <- frameReadResult{gotFrame: frame != nil, denied: denied, ok: ok} + if !ok { + return + } + } + }) + + valid := mustMarshalTerminalClientFrame(t) + // 帧 1:合法,消耗唯一令牌。 + if err := client.WriteMessage(websocket.BinaryMessage, valid); err != nil { + t.Fatalf("write valid frame: %v", err) + } + // 帧 2:非法 protobuf,立即送达(令牌未回填,必被限速)。若限速发生在 + // 反序列化之后,此帧会因解析失败直接关闭连接,帧 3 永远读不到。 + if err := client.WriteMessage(websocket.BinaryMessage, []byte{0xff, 0xff, 0xff}); err != nil { + t.Fatalf("write invalid frame: %v", err) + } + // 等令牌回填后帧 3 必须仍然可读、可解析。 + time.Sleep(30 * time.Millisecond) + if err := client.WriteMessage(websocket.BinaryMessage, valid); err != nil { + t.Fatalf("write third frame: %v", err) + } + + first := <-results + if !first.gotFrame || !first.ok || first.denied { + t.Fatalf("first read = %+v, want parsed frame", first) + } + second := <-results + if !second.denied || !second.ok || second.gotFrame { + t.Fatalf("second read = %+v, want denied-but-alive (rate limit must precede unmarshal)", second) + } + third := <-results + if !third.gotFrame || !third.ok || third.denied { + t.Fatalf("third read = %+v, want parsed frame after refill", third) + } +} + +// 握手前洪泛文本帧必须被判死关闭:旧实现 pre-hello 文本帧直接 continue, +// 连接可零成本无限占住 terminal slot。 +func TestTerminalHandlerClosesPreHelloTextFlood(t *testing.T) { + srv := &Server{cfg: &config.Config{Token: "dev-token"}, sm: session.NewManager()} + ts := httptest.NewServer(srv.TerminalHandler()) + defer ts.Close() + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial terminal: %v", err) + } + defer func() { _ = conn.Close() }() + + // 突发 200 + 3 次连续违规:300 帧洪泛必然越线。 + for i := 0; i < 300; i++ { + if err := conn.WriteMessage(websocket.TextMessage, []byte("flood")); err != nil { + break // 服务端已关闭,后续写入失败属预期 + } + } + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + if _, _, err := conn.ReadMessage(); err == nil { + t.Fatal("connection survived a 300-frame pre-hello text flood, want closed") + } +} + +// 握手绝对时间边界:完全沉默的连接也必须在超时内被关闭。 +func TestTerminalHandshakeDeadlineClosesSilentConnection(t *testing.T) { + old := terminalHandshakeTimeout + terminalHandshakeTimeout = 50 * time.Millisecond + defer func() { terminalHandshakeTimeout = old }() + + srv := &Server{cfg: &config.Config{Token: "dev-token"}, sm: session.NewManager()} + ts := httptest.NewServer(srv.TerminalHandler()) + defer ts.Close() + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial terminal: %v", err) + } + defer func() { _ = conn.Close() }() + + // 不发任何帧:旧实现下可无限挂住 slot;50ms 握手超时后必须被关闭。 + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + if _, _, err := conn.ReadMessage(); err == nil { + t.Fatal("silent pre-hello connection was not closed by the handshake deadline") + } +} + +// 正路回归:合法浏览器 hello 在新握手路径(限速器 + 截止时间)下照常放行。 +func TestTerminalHandlerBrowserHelloAccepted(t *testing.T) { + srv := &Server{cfg: &config.Config{Token: "dev-token"}, sm: session.NewManager()} + ts := httptest.NewServer(srv.TerminalHandler()) + defer ts.Close() + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial terminal: %v", err) + } + defer func() { _ = conn.Close() }() + + hello, err := proto.Marshal(&gatewayv2.TerminalClientFrame{ + Payload: &gatewayv2.TerminalClientFrame_Hello{ + Hello: &gatewayv2.ClientHello{ + ProtocolVersion: ProtocolVersion, + Role: gatewayv2.ClientRole_CLIENT_ROLE_BROWSER, + AgentId: "agent-x", + Token: "dev-token", + }, + }, + }) + if err != nil { + t.Fatalf("marshal hello: %v", err) + } + if err := conn.WriteMessage(websocket.BinaryMessage, hello); err != nil { + t.Fatalf("write hello: %v", err) + } + + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, payload, err := conn.ReadMessage() + if err != nil { + t.Fatalf("read server hello: %v", err) + } + var frame gatewayv2.TerminalServerFrame + if err := proto.Unmarshal(payload, &frame); err != nil { + t.Fatalf("unmarshal server hello: %v", err) + } + if !frame.GetHello().GetOk() { + t.Fatalf("browser hello rejected: %q", frame.GetHello().GetMessage()) + } +} diff --git a/crates/agent-gateway/internal/server/http.go b/crates/agent-gateway/internal/server/http.go index 416508c82..bae6f07bf 100644 --- a/crates/agent-gateway/internal/server/http.go +++ b/crates/agent-gateway/internal/server/http.go @@ -60,6 +60,10 @@ func NewHTTPServer(cfg *config.Config, sm *session.Manager, tokens *agenttoken.S serveIndex := func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Header().Set("Content-Type", "text/html; charset=utf-8") + // 防点击劫持:WebUI token 持久化在 localStorage 且页面加载即自动登录, + // 第三方站点 iframe 嵌入会渲染完整聊天/终端界面;禁 frame 内嵌。 + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Content-Security-Policy", "frame-ancestors 'none'") http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(indexHTML)) } diff --git a/crates/agent-gateway/internal/server/http_test.go b/crates/agent-gateway/internal/server/http_test.go index adaf242b3..e52ff8ac8 100644 --- a/crates/agent-gateway/internal/server/http_test.go +++ b/crates/agent-gateway/internal/server/http_test.go @@ -34,6 +34,13 @@ func TestNewHTTPServerServesRootWithoutRedirect(t *testing.T) { if cacheControl := rec.Header().Get("Cache-Control"); !strings.Contains(cacheControl, "no-store") { t.Fatalf("Cache-Control = %q, want no-store for index.html", cacheControl) } + // 防点击劫持:WebUI token 自动登录,禁 iframe 内嵌。 + if got := rec.Header().Get("X-Frame-Options"); got != "DENY" { + t.Fatalf("X-Frame-Options = %q, want DENY", got) + } + if got := rec.Header().Get("Content-Security-Policy"); got != "frame-ancestors 'none'" { + t.Fatalf("CSP = %q, want frame-ancestors 'none'", got) + } } func TestNewHTTPServerServesSpaFallbackWithoutRedirect(t *testing.T) { diff --git a/crates/agent-gateway/test/websocket/v2_git_gating_test.go b/crates/agent-gateway/test/websocket/v2_git_gating_test.go index afe619577..736654a98 100644 --- a/crates/agent-gateway/test/websocket/v2_git_gating_test.go +++ b/crates/agent-gateway/test/websocket/v2_git_gating_test.go @@ -77,6 +77,29 @@ func TestV2GitRejectsWriteRequestsWhenDisabled(t *testing.T) { } } +func TestV2GitRejectsCaseMutatedWriteActionsWhenDisabled(t *testing.T) { + t.Parallel() + + _, _, conn, cleanup := newV2GitBrowserTest(t, false) + defer cleanup() + + // 大小写变体:桌面端对 action 做 to_ascii_lowercase 后执行,网关门控必须 + // 先归一化,否则 CLONE_START 等变体被当作读操作放行却在桌面端执行写操作。 + for _, action := range []string{"CLONE_START", "Push", "COMMIT", "Stage", "Clone"} { + id := "git-case-" + action + sendGitAgentRequest(t, conn, id, action) + + frame := receiveWebFrameWithID(t, conn, id) + localError := frame.GetLocalError() + if localError == nil { + t.Fatalf("git %s reply = %#v, want local_error", action, frame) + } + if !strings.Contains(localError.GetMessage(), "web git is disabled") { + t.Fatalf("git %s error = %q, want web git disabled message", action, localError.GetMessage()) + } + } +} + func TestV2GitAllowsReadRequestsWhenDisabled(t *testing.T) { t.Parallel() diff --git a/crates/agent-gateway/web/src/app/hooks/useGatewayHistoryReconciliation.ts b/crates/agent-gateway/web/src/app/hooks/useGatewayHistoryReconciliation.ts index 7c3dbb5e3..303ea425f 100644 --- a/crates/agent-gateway/web/src/app/hooks/useGatewayHistoryReconciliation.ts +++ b/crates/agent-gateway/web/src/app/hooks/useGatewayHistoryReconciliation.ts @@ -109,7 +109,7 @@ export function useGatewayHistoryReconciliation({ if (!conversationId) return; const windowStates = historyWindowStatesRef.current; const windowState = windowStates.get(conversationId); - if (windowState) { + if (windowState && event.conversation) { const noted = noteHistoryWindowTotal(windowState, event.conversation.message_count); if (noted !== windowState) windowStates.set(conversationId, noted); } diff --git a/crates/agent-gateway/web/src/lib/sidebar/webSidebarBackend.ts b/crates/agent-gateway/web/src/lib/sidebar/webSidebarBackend.ts index 207e515f6..753420ef1 100644 --- a/crates/agent-gateway/web/src/lib/sidebar/webSidebarBackend.ts +++ b/crates/agent-gateway/web/src/lib/sidebar/webSidebarBackend.ts @@ -208,6 +208,13 @@ export function createWebSidebarBackend(deps: WebSidebarBackendDeps): SidebarBac listener({ kind: "delete", conversationId }); return; } + // 网关可能发出不带 conversation 对象的 upsert(会话隔离/迁移场景, + // adapters 不生成该键)。normalizeGatewayConversationSummary 对 + // undefined 会抛 TypeError,中断 emitHistory 对后续监听者的投递, + // 这里直接跳过该帧。 + if (!event.conversation) { + return; + } listener({ kind: "upsert", conversationId, diff --git a/crates/agent-gateway/web/test/web-sidebar-backend.test.mjs b/crates/agent-gateway/web/test/web-sidebar-backend.test.mjs index 3f9d09798..658923eba 100644 --- a/crates/agent-gateway/web/test/web-sidebar-backend.test.mjs +++ b/crates/agent-gateway/web/test/web-sidebar-backend.test.mjs @@ -262,6 +262,47 @@ test("subscribeEvents forwards history events normalized and bridges activity di assert.equal(state.historyListeners.size, 0); }); +test("subscribeEvents skips upserts without a conversation object instead of crashing", () => { + const { api, state, emitHistory } = createFakeApi(); + const activityStore = createActivityStore(); + const backend = createWebSidebarBackend({ + api, + activityStore, + getProtectedConversationIds: () => [], + }); + + const events = []; + backend.subscribeEvents((event) => events.push(event)); + + // 网关合法路径可发出不带 conversation 的 upsert(会话隔离/迁移,adapters + // 不生成该键);此前 normalizeGatewayConversationSummary(undefined) 抛 + // TypeError,中断 emitHistory 对后续监听者的投递。修复后该帧被跳过, + // 后续事件照常到达。 + emitHistory({ kind: "upsert", conversation_id: "conv-a" }); + assert.deepEqual(events, [], "upsert without conversation must be skipped"); + + emitHistory({ + kind: "upsert", + conversation_id: "c1", + conversation: summary("c1", { updated_at: SECONDS + 9 }), + }); + assert.equal(events.length, 1); + assert.equal(events[0].kind, "upsert"); + assert.equal(events[0].conversationId, "c1"); + + // 第二个监听者注册在异常帧之后:不得被先行抛出的异常饿死。 + const second = []; + backend.subscribeEvents((event) => second.push(event)); + emitHistory({ + kind: "upsert", + conversation_id: "c2", + conversation: summary("c2"), + }); + assert.equal(second.length, 1); + assert.equal(second[0].conversationId, "c2"); + assert.equal(events.length, 2); +}); + test("subscribeEvents seeds the already-running set on attach", () => { const { api } = createFakeApi(); const activityStore = createActivityStore(); diff --git a/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs b/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs index f3fa6009c..54d2abafd 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs @@ -1483,6 +1483,19 @@ fn redact_builtin_tool_content_json(raw: &str) -> Result { ); } } + // 归档段摘要(role=summary)是 LLM 对含工具输出的历史窗口的浓缩, + // 其提示词要求原文保留路径/命令/错误信息——无法可靠剥离工具内容, + // 在 redact 语义下整体替换,避免泄露绕过。 + Some("summary") => { + object.insert( + "content".to_string(), + json!([{ "type": "text", "text": "历史段摘要已脱敏" }]), + ); + object.insert( + "details".to_string(), + json!({ "kind": "redacted_tool_content" }), + ); + } _ => {} } } @@ -2053,6 +2066,42 @@ mod tests { assert_eq!(items[3]["details"]["kind"], "redacted_tool_content"); } + #[test] + fn redact_builtin_tool_content_redacts_archived_segment_summaries() { + // role=summary 是 LLM 对含工具输出的归档段的浓缩(提示词要求原文保留 + // 路径/命令/错误信息);redact 语义下必须整体替换,否则分享泄露绕过。 + let raw = serde_json::to_string(&json!([ + { + "role": "user", + "content": [{ "type": "text", "text": "normal message" }] + }, + { + "role": "summary", + "content": "用户运行 `cat /home/alice/.ssh/id_rsa`,输出为 SECRET-KEY-12345,错误信息: Permission denied /etc/passwd" + } + ])) + .expect("serialize input"); + + let redacted = redact_builtin_tool_content_json(&raw).expect("redact builtin tool content"); + let parsed = serde_json::from_str::(&redacted).expect("parse redacted output"); + let items = parsed.as_array().expect("redacted history array"); + + assert_eq!(items[0]["content"][0]["text"], "normal message"); + let summary = &items[1]; + assert_eq!(summary["role"], "summary"); + assert_eq!(summary["content"][0]["text"], "历史段摘要已脱敏"); + assert_eq!(summary["details"]["kind"], "redacted_tool_content"); + let serialized = serde_json::to_string(&items[1]).expect("serialize summary"); + assert!( + !serialized.contains("SECRET-KEY-12345"), + "summary tool content leaked: {serialized}" + ); + assert!( + !serialized.contains("/home/alice"), + "summary path leaked: {serialized}" + ); + } + #[test] fn shared_chat_history_builtin_policy_covers_the_tool_catalog() { let catalog = include_str!("../../../../agent-ui/src/lib/tools/builtinToolCatalog.ts"); diff --git a/crates/agent-gui/src-tauri/src/services/proxy.rs b/crates/agent-gui/src-tauri/src/services/proxy.rs index 0fadcd61a..ab7071b81 100644 --- a/crates/agent-gui/src-tauri/src/services/proxy.rs +++ b/crates/agent-gui/src-tauri/src/services/proxy.rs @@ -1,5 +1,5 @@ use std::{ - net::{Ipv4Addr, TcpListener}, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpListener, ToSocketAddrs}, sync::Arc, time::Duration, }; @@ -13,7 +13,10 @@ use axum::{ Router, }; use base64::Engine as _; -use reqwest::Url; +use reqwest::{ + dns::{Addrs, Name, Resolve, Resolving}, + Url, +}; use serde::{Deserialize, Serialize}; use serde_json::Value; use tokio::net::TcpListener as TokioTcpListener; @@ -125,21 +128,37 @@ pub fn start_proxy_server() -> Result, String> { } async fn handle_image_proxy(Query(query): Query, headers: HeaderMap) -> Response { + // 纵深防御:非 WebView 来源的 fetch(恶意网页)直接拒绝。 + if !image_proxy_origin_allowed(&headers) { + return error_response( + StatusCode::FORBIDDEN, + "Image proxy origin is not allowed", + &headers, + ); + } let target_url = match validate_image_proxy_url(&query.url) { Ok(url) => url, Err(message) => return error_response(StatusCode::BAD_REQUEST, &message, &headers), }; + // 连接前解析预检:命中黑名单在出网前快速 400(也是代理模式下的尽力校验—— + // HTTP 代理由代理端解析目标,客户端无法绑定,见 ImageProxyResolver 注释)。 + if image_proxy_host_resolves_to_blocked(&target_url).await { + return error_response( + StatusCode::BAD_REQUEST, + "Image URL host resolves to a blocked IP range", + &headers, + ); + } - // 图片外链与商店链路同语义:恒随应用代理出网(未启用=直连,配置异常 - // 502 fail fast)。 请求无法携带自定义头,因此不走 per-request 开关。 - let client = match crate::services::system_proxy::cached_client() { + // 图片外链与商店链路同语义:恒随应用代理出网(未启用=直连,配置异常 + // 502 fail fast)。 请求无法携带自定义头,因此不走 per-request 开关。 + // 终审在 build_image_proxy_client 安装的安全 resolver:拨号所用地址即 + // 本轮已审核地址,重定向每一跳的新连接自动重审;redirect policy 做每跳 + // 预检并拦截不经 DNS 的字面 IP 目标。 + let client = match build_image_proxy_client() { Ok(client) => client, Err(error) => { - return error_response( - StatusCode::BAD_GATEWAY, - &format!("App proxy unavailable: {error}"), - &headers, - ); + return error_response(StatusCode::BAD_GATEWAY, &error, &headers); } }; let image_request = client @@ -240,9 +259,268 @@ fn validate_image_proxy_url(raw: &str) -> Result { "Image URL must be a valid absolute URL without embedded credentials".to_string(), ); } + // SSRF 防护:拒绝指向本机/内网/云元数据/保留段的字面 IP 目标(与网关 Go 侧 + // outbound_http.go 的 blocked prefixes 对齐)。本地代理以应用身份出网, + // 若放行 127.0.0.1 / 169.254.169.254 等目标,任何本机页面或提示注入的 + // 模型输出图片 URL 都能用它探测/访问内网服务。注意:图片外链()请求 + // 不带 Origin,来源校验挡不住,此处是唯一且必须的主防线。 + if let Some(host) = url.host_str() { + let host = host.trim_start_matches('[').trim_end_matches(']'); + // 回环主机名(无拨号时 DNS 检查,字面拦截,与 Go 侧 127.0.0.0/8 等价)。 + let host_lower = host.to_ascii_lowercase(); + if host_lower == "localhost" || host_lower == "localhost.localdomain" { + return Err("Image URL host is in a blocked IP range".to_string()); + } + if let Ok(ip) = host.parse::() { + if is_blocked_image_proxy_ip(ip) { + return Err("Image URL host is in a blocked IP range".to_string()); + } + } + } Ok(url) } +/// 判断 IPv4 字面地址是否属于禁止出网访问的段(与 Go 侧 outbound_http.go 对齐, +/// 出站代理语义:回环/私网/link-local/多播/保留全部拒绝)。 +fn is_blocked_image_proxy_ipv4(ip: std::net::Ipv4Addr) -> bool { + let octets = ip.octets(); + let [a, b, c, _] = octets; + // 0.0.0.0/8、10.0.0.0/8、127.0.0.0/8、169.254.0.0/16、172.16.0.0/12、 + // 192.0.0.0/24、192.0.2.0/24、192.88.99.0/24、192.168.0.0/16、 + // 198.18.0.0/15、198.51.100.0/24、203.0.113.0/24、224.0.0.0/4、 + // 240.0.0.0/4(含广播)、100.64.0.0/10 + (a == 0) + || (a == 10) + || (a == 100 && (64..=127).contains(&b)) + || (a == 127) + || (a == 169 && b == 254) + || (a == 172 && (16..=31).contains(&b)) + || (a == 192 && b == 0 && c == 0) + || (a == 192 && b == 0 && c == 2) + || (a == 192 && b == 88 && c == 99) + || (a == 192 && b == 168) + || (a == 198 && (18..=19).contains(&b)) + || (a == 198 && b == 51 && c == 100) + || (a == 203 && b == 0 && c == 113) + || (a >= 224) +} + +/// 统一入口:IPv4-mapped IPv6(::ffff:a.b.c.d)先 unmap 还原为 IPv4 再走 +/// IPv4 黑名单(与网关 Go 侧 outbound_http.go 的 Unmap() 先例一致)。 +fn is_blocked_image_proxy_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => is_blocked_image_proxy_ipv4(v4), + IpAddr::V6(v6) => match v6.to_ipv4_mapped() { + Some(v4) => is_blocked_image_proxy_ipv4(v4), + None => is_blocked_image_proxy_ipv6(v6), + }, + } +} + +fn ipv6_in_range(addr: Ipv6Addr, network: Ipv6Addr, prefix_len: u32) -> bool { + if prefix_len == 0 { + return true; + } + let mask = u128::MAX << (128 - prefix_len); + (u128::from_be_bytes(addr.octets()) & mask) == (u128::from_be_bytes(network.octets()) & mask) +} + +/// IPv6 禁止段,与 Go 侧 outbound_http.go 的 blocked prefixes 逐项对齐: +/// 未指定/回环、NAT64、Discard-only、Teredo/文档段、6to4、ULA、link-local、多播。 +fn is_blocked_image_proxy_ipv6(ip: Ipv6Addr) -> bool { + const BLOCKED_PREFIXES: &[(&str, u32)] = &[ + ("::", 128), + ("::1", 128), + ("64:ff9b::", 96), + ("64:ff9b:1::", 48), + ("100::", 64), + ("2001::", 23), + ("2001::", 32), + ("2001:2::", 48), + ("2001:10::", 28), + ("2001:20::", 28), + ("2001:db8::", 32), + ("2002::", 16), + ("3fff::", 20), + ("5f00::", 16), + ("fc00::", 7), + ("fe80::", 10), + ("ff00::", 8), + ]; + BLOCKED_PREFIXES.iter().any(|(network, prefix_len)| { + // 静态字面量在编译期已由 Go 侧同表验证格式,parse 不会失败。 + let network = network + .parse::() + .expect("static blocked ipv6 prefix must parse"); + ipv6_in_range(ip, network, *prefix_len) + }) +} + +/// 生产底层解析:系统 getaddrinfo(tokio 异步封装),与 reqwest 默认 GAI 语义一致。 +/// 端口填 0——hyper 拨号时按 URL 的显式端口或 scheme 默认端口覆盖。 +struct SystemResolver; + +impl Resolve for SystemResolver { + fn resolve(&self, name: Name) -> Resolving { + let host = name.as_str().to_string(); + Box::pin(async move { + let addresses: Vec = + tokio::net::lookup_host((host.as_str(), 0)).await?.collect(); + Ok(Box::new(addresses.into_iter()) as Addrs) + }) + } +} + +/// image-proxy 专用安全 resolver:把 IP 黑名单放进 reqwest 实际拨号使用的 DNS +/// 解析路径,堵住预检与连接脱钩的 DNS rebinding 缺口(预检返回公网、连接时 +/// 改答 127.0.0.1/RFC1918/云元数据)。 +/// +/// 绑定原理:hyper 对主机名目标每次新建连接都调用本 resolver,且只拨号本轮 +/// 回填的地址——审核结果与连接目标一一对应,不存在第二次独立解析。重定向 +/// 跨 origin 的每一跳必然新建连接,自动重走本 resolver 再审;同 origin 跳转 +/// 复用的是当初已过审的连接本身,无重新解析窗口。任一地址命中黑名单即整体 +/// fail-closed(不允许 hyper 从混合答案里挑出内网地址),解析失败/空答案同样拒绝。 +/// +/// 边界说明: +/// - 字面 IP 目标不经 resolver(hyper 短路),由 validate_image_proxy_url 与 +/// 重定向策略的字面量检查拦截,两层缺一不可。 +/// - 应用代理启用时,socks5 已由 async_client_builder_local_dns 翻转为本地 +/// 解析(经本 resolver 审核后把 IP 交给代理);HTTP 代理(CONNECT/absolute- +/// URI)协议只携带主机名,目标解析在代理端完成,客户端无法绑定——残余窗口 +/// 由前置预检 + 重定向策略尽力收缩,触达面限于代理所在网络位置。 +/// - proxy_host 豁免:已启用代理的主机名来自用户设置(非攻击者输入),其解析 +/// 不走黑名单,否则指向回环/内网的代理端点会让图片代理整体不可用。图片 +/// URL 本身指向代理主机时仍被前置预检/重定向策略拦截,豁免不放大攻击面。 +struct ImageProxyResolver { + inner: Arc, + block: Arc bool + Send + Sync>, + proxy_host: Option, +} + +impl ImageProxyResolver { + fn production(proxy_host: Option) -> Self { + Self { + inner: Arc::new(SystemResolver), + block: Arc::new(is_blocked_image_proxy_ip), + proxy_host, + } + } +} + +impl Resolve for ImageProxyResolver { + fn resolve(&self, name: Name) -> Resolving { + let inner = Arc::clone(&self.inner); + let block = Arc::clone(&self.block); + let exempt = self + .proxy_host + .as_deref() + .is_some_and(|proxy| proxy.eq_ignore_ascii_case(name.as_str())); + Box::pin(async move { + let host = name.as_str().to_string(); + let addresses: Vec = inner.resolve(name).await?.collect(); + // fail-closed:空答案与解析失败同罪,绝不让 connector 拿到未审地址。 + if addresses.is_empty() { + return Err(format!("image proxy resolved no addresses for {host}").into()); + } + if !exempt && addresses.iter().any(|addr| block(addr.ip())) { + return Err( + format!("image proxy host {host} resolves to a blocked IP range").into(), + ); + } + Ok(Box::new(addresses.into_iter()) as Addrs) + }) + } +} + +/// 构造 image-proxy 专用 client:安全 resolver(拨号前终审)+ 重定向策略(每跳 +/// 预检)。reqwest 0.13 的重定向策略是 client 级配置,client 每次请求新建, +/// 与修改前语义一致。 +fn build_image_proxy_client() -> Result { + let (builder, proxy_host) = crate::services::system_proxy::async_client_builder_local_dns() + .map_err(|error| format!("App proxy unavailable: {error}"))?; + builder + .dns_resolver(ImageProxyResolver::production(proxy_host)) + .redirect(reqwest::redirect::Policy::custom(|attempt| { + if validate_image_proxy_redirect_target(attempt.url()) { + attempt.follow() + } else { + attempt.stop() + } + })) + .build() + .map_err(|error| format!("App proxy unavailable: {error}")) +} + +/// 解析目标主机的全部地址,任一命中黑名单即拒绝(fail-closed:解析失败也拒绝)。 +/// 覆盖 DNS rebinding——攻击者域名可解析到回环/内网地址,字面校验挡不住。 +async fn image_proxy_host_resolves_to_blocked(url: &Url) -> bool { + let Some(host) = url.host_str() else { + return true; + }; + let host = host.trim_start_matches('[').trim_end_matches(']'); + // 字面 IP 已在 validate_image_proxy_url 中校验,这里只处理主机名。 + if host.parse::().is_ok() { + return false; + } + let port = url.port_or_known_default().unwrap_or(80); + let addresses = match tokio::net::lookup_host((host, port)).await { + Ok(addresses) => addresses, + Err(_) => return true, + }; + addresses + .into_iter() + .any(|address| is_blocked_image_proxy_ip(address.ip())) +} + +/// 重定向目标的校验(每次跳转都重新过一遍):scheme/凭据/字面 IP/主机名解析 +/// 全部地址。主机名解析为同步阻塞调用——重定向跳数极少且受 OS DNS 超时约束, +/// 在 reqwest 同步 redirect policy 回调内是唯一可行形态;失败一律拒绝(fail-closed)。 +fn validate_image_proxy_redirect_target(url: &Url) -> bool { + if !matches!(url.scheme(), "http" | "https") { + return false; + } + if !url.username().is_empty() || url.password().is_some() { + return false; + } + let Some(host) = url.host_str() else { + return false; + }; + let host = host.trim_start_matches('[').trim_end_matches(']'); + let host_lower = host.to_ascii_lowercase(); + if host_lower == "localhost" || host_lower == "localhost.localdomain" { + return false; + } + if let Ok(ip) = host.parse::() { + return !is_blocked_image_proxy_ip(ip); + } + let port = url.port_or_known_default().unwrap_or(80); + match (host, port).to_socket_addrs() { + Ok(addresses) => !addresses + .into_iter() + .any(|address| is_blocked_image_proxy_ip(address.ip())), + Err(_) => false, + } +} + +/// 本地来源校验:image-proxy 端点无 token( 无法携带自定义头),以 Origin +/// 白名单作纵深防御——只允许空 Origin(非浏览器)、Tauri WebView 来源或同源 +/// 请求;恶意网页的 fetch 携带其自身 Origin,会被拒绝。注意浏览器 请求 +/// 不带 Origin,该检查不拦截 img 路径(由上面的 IP 黑名单兜底)。 +fn image_proxy_origin_allowed(headers: &HeaderMap) -> bool { + let Some(origin) = headers.get(ORIGIN).and_then(|value| value.to_str().ok()) else { + return true; + }; + let origin = origin.trim(); + if origin.is_empty() { + return true; + } + match origin.to_ascii_lowercase().as_str() { + // Tauri WebView 的来源(Windows/macOS/Linux 桌面)。 + "tauri://localhost" | "http://tauri.localhost" | "https://tauri.localhost" => true, + _ => false, + } +} + fn image_proxy_referer(target_url: &Url) -> String { format!("{}/", target_url.origin().ascii_serialization()) } @@ -868,6 +1146,135 @@ mod tests { assert!(validate_image_proxy_url("https://user:pass@example.com/photo.png").is_err()); } + #[test] + fn rejects_ssrf_targets_in_image_proxy_urls() { + // 回环 / 私网 / 云元数据 / link-local / 多播 / 保留 / 广播段全部拒绝。 + for value in [ + "http://127.0.0.1:3000/admin", + "http://localhost:3000/admin", + "http://[::1]:5173", + "http://10.0.0.5:80/", + "http://172.16.0.1:80/", + "http://192.168.1.1:80/", + "http://169.254.169.254/latest/meta-data/", + "http://169.254.170.2/", + "http://100.64.0.1:80/", + "http://224.0.0.1:80/", + "http://255.255.255.255:80/", + "http://0.0.0.0:80/", + "http://192.0.2.1:80/", + "http://198.51.100.1:80/", + "http://203.0.113.1:80/", + ] { + assert!(validate_image_proxy_url(value).is_err(), "{value}"); + } + + // 公网字面 IP 与域名不受影响。 + assert!(validate_image_proxy_url("http://8.8.8.8:8080/photo.png").is_ok()); + assert!(validate_image_proxy_url("https://example.com/photo.png").is_ok()); + } + + #[test] + fn rejects_ipv6_and_mapped_ssrf_targets_in_image_proxy_urls() { + // IPv6 禁止段:ULA / link-local / 多播 / 未指定 / NAT64 / 文档段。 + for value in [ + "http://[fc00::1]:80/", + "http://[fd00:abcd::1]:80/", + "http://[fe80::1]:80/", + "http://[ff02::1]:80/", + "http://[::]:80/", + "http://[64:ff9b::1]:80/", + "http://[2001:db8::1]:80/", + "http://[2002:7f00:1::1]:80/", + ] { + assert!(validate_image_proxy_url(value).is_err(), "{value}"); + } + + // IPv4-mapped IPv6 先 unmap 再走 IPv4 黑名单: + // ::ffff:127.0.0.1 / ::ffff:169.254.169.254 不得绕过。 + for value in [ + "http://[::ffff:127.0.0.1]:80/", + "http://[::ffff:169.254.169.254]:80/latest/meta-data/", + "http://[::ffff:10.0.0.5]:80/", + "http://[::ffff:192.168.1.1]:80/", + ] { + assert!(validate_image_proxy_url(value).is_err(), "{value}"); + } + + // 公网 IPv6 与 mapped 公网 IPv4 不受影响。 + assert!(validate_image_proxy_url("http://[2001:4860:4860::8888]:80/").is_ok()); + assert!(validate_image_proxy_url("http://[2606:4700:4700::1111]:80/").is_ok()); + assert!(validate_image_proxy_url("http://[::ffff:8.8.8.8]:80/").is_ok()); + } + + #[test] + fn image_proxy_redirect_targets_revalidate_every_hop() { + // 字面 IP / 主机名 / mapped / scheme / 凭据的拒绝路径。 + for value in [ + "http://127.0.0.1:8080/next", + "http://localhost:3000/next", + "http://[::1]:5173/next", + "http://[::ffff:127.0.0.1]:80/next", + "http://[fe80::1]:80/next", + "ftp://example.com/file", + "https://user:pass@example.com/file", + ] { + let url = Url::parse(value).expect("redirect target url parses"); + assert!(!validate_image_proxy_redirect_target(&url), "{value}"); + } + + // 主机名解析到回环地址同样拒绝(DNS rebinding 的跳转形态)。 + // "localhost." 带尾点,不命中字面 localhost 拦截,但解析结果命中黑名单。 + let trailing_dot = Url::parse("http://localhost.:8080/next").expect("trailing-dot parses"); + assert!( + !validate_image_proxy_redirect_target(&trailing_dot), + "localhost. resolves to loopback and must be rejected" + ); + + // 公网字面 IP 放行(不依赖外部 DNS)。 + let public = Url::parse("http://8.8.8.8:80/photo.png").expect("public ip parses"); + assert!(validate_image_proxy_redirect_target(&public)); + } + + #[tokio::test] + async fn image_proxy_dns_precheck_rejects_loopback_resolution() { + // 主机名解析到回环地址:在出网前拒绝(fail-closed)。 + let localhost = Url::parse("http://localhost.:8080/photo.png").expect("localhost parses"); + assert!(image_proxy_host_resolves_to_blocked(&localhost).await); + + // 字面 IP 不做 DNS 解析(已在 validate 层拦截,这里直接放行给上层)。 + let literal = Url::parse("http://8.8.8.8:80/photo.png").expect("literal parses"); + assert!(!image_proxy_host_resolves_to_blocked(&literal).await); + + // 无法解析的域名按拒绝处理(fail-closed)。 + let nonexistent = Url::parse("http://nonexistent.invalid/photo.png").expect("parses"); + assert!(image_proxy_host_resolves_to_blocked(&nonexistent).await); + } + + #[test] + fn image_proxy_origin_check_blocks_foreign_web_pages() { + use axum::http::HeaderMap; + + let empty = HeaderMap::new(); + assert!(image_proxy_origin_allowed(&empty), "no Origin allowed"); + + for origin in [ + "tauri://localhost", + "http://tauri.localhost", + "https://tauri.localhost", + ] { + let mut headers = HeaderMap::new(); + headers.insert("origin", origin.parse().unwrap()); + assert!(image_proxy_origin_allowed(&headers), "{origin}"); + } + + for origin in ["https://evil.example", "http://127.0.0.1:3000", "null"] { + let mut headers = HeaderMap::new(); + headers.insert("origin", origin.parse().unwrap()); + assert!(!image_proxy_origin_allowed(&headers), "{origin}"); + } + } + #[test] fn builds_origin_referer_for_image_proxy_requests() { let url = validate_image_proxy_url("https://example.com:8443/path/photo.png?size=large") @@ -1061,6 +1468,225 @@ mod tests { assert!(decode_upstream_header_overrides(&oversized).is_err()); } + // ------------------------------------------------------------------ + // 安全 resolver(DNS rebinding 绑定)回归测试 + // ------------------------------------------------------------------ + + use std::collections::{HashMap, VecDeque}; + use std::str::FromStr; + use std::sync::Mutex; + + /// 脚本化底层 resolver:按主机名排队应答,记录调用序列——模拟攻击者控制的 + /// DNS(同一域名前后两次解析返回不同答案),验证审核绑定在拨号路径上。 + #[derive(Default)] + struct ScriptedResolver { + answers: Mutex, String>>>>, + calls: Mutex>, + } + + impl ScriptedResolver { + fn push(&self, host: &str, answer: Result, &str>) { + self.answers + .lock() + .unwrap() + .entry(host.to_string()) + .or_default() + .push_back(answer.map_err(str::to_string)); + } + } + + impl Resolve for ScriptedResolver { + fn resolve(&self, name: Name) -> Resolving { + let host = name.as_str().to_string(); + self.calls.lock().unwrap().push(host.clone()); + let answer = self + .answers + .lock() + .unwrap() + .get_mut(&host) + .and_then(VecDeque::pop_front); + Box::pin(async move { + match answer { + Some(Ok(addrs)) => Ok(Box::new(addrs.into_iter()) as Addrs), + Some(Err(message)) => Err(message.into()), + None => Err(format!("no scripted answer for {host}").into()), + } + }) + } + } + + fn resolver_over( + scripted: Arc, + proxy_host: Option, + ) -> ImageProxyResolver { + ImageProxyResolver { + inner: scripted, + block: Arc::new(is_blocked_image_proxy_ip), + proxy_host, + } + } + + async fn resolve_once( + resolver: &ImageProxyResolver, + host: &str, + ) -> Result, String> { + let name = Name::from_str(host).expect("test host must be a valid name"); + resolver + .resolve(name) + .await + .map(|addrs| addrs.collect()) + .map_err(|err| err.to_string()) + } + + #[tokio::test] + async fn image_proxy_resolver_rejects_dns_rebinding_flip() { + // 审核要求的核心场景:第一次解析返回公网 IP(通过预检),同一域名第二次 + // 解析翻转为回环地址。resolver 装在拨号路径上,翻转答案在连接前被拒。 + let scripted = Arc::new(ScriptedResolver::default()); + scripted.push("rebind.test", Ok(vec![SocketAddr::from(([8, 8, 8, 8], 0))])); + scripted.push( + "rebind.test", + Ok(vec![SocketAddr::from(([127, 0, 0, 1], 0))]), + ); + let resolver = resolver_over(scripted, None); + + let first = resolve_once(&resolver, "rebind.test") + .await + .expect("public answer must pass"); + assert_eq!(first, vec![SocketAddr::from(([8, 8, 8, 8], 0))]); + + let error = resolve_once(&resolver, "rebind.test") + .await + .expect_err("rebound loopback answer must be rejected at dial time"); + assert!(error.contains("blocked IP range"), "{error}"); + } + + #[tokio::test] + async fn image_proxy_resolver_fail_closed_on_mixed_empty_and_failed_answers() { + let scripted = Arc::new(ScriptedResolver::default()); + // 混合答案:公网 + 内网并存,任一命中即整体拒绝(防止 happy-eyeballs + // 从答案集中挑出内网地址)。 + scripted.push( + "mixed.test", + Ok(vec![ + SocketAddr::from(([8, 8, 8, 8], 0)), + SocketAddr::from(([169, 254, 169, 254], 0)), + ]), + ); + // 空答案与解析失败同样 fail-closed。 + scripted.push("empty.test", Ok(Vec::new())); + scripted.push("down.test", Err("simulated DNS failure")); + + let resolver = resolver_over(scripted, None); + for host in ["mixed.test", "empty.test", "down.test"] { + assert!( + resolve_once(&resolver, host).await.is_err(), + "{host} must be rejected" + ); + } + // 未脚本化的域名也不许静默通过。 + assert!(resolve_once(&resolver, "unscripted.test").await.is_err()); + } + + #[tokio::test] + async fn image_proxy_resolver_exempts_only_the_configured_proxy_host() { + let scripted = Arc::new(ScriptedResolver::default()); + scripted.push("proxy.lan", Ok(vec![SocketAddr::from(([10, 0, 0, 1], 0))])); + scripted.push("PROXY.lan", Ok(vec![SocketAddr::from(([10, 0, 0, 1], 0))])); + scripted.push("other.lan", Ok(vec![SocketAddr::from(([10, 0, 0, 1], 0))])); + + let resolver = resolver_over(scripted, Some("proxy.lan".to_string())); + // 用户配置的代理端点(大小写不敏感)豁免黑名单——否则指向内网的代理 + // 主机名会让图片代理整体不可用。 + assert!(resolve_once(&resolver, "proxy.lan").await.is_ok()); + assert!(resolve_once(&resolver, "PROXY.lan").await.is_ok()); + // 同一内网地址出现在任何其他主机名上照样拒绝。 + let error = resolve_once(&resolver, "other.lan") + .await + .expect_err("non-proxy hosts stay blocked"); + assert!(error.contains("blocked IP range"), "{error}"); + } + + #[tokio::test] + async fn image_proxy_client_dials_only_resolver_approved_addresses() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + // 本地 HTTP 服务器:统计 accept 次数,返回最小合法响应。 + let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind loopback test server"); + let server_addr = listener.local_addr().expect("server addr"); + let accepts = Arc::new(AtomicUsize::new(0)); + tokio::spawn({ + let accepts = Arc::clone(&accepts); + async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + accepts.fetch_add(1, Ordering::SeqCst); + tokio::spawn(async move { + let mut buf = [0u8; 1024]; + let _ = socket.read(&mut buf).await; + let _ = socket + .write_all( + b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\ncontent-type: image/png\r\nconnection: close\r\n\r\nok", + ) + .await; + }); + } + } + }); + + // 两个 client 共用同一脚本:img.test 恒解析到这个**可达的**回环服务器。 + let scripted = Arc::new(ScriptedResolver::default()); + for _ in 0..4 { + scripted.push("img.test", Ok(vec![server_addr])); + } + let url = format!("http://img.test:{}/photo.png", server_addr.port()); + + // 对照组(放行谓词):请求成功,证明服务器可达、resolver 接线正确。 + let allowed_client = reqwest::Client::builder() + .no_proxy() + .dns_resolver(ImageProxyResolver { + inner: Arc::clone(&scripted) as Arc, + block: Arc::new(|_| false), + proxy_host: None, + }) + .build() + .expect("allow-all client builds"); + allowed_client + .get(&url) + .send() + .await + .expect("allow-all resolver must reach the loopback server"); + assert_eq!(accepts.load(Ordering::SeqCst), 1); + + // 实验组(真实黑名单):同一可达地址在拨号前被否决——服务器不得再收到 + // 任何连接。这证明 veto 绑定在 connector 实际使用的解析上,而非仅仅是 + // 一次独立的预检(DNS rebinding 中第二次解析返内网即此形态)。 + let blocked_client = reqwest::Client::builder() + .no_proxy() + .dns_resolver(resolver_over(scripted, None)) + .build() + .expect("blocked client builds"); + let error = blocked_client + .get(&url) + .send() + .await + .expect_err("blocked resolver must veto the dial"); + assert!( + format!("{error:?}").contains("blocked IP range"), + "{error:?}" + ); + assert_eq!( + accepts.load(Ordering::SeqCst), + 1, + "vetoed resolution must not produce any connection" + ); + } + fn encoded_overrides(value: serde_json::Value) -> HeaderValue { let encoded = base64::engine::general_purpose::STANDARD .encode(serde_json::to_vec(&value).expect("serialize overrides")); diff --git a/crates/agent-gui/src-tauri/src/services/system_proxy.rs b/crates/agent-gui/src-tauri/src/services/system_proxy.rs index 9d2d81fdc..2b9bf0e7c 100644 --- a/crates/agent-gui/src-tauri/src/services/system_proxy.rs +++ b/crates/agent-gui/src-tauri/src/services/system_proxy.rs @@ -38,6 +38,21 @@ impl SystemProxyConfig { } fn proxy_url(&self) -> String { + self.proxy_url_with_scheme(self.scheme()) + } + + /// socks5h = 代理端解析目标主机;socks5 = 本进程解析后经 IP 连接代理。 + /// SSRF 敏感出网点(图片代理)用后者:目标解析经由安全 resolver 完成并 + /// 与最终连接绑定,代理只收到已审核的 IP。 + fn proxy_url_local_dns(&self) -> String { + if self.proxy_type == SYSTEM_PROXY_TYPE_SOCKS5 { + self.proxy_url_with_scheme("socks5") + } else { + self.proxy_url() + } + } + + fn proxy_url_with_scheme(&self, scheme: &str) -> String { let credentials = if self.username.is_empty() && self.password.is_empty() { String::new() } else { @@ -49,7 +64,7 @@ impl SystemProxyConfig { }; format!( "{}://{}{}:{}", - self.scheme(), + scheme, credentials, self.url_host(), self.port @@ -304,6 +319,38 @@ pub fn async_client_builder() -> Result { async_client_builder_for_mode(¤t_snapshot().mode) } +/// SSRF 敏感出网点(图片代理)专用:与 `async_client_builder()` 相同,但 +/// socks5h 翻转为 socks5(目标主机由本进程解析后再把 IP 交给代理——图片代理 +/// 本就对每个目标做本地解析校验,不引入额外 DNS 暴露;解析结果与最终连接由此 +/// 绑定,杜绝代理端 rebind)。HTTP 代理协议(CONNECT / absolute-URI)只携带 +/// 主机名,目标解析仍在代理端完成,该模式的残余风险由调用方文档说明。 +/// +/// 返回的第二个值是已启用代理的主机名(小写、去 IPv6 方括号),供调用方的安全 +/// resolver 豁免代理端点自身——代理地址来自用户设置而非攻击者输入,若不经 +/// 豁免,指向回环/内网地址的代理主机名会被出网黑名单误杀,图片代理整体不可用。 +/// 直连时返回 None。 +pub fn async_client_builder_local_dns() -> Result<(reqwest::ClientBuilder, Option), String> +{ + match current_snapshot().mode { + ProxyMode::Disabled => Ok((reqwest::Client::builder().no_proxy(), None)), + ProxyMode::Invalid(error) => Err(error), + ProxyMode::Enabled(config) => { + let proxy = reqwest::Proxy::all(config.proxy_url_local_dns()) + .map(|proxy| proxy.no_proxy(reqwest::NoProxy::from_string(NO_PROXY_DEFAULT))) + .map_err(|_| format!("应用代理地址无效:{}", config.display_target()))?; + let host = config + .url_host() + .trim_start_matches('[') + .trim_end_matches(']') + .to_ascii_lowercase(); + Ok(( + reqwest::Client::builder().no_proxy().proxy(proxy), + Some(host), + )) + } + } +} + /// Resolved proxy URL for consumers that configure their own HTTP client rather /// than using `cached_client()`/`blocking_client_builder()` (e.g. /// `tauri-plugin-updater`, which only accepts a `Url` on its builder). @@ -404,6 +451,41 @@ mod tests { assert!(os_proxy_fallback_builder_for_mode(&enabled).is_ok()); } + #[test] + fn local_dns_builder_flips_socks5h_to_socks5_and_exposes_proxy_host() { + let socks = config(json!({ + "enabled": true, "type": "socks5", "host": "Proxy.Local", "port": 1080, + "username": "", "password": "" + })); + // socks5h(代理端解析)→ socks5(本进程经安全 resolver 解析,IP 交给代理)。 + assert_eq!(socks.proxy_url(), "socks5h://Proxy.Local:1080"); + assert_eq!(socks.proxy_url_local_dns(), "socks5://Proxy.Local:1080"); + + let http = config(json!({ + "enabled": true, "type": "http", "host": "proxy.local", "port": 8080, + "username": "", "password": "" + })); + // HTTP 代理协议只携带主机名,无法本地解析,保持原样。 + assert_eq!(http.proxy_url_local_dns(), "http://proxy.local:8080"); + } + + #[test] + fn local_dns_builder_returns_proxy_host_for_resolver_exemption() { + set_config(Some(&json!({ + "enabled": true, "type": "socks5", "host": "[::1]", "port": 1080, + "username": "", "password": "" + }))); + let (builder, host) = + async_client_builder_local_dns().expect("enabled proxy local-dns builder"); + // 豁免串去方括号并小写,与 resolver 见到的 Name 形态对齐。 + assert_eq!(host.as_deref(), Some("::1")); + drop(builder); + + set_config(None); + let (_, host) = async_client_builder_local_dns().expect("disabled local-dns builder"); + assert_eq!(host, None); + } + #[test] fn proxy_mode_equality_drives_set_config_dedupe() { let config = json!({ diff --git a/docs/security/p1-fixes-evidence.svg b/docs/security/p1-fixes-evidence.svg new file mode 100644 index 000000000..83c3a7e6a --- /dev/null +++ b/docs/security/p1-fixes-evidence.svg @@ -0,0 +1,40 @@ + + + 中危批次修复验证 — 复现测试证据(before/after) + + + 修复前(漏洞复现) + M1 本地代理: FAIL + http://169.254.169.254 与 127.0.0.1 + 目标通过校验(无 IP 过滤) + M2 terminal attach: FAIL + 无帧率限制,任意数量 session_id + 无界进跟踪表 + M3 SVG XSS: FAIL + image-proxy 直出 SVG,无 CSP/ + Content-Disposition,顶层导航执行脚本 + M4 分享脱敏: FAIL + role=summary 摘要原样流出 + M5 git 大小写: FAIL + CLONE_START 绕过写操作门控被转发 + M6 history.event: FAIL + 无 conversation 的 upsert 抛 TypeError + + 修复后(验证通过) + M1 本地代理: PASS + 回环/私网/元数据/保留段全部拒绝, + 恶意网页 fetch 被 Origin 校验拒绝 + M2 terminal attach: PASS + 100fps 限速 + 跟踪表 512 容量上限 + + 512 字符 id 长度上限 + M3 SVG XSS: PASS + SVG 响应带 sandbox CSP + inline 处置 + M4 分享脱敏: PASS + summary 整体替换为"历史段摘要已脱敏" + M5 git 大小写: PASS + action 归一化后门控生效 + M6 history.event: PASS + 无 conversation 的帧被跳过, + 后续监听者不受影响 + +