From a2130c01dabe60cd8e65db8dadcb3701c917345d Mon Sep 17 00:00:00 2001 From: Zayoka <141497128+zayokami@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:22:10 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(security):=20=E4=B8=AD=E5=8D=B1?= =?UTF-8?q?=E6=89=B9=E6=AC=A1=E2=80=94=E2=80=94=E6=9C=AC=E5=9C=B0=E4=BB=A3?= =?UTF-8?q?=E7=90=86=20SSRF=E3=80=81terminal=20attach=20=E9=99=90=E9=80=9F?= =?UTF-8?q?=E3=80=81SVG=20XSS=E3=80=81frame=20=E9=98=B2=E6=8A=A4=E3=80=81?= =?UTF-8?q?=E5=88=86=E4=BA=AB=E8=84=B1=E6=95=8F=E3=80=81git=20=E9=97=A8?= =?UTF-8?q?=E6=8E=A7=E5=A4=A7=E5=B0=8F=E5=86=99=E3=80=81history=20?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E5=81=A5=E5=A3=AE=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 安全审计中危(confirmed×2 为主)的 7 项修复,各带复现/回归测试: 1. 桌面本地 /image-proxy SSRF(confirmed×2):validate_image_proxy_url 增加 与网关 Go 侧对齐的 IPv4/IPv6 字面 IP 黑名单(回环/私网/link-local/云元数据/ 多播/保留/广播,含 localhost 主机名),并增加 Origin 来源校验(只允许 Tauri WebView 来源,恶意网页 fetch 被拒)。 路径由 IP 黑名单兜底。 - 测试:rejects_ssrf_targets_in_image_proxy_urls + origin 用例 2. /ws/v2/terminal 浏览器链路无界 attach(confirmed×2):读循环加入与主链路 同款 InboundRateLimiter(100fps/突发 200/3 次违规断连);attach 跟踪表 增加容量(512)与 id 长度(512)上限,杜绝单连接以 1MiB/帧无限增长内存。 - 测试:TestTerminalBrowserRememberCapsTrackedIDs / RejectsOversizedIDs 3. SVG XSS on gateway origin(confirmed×2):image-proxy 对 SVG 响应加 Content-Security-Policy: sandbox + Content-Disposition: inline,顶层 导航到 SVG 不再能在网关 origin 执行脚本(WebUI token 在 localStorage)。 - 测试:SVG sandbox CSP 用例 + 非 SVG 无 CSP 用例 4. WebUI 点击劫持(finder):serveIndex 加 X-Frame-Options: DENY + CSP frame-ancestors 'none'(WebUI token 自动登录,禁 iframe 内嵌)。 - 测试:root index 响应头断言 5. Public-share 脱敏绕过(confirmed×2):redact_builtin_tool_content_json 新增 role=summary 分支——归档段摘要是 LLM 对含工具输出窗口的浓缩, 无法可靠剥离工具内容,redact 语义下整体替换为占位。 - 测试:summary 泄露用例(secret/路径不得出现在输出中) 6. enable_web_git 大小写绕过(1 plausible/1 refuted 的窄通道):网关侧 git action 先 ToLower 再判写操作,CLONE_START/Push 等变体不再绕过门控。 - 测试:TestV2GitRejectsCaseMutatedWriteActionsWhenDisabled (修复前:大小写变体被放行转发=复现) 7. history.event 缺 conversation 字段崩溃(plausible×2):webSidebarBackend 跳过无 conversation 的 upsert 帧(不再 TypeError 中断后续监听者投递); useGatewayHistoryReconciliation 对 message_count 加 optional chain。 - 测试:web-sidebar-backend 用例(修复前:抛异常=复现) 回归:Go 全量(仅基线 Windows 权限测试)、Rust 改动域 129 个、WebUI 579 个 全过;lint 仅仓库既有 CRLF 格式误报。 Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + .../internal/handler/image_proxy.go | 7 + .../internal/handler/image_proxy_test.go | 56 ++++++++ .../internal/protocol/pbws/guard.go | 4 +- .../internal/protocol/pbws/terminal_conn.go | 34 ++++- .../protocol/pbws/terminal_conn_test.go | 85 ++++++++++++ crates/agent-gateway/internal/server/http.go | 4 + .../internal/server/http_test.go | 7 + .../test/websocket/v2_git_gating_test.go | 23 ++++ .../hooks/useGatewayHistoryReconciliation.ts | 2 +- .../web/src/lib/sidebar/webSidebarBackend.ts | 7 + .../web/test/web-sidebar-backend.test.mjs | 41 ++++++ .../src-tauri/src/services/gateway_bridge.rs | 43 ++++++ .../agent-gui/src-tauri/src/services/proxy.rs | 129 ++++++++++++++++++ 14 files changed, 441 insertions(+), 4 deletions(-) create mode 100644 crates/agent-gateway/internal/protocol/pbws/terminal_conn_test.go 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/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..6b3567406 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 区分。浏览器 @@ -227,6 +228,13 @@ func (s *Server) writeTerminalFrame(conn *websocket.Conn, frame *gatewayv2.Termi // 浏览器角色 // --------------------------------------------------------------------------- +// terminalBrowserMaxTrackedIDs 限制单连接 attach/detach 跟踪的会话/流 id 数量: +// 无上限时任意长度、任意数量的 session_id 会把网关内存打爆(单帧可达 1 MiB)。 +const terminalBrowserMaxTrackedIDs = 512 + +// terminalBrowserMaxIDLen 限制单个 session_id / stream_id 的长度上限。 +const terminalBrowserMaxIDLen = 512 + type terminalBrowserConn struct { srv *Server sm *session.Manager @@ -239,6 +247,10 @@ type terminalBrowserConn struct { done chan struct{} once sync.Once + // rateLimiter 与主链路同款入站限速(100 帧/秒、突发 200、3 次违规断连): + // 终端链路此前完全没有帧率限制,可被单连接以任意速率灌帧。 + rateLimiter *wscore.InboundRateLimiter + mu sync.RWMutex attached map[string]struct{} streams map[string]struct{} @@ -252,6 +264,9 @@ func (s *Server) serveTerminalBrowser(conn *websocket.Conn, agentID string) { agentID: agentID, out: make(chan []byte, terminalWriteQueueSize), done: make(chan struct{}), + rateLimiter: wscore.NewInboundRateLimiter( + browserInboundFramesPerSecond, browserInboundBurst, browserRateLimitMaxViolations, + ), attached: make(map[string]struct{}), streams: make(map[string]struct{}), } @@ -265,6 +280,17 @@ func (s *Server) serveTerminalBrowser(conn *websocket.Conn, agentID string) { if !ok { return } + // 入站限速:与主链路同款,超限丢帧、连续违规判定失控客户端、关闭连接。 + if allowed, exceeded := c.rateLimiter.Allow(); !allowed { + if exceeded { + return + } + c.enqueueFrame(terminalErrorFrame( + &gatewayv2.TerminalStreamFrame{Kind: "error"}, + "too many requests", + )) + continue + } streamFrame := frame.GetFrame() if streamFrame == nil { continue @@ -369,11 +395,15 @@ func (c *terminalBrowserConn) remember(sessionID string, streamID string) { if sessionID == "" && streamID == "" { return } + // 长度上限:拒绝超长 id 进跟踪表(帧可携带任意长度字符串)。 + if len(sessionID) > terminalBrowserMaxIDLen || len(streamID) > terminalBrowserMaxIDLen { + return + } c.mu.Lock() - if sessionID != "" { + if sessionID != "" && len(c.attached) < terminalBrowserMaxTrackedIDs { c.attached[sessionID] = struct{}{} } - if streamID != "" { + if streamID != "" && len(c.streams) < terminalBrowserMaxTrackedIDs { c.streams[streamID] = struct{}{} } c.mu.Unlock() 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..1061934eb --- /dev/null +++ b/crates/agent-gateway/internal/protocol/pbws/terminal_conn_test.go @@ -0,0 +1,85 @@ +package pbws + +// 终端数据面浏览器角色的加固:attach/detach 跟踪表有容量与长度上限, +// 防止单连接以任意大小/数量的 session_id 无界增长内存。 + +import ( + "strings" + "testing" +) + +func newTestTerminalBrowserConn() *terminalBrowserConn { + return &terminalBrowserConn{ + attached: make(map[string]struct{}), + streams: make(map[string]struct{}), + } +} + +func TestTerminalBrowserRememberCapsTrackedIDs(t *testing.T) { + c := newTestTerminalBrowserConn() + + for i := 0; i < terminalBrowserMaxTrackedIDs+128; i++ { + c.remember(sprintfSessionID(i), "") + } + c.mu.RLock() + attachedCount := len(c.attached) + c.mu.RUnlock() + if attachedCount != terminalBrowserMaxTrackedIDs { + t.Fatalf("attached = %d, want capped at %d", attachedCount, terminalBrowserMaxTrackedIDs) + } + + for i := 0; i < terminalBrowserMaxTrackedIDs+128; i++ { + c.remember("", sprintfStreamID(i)) + } + c.mu.RLock() + streamsCount := len(c.streams) + c.mu.RUnlock() + if streamsCount != terminalBrowserMaxTrackedIDs { + t.Fatalf("streams = %d, want capped at %d", streamsCount, terminalBrowserMaxTrackedIDs) + } +} + +func TestTerminalBrowserRememberRejectsOversizedIDs(t *testing.T) { + c := newTestTerminalBrowserConn() + + // 超长 id(帧内任意长度字符串)不得进入跟踪表。 + c.remember(strings.Repeat("s", terminalBrowserMaxIDLen+1), "") + c.remember("", strings.Repeat("t", terminalBrowserMaxIDLen+1)) + + 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) + } + + // 边界长度仍然接受。 + c.remember(strings.Repeat("s", terminalBrowserMaxIDLen), "") + c.mu.RLock() + attachedCount = len(c.attached) + c.mu.RUnlock() + if attachedCount != 1 { + t.Fatalf("boundary-length id not tracked: attached=%d", attachedCount) + } +} + +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) +} 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..813eb9b4e 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,36 @@ 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..fec9147ff 100644 --- a/crates/agent-gui/src-tauri/src/services/proxy.rs +++ b/crates/agent-gui/src-tauri/src/services/proxy.rs @@ -125,6 +125,10 @@ 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), @@ -240,9 +244,78 @@ 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()); + } + match host.parse::() { + Ok(std::net::IpAddr::V4(ip)) => { + if is_blocked_image_proxy_ipv4(ip) { + return Err("Image URL host is in a blocked IP range".to_string()); + } + } + // IPv6 回环 ::1 与 127.0.0.1 同语义,拒绝。 + Ok(std::net::IpAddr::V6(ip)) if ip.is_loopback() => { + 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) +} + +/// 本地来源校验: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 +941,62 @@ 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 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") From c8419f2a0877d979fe0e7780c41bc7566f346f08 Mon Sep 17 00:00:00 2001 From: Zayoka <141497128+zayokami@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:23:56 +0800 Subject: [PATCH 2/4] =?UTF-8?q?docs(security):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=B8=AD=E5=8D=B1=E6=89=B9=E6=AC=A1=E4=BF=AE=E5=A4=8D=E7=9A=84?= =?UTF-8?q?=E5=A4=8D=E7=8E=B0=E6=B5=8B=E8=AF=95=E8=AF=81=E6=8D=AE=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/security/p1-fixes-evidence.svg | 40 +++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 docs/security/p1-fixes-evidence.svg 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 的帧被跳过, + 后续监听者不受影响 + + From f1d75104e46e38e27188fc9161125b5ddcba54bb Mon Sep 17 00:00:00 2001 From: Zayoka <141497128+zayokami@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:41:26 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(security):=20=E6=8C=89=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E6=84=8F=E8=A7=81=E4=BF=AE=E8=AE=A2=E4=B8=AD=E5=8D=B1?= =?UTF-8?q?=E6=89=B9=E6=AC=A1=E2=80=94=E2=80=94=E5=B0=81=E5=A0=B5=20SSRF?= =?UTF-8?q?=20=E7=BB=95=E8=BF=87(DNS/=E9=87=8D=E5=AE=9A=E5=90=91/IPv6/unma?= =?UTF-8?q?p)=E3=80=81terminal=20attach=20=E5=86=85=E5=AD=98=E4=BF=9D?= =?UTF-8?q?=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 针对 PR #514 审核的两个安全阻塞问题: 1. 桌面 /image-proxy SSRF 完整封堵(原修复仅拦截字面 IPv4 与 ::1): - is_blocked_image_proxy_ip 统一入口:IPv4-mapped IPv6 先 to_ipv4_mapped() 还原再走 IPv4 黑名单;IPv6 段与网关 Go 侧 outbound_http.go 逐项对齐 (::/::1、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) - 连接前 DNS 解析(image_proxy_host_resolves_to_blocked):主机名解析出的 全部地址任一命中黑名单即拒绝,fail-closed 封 DNS rebinding - 每次 30x 跳转重新校验(validate_image_proxy_redirect_target):字面 IP、 localhost、凭据、scheme 与主机名解析全查;reqwest 0.13 重定向策略是 client 级配置,image-proxy 改走 system_proxy::async_client_builder 自定义 redirect policy - 测试:IPv6/mapped 拒绝与公网放行、redirect 每跳校验(含 "localhost." 尾点解析)、DNS 预检(localhost 解析拒绝、无效域名 fail-closed) 2. terminal attach ID 内存保留(Go 子切片持有整帧缓冲): - remember 先按原始字段长度(4 KiB)拒绝再 trim,杜绝"接近 1 MiB 空白 前缀 + 短 ID"把整帧内存挂在 map key 上;规范化后 ≤512 并做有界拷贝 切断与帧缓冲的引用 - 容量已满 / 超长 / 空 id 均返回明确错误,handleFrame 回错误帧且不再 转发给 Agent(此前静默丢弃) - 测试:空白前缀绕过用例(修复前会接受)、handleFrame 拒绝路径不回转发 (队列只有拒绝帧而非 offline 帧)、容量满拒绝、合法 attach 不受影响 另:cargo fmt 修复本 PR Rust 代码的 3 处格式差异(--check 现为 0)。 回归:Rust 受影响域 46/46;Go 全量仅既有 Windows 权限基线失败;WebUI 未触及。 Co-Authored-By: Claude Fable 5 --- .../internal/protocol/pbws/terminal_conn.go | 51 +++- .../protocol/pbws/terminal_conn_test.go | 181 +++++++++++-- .../src-tauri/src/services/gateway_bridge.rs | 10 +- .../agent-gui/src-tauri/src/services/proxy.rs | 239 ++++++++++++++++-- 4 files changed, 423 insertions(+), 58 deletions(-) diff --git a/crates/agent-gateway/internal/protocol/pbws/terminal_conn.go b/crates/agent-gateway/internal/protocol/pbws/terminal_conn.go index 6b3567406..600c01cab 100644 --- a/crates/agent-gateway/internal/protocol/pbws/terminal_conn.go +++ b/crates/agent-gateway/internal/protocol/pbws/terminal_conn.go @@ -232,7 +232,11 @@ func (s *Server) writeTerminalFrame(conn *websocket.Conn, frame *gatewayv2.Termi // 无上限时任意长度、任意数量的 session_id 会把网关内存打爆(单帧可达 1 MiB)。 const terminalBrowserMaxTrackedIDs = 512 -// terminalBrowserMaxIDLen 限制单个 session_id / stream_id 的长度上限。 +// terminalBrowserMaxRawIDLen 限制 trim 前的原始字段长度:TrimSpace 返回子切片、 +// 底层数组仍持有整帧缓冲区,必须先按原始长度拒绝再规范化。 +const terminalBrowserMaxRawIDLen = 4 * 1024 + +// terminalBrowserMaxIDLen 限制规范化后单个 session_id / stream_id 的长度上限。 const terminalBrowserMaxIDLen = 512 type terminalBrowserConn struct { @@ -308,7 +312,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": @@ -389,24 +398,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" } - // 长度上限:拒绝超长 id 进跟踪表(帧可携带任意长度字符串)。 if len(sessionID) > terminalBrowserMaxIDLen || len(streamID) > terminalBrowserMaxIDLen { - return + 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() - if sessionID != "" && len(c.attached) < terminalBrowserMaxTrackedIDs { + 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 != "" && len(c.streams) < terminalBrowserMaxTrackedIDs { + 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 index 1061934eb..dbc05fb22 100644 --- a/crates/agent-gateway/internal/protocol/pbws/terminal_conn_test.go +++ b/crates/agent-gateway/internal/protocol/pbws/terminal_conn_test.go @@ -1,25 +1,64 @@ package pbws // 终端数据面浏览器角色的加固:attach/detach 跟踪表有容量与长度上限, -// 防止单连接以任意大小/数量的 session_id 无界增长内存。 +// 超长/超限的 attach 明确拒绝且不转发给 Agent;trim 前按原始长度拒绝并做 +// 有界拷贝,防止 Go 子切片保留整帧缓冲区(接近 1 MiB 的空白前缀 + 短 ID)。 import ( "strings" "testing" + + "google.golang.org/protobuf/proto" + + gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" + "github.com/liveagent/agent-gateway/internal/session" ) -func newTestTerminalBrowserConn() *terminalBrowserConn { +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() + c := newTestTerminalBrowserConn(nil) - for i := 0; i < terminalBrowserMaxTrackedIDs+128; i++ { - c.remember(sprintfSessionID(i), "") + 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) @@ -28,23 +67,41 @@ func TestTerminalBrowserRememberCapsTrackedIDs(t *testing.T) { t.Fatalf("attached = %d, want capped at %d", attachedCount, terminalBrowserMaxTrackedIDs) } - for i := 0; i < terminalBrowserMaxTrackedIDs+128; i++ { - c.remember("", sprintfStreamID(i)) + 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) + } } - c.mu.RLock() - streamsCount := len(c.streams) - c.mu.RUnlock() + 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() + c := newTestTerminalBrowserConn(nil) - // 超长 id(帧内任意长度字符串)不得进入跟踪表。 - c.remember(strings.Repeat("s", terminalBrowserMaxIDLen+1), "") - c.remember("", strings.Repeat("t", terminalBrowserMaxIDLen+1)) + // 空白前缀绕过: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) @@ -54,8 +111,10 @@ func TestTerminalBrowserRememberRejectsOversizedIDs(t *testing.T) { t.Fatalf("oversized ids tracked: attached=%d streams=%d", attachedCount, streamsCount) } - // 边界长度仍然接受。 - c.remember(strings.Repeat("s", terminalBrowserMaxIDLen), "") + // 边界长度(原始 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() @@ -64,22 +123,88 @@ func TestTerminalBrowserRememberRejectsOversizedIDs(t *testing.T) { } } -func sprintfSessionID(i int) string { - return "session-" + itoa(i) +// 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 sprintfStreamID(i int) string { - return "stream-" + itoa(i) +func withWebTerminalEnabled(t *testing.T) *session.Manager { + t.Helper() + sm := session.NewManager() + sm.ApplySettingsJSON("agent-x", `{"remote":{"enableWebTerminal":true}}`) + return sm } -func itoa(i int) string { - if i == 0 { - return "0" +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) } - var digits []byte - for i > 0 { - digits = append([]byte{byte('0' + i%10)}, digits...) - i /= 10 + 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") } - return string(digits) } 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 813eb9b4e..54d2abafd 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs @@ -2092,8 +2092,14 @@ mod tests { 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}"); + assert!( + !serialized.contains("SECRET-KEY-12345"), + "summary tool content leaked: {serialized}" + ); + assert!( + !serialized.contains("/home/alice"), + "summary path leaked: {serialized}" + ); } #[test] diff --git a/crates/agent-gui/src-tauri/src/services/proxy.rs b/crates/agent-gui/src-tauri/src/services/proxy.rs index fec9147ff..739a52607 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, TcpListener, ToSocketAddrs}, sync::Arc, time::Duration, }; @@ -127,16 +127,49 @@ 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); + 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), }; + // 连接前解析:主机名若解析到回环/内网/元数据段(DNS rebinding),在出网前拒绝。 + 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 开关。 + // 走 async_client_builder 而非 cached_client:reqwest 0.13 的重定向策略是 + // client 级配置,每次 30x 跳转都要重新校验目标(字面 IP + DNS 解析), + // 公网 URL 经重定向指向内网地址的链路在此被切断。 + let client = match crate::services::system_proxy::async_client_builder() { + Ok(builder) => builder + .redirect(reqwest::redirect::Policy::custom(|attempt| { + if validate_image_proxy_redirect_target(attempt.url()) { + attempt.follow() + } else { + attempt.stop() + } + })) + .build(), + Err(error) => { + return error_response( + StatusCode::BAD_GATEWAY, + &format!("App proxy unavailable: {error}"), + &headers, + ); + } + }; + let client = match client { Ok(client) => client, Err(error) => { return error_response( @@ -256,17 +289,10 @@ fn validate_image_proxy_url(raw: &str) -> Result { if host_lower == "localhost" || host_lower == "localhost.localdomain" { return Err("Image URL host is in a blocked IP range".to_string()); } - match host.parse::() { - Ok(std::net::IpAddr::V4(ip)) => { - if is_blocked_image_proxy_ipv4(ip) { - return Err("Image URL host is in a blocked IP range".to_string()); - } - } - // IPv6 回环 ::1 与 127.0.0.1 同语义,拒绝。 - Ok(std::net::IpAddr::V6(ip)) if ip.is_loopback() => { + 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) @@ -297,6 +323,108 @@ fn is_blocked_image_proxy_ipv4(ip: std::net::Ipv4Addr) -> bool { || (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) + }) +} + +/// 解析目标主机的全部地址,任一命中黑名单即拒绝(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,会被拒绝。注意浏览器 请求 @@ -969,6 +1097,83 @@ mod tests { 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; @@ -986,11 +1191,7 @@ mod tests { assert!(image_proxy_origin_allowed(&headers), "{origin}"); } - for origin in [ - "https://evil.example", - "http://127.0.0.1:3000", - "null", - ] { + 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}"); From 52f818f56fec1cfe2130c24daa0ebcbdc4bcf5aa Mon Sep 17 00:00:00 2001 From: Zayoka <141497128+zayokami@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:52:59 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(security):=20=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=E4=BF=AE=E8=AE=A2=E2=80=94=E2=80=94image-proxy=20=E6=8B=A8?= =?UTF-8?q?=E5=8F=B7=E8=B7=AF=E5=BE=84=E7=BB=91=E5=AE=9A=20DNS=20=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E5=B0=81=E5=A0=B5=20rebinding=E3=80=81=E5=85=A5?= =?UTF-8?q?=E7=AB=99=E9=99=90=E9=80=9F=E5=89=8D=E7=A7=BB=E8=87=B3=E5=8F=8D?= =?UTF-8?q?=E5=BA=8F=E5=88=97=E5=8C=96=E5=89=8D=E5=B9=B6=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E5=B8=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 #514 复审意见修复两个安全阻塞问题: 1. image-proxy SSRF(DNS rebinding):预检与实际连接脱钩——预检独立解析后, reqwest client 拨号时会再次解析域名,攻击者可先答公网、连接时改答 127.0.0.1/RFC1918/云元数据。现将 IP 黑名单装进 reqwest 实际拨号使用的 DNS resolver(ImageProxyResolver):hyper 每次新建连接都经它解析且只拨号 本轮回填的地址,审核与连接一一绑定;重定向跨 origin 每跳的新连接自动重审; 混合答案/空答案/解析失败全部 fail-closed。字面 IP 不经 resolver,仍由 validate_image_proxy_url 与重定向策略的字面量检查拦截。 代理模式:socks5h 翻转为 socks5(system_proxy 新增 async_client_builder_local_dns),目标经安全 resolver 本地解析后把 IP 交给代理,解析与连接保持绑定(图片代理本就本地预解析目标,无新增 DNS 暴露);HTTP 代理(CONNECT/absolute-URI)协议只携带主机名,目标解析在 代理端完成,客户端无法绑定,残余窗口由前置预检 + 每跳重定向预检收缩, 触达面限于代理所在网络位置(已在代码注释与本文档明确)。已启用代理的 主机名来自用户设置,resolver 对其豁免,避免回环/内网代理端点被误杀。 回归:脚本化 resolver 验证"第一次公网、第二次内网"翻转在拨号前被拒、 混合/空/失败答案 fail-closed、代理主机豁免不扩散;集成测试用本地回环 HTTP 服务器证明被否决的解析不产生任何实际连接(对照组放行谓词可达)。 2. terminal 入站限速绕过:readTerminalFrame 对非二进制帧直接 continue 且 rateLimiter.Allow 在 proto 反序列化之后执行——握手前文本帧洪泛零成本占 slot、认证后文本帧不计数、大帧先解析后限速无法约束解析 CPU。现将计数点 移到每次 ReadMessage 成功后、反序列化前:文本帧同罪计入,超限帧在解析前 丢弃,连续违规判死;握手阶段新增限速器(自第一帧生效,浏览器角色握手后 沿用同一预算)与 10s 绝对读超时(terminalHandshakeTimeout)。主链路 browser_conn.readFrame 存在完全相同的形态,同款修复(握手已有 idle 读 超时)。Agent 数据面不限速(nil limiter):输出帧无丢帧语义,其入站由 read limit 与会话通道背压兜底,对端为凭证认证的桌面端。 回归:文本帧耗尽突发额度后合法帧被拒并三次违规判死、非法 protobuf 大帧 在反序列化前被丢弃(连接存活、回填后后续帧正常)、pre-hello 300 帧文本 洪泛被判死、沉默连接被握手超时关闭、合法浏览器 hello 正路放行;主链路 读路径同款两个测试。 验证:Rust proxy/system_proxy 41/41 通过,cargo fmt --all --check 干净; Gateway go build/vet 干净,pbws 包全部测试通过,golangci-lint 0 issues; git diff --check 干净。(本机 Windows 上 agenttoken 文件权限测试与 git worktree/hook 共 12 项 Rust 测试在干净树上同样失败,为既有环境限制, 与本次改动无关。) Co-Authored-By: Claude Fable 5 --- .../internal/protocol/pbws/browser_conn.go | 43 ++- .../protocol/pbws/browser_conn_test.go | 128 +++++++ .../internal/protocol/pbws/terminal_conn.go | 104 +++-- .../protocol/pbws/terminal_conn_test.go | 244 ++++++++++++ .../agent-gui/src-tauri/src/services/proxy.rs | 356 ++++++++++++++++-- .../src-tauri/src/services/system_proxy.rs | 84 ++++- 6 files changed, 880 insertions(+), 79 deletions(-) create mode 100644 crates/agent-gateway/internal/protocol/pbws/browser_conn_test.go 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/terminal_conn.go b/crates/agent-gateway/internal/protocol/pbws/terminal_conn.go index 600c01cab..0f4c64cfe 100644 --- a/crates/agent-gateway/internal/protocol/pbws/terminal_conn.go +++ b/crates/agent-gateway/internal/protocol/pbws/terminal_conn.go @@ -25,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() @@ -49,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 { @@ -148,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 } } @@ -197,7 +239,8 @@ func (s *Server) serveTerminalAgent( }() for { - frame, ok := readTerminalFrame(conn) + // Agent 数据面不限速(nil):输出帧无丢帧语义,见 readTerminalFrame。 + frame, _, ok := readTerminalFrame(conn, nil) if !ok { cancel() return @@ -251,8 +294,10 @@ type terminalBrowserConn struct { done chan struct{} once sync.Once - // rateLimiter 与主链路同款入站限速(100 帧/秒、突发 200、3 次违规断连): - // 终端链路此前完全没有帧率限制,可被单连接以任意速率灌帧。 + // rateLimiter 与主链路同款入站限速(100 帧/秒、突发 200、3 次违规断连)。 + // 由 serveTerminal 在握手前创建并传入:限速自第一帧起生效,浏览器角色握手 + // 后沿用同一预算,攻击者无法借完成握手重置额度。计数点在 ReadMessage 成功 + // 后、反序列化前(见 readTerminalFrame),文本帧与大帧都计入。 rateLimiter *wscore.InboundRateLimiter mu sync.RWMutex @@ -260,19 +305,17 @@ type terminalBrowserConn 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{}), - rateLimiter: wscore.NewInboundRateLimiter( - browserInboundFramesPerSecond, browserInboundBurst, browserRateLimitMaxViolations, - ), - 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() @@ -280,15 +323,12 @@ 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 allowed, exceeded := c.rateLimiter.Allow(); !allowed { - if exceeded { - return - } + if denied { + // 帧在反序列化前已被丢弃,无 request 上下文可关联,回通用错误帧。 c.enqueueFrame(terminalErrorFrame( &gatewayv2.TerminalStreamFrame{Kind: "error"}, "too many requests", diff --git a/crates/agent-gateway/internal/protocol/pbws/terminal_conn_test.go b/crates/agent-gateway/internal/protocol/pbws/terminal_conn_test.go index dbc05fb22..3e2acdaea 100644 --- a/crates/agent-gateway/internal/protocol/pbws/terminal_conn_test.go +++ b/crates/agent-gateway/internal/protocol/pbws/terminal_conn_test.go @@ -5,13 +5,19 @@ package pbws // 有界拷贝,防止 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 { @@ -208,3 +214,241 @@ func TestTerminalBrowserHandleFrameRejectsFullTrackingTable(t *testing.T) { 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-gui/src-tauri/src/services/proxy.rs b/crates/agent-gui/src-tauri/src/services/proxy.rs index 739a52607..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::{IpAddr, Ipv4Addr, Ipv6Addr, TcpListener, ToSocketAddrs}, + 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; @@ -137,7 +140,8 @@ async fn handle_image_proxy(Query(query): Query, headers: Heade Ok(url) => url, Err(message) => return error_response(StatusCode::BAD_REQUEST, &message, &headers), }; - // 连接前解析:主机名若解析到回环/内网/元数据段(DNS rebinding),在出网前拒绝。 + // 连接前解析预检:命中黑名单在出网前快速 400(也是代理模式下的尽力校验—— + // HTTP 代理由代理端解析目标,客户端无法绑定,见 ImageProxyResolver 注释)。 if image_proxy_host_resolves_to_blocked(&target_url).await { return error_response( StatusCode::BAD_REQUEST, @@ -148,35 +152,13 @@ async fn handle_image_proxy(Query(query): Query, headers: Heade // 图片外链与商店链路同语义:恒随应用代理出网(未启用=直连,配置异常 // 502 fail fast)。 请求无法携带自定义头,因此不走 per-request 开关。 - // 走 async_client_builder 而非 cached_client:reqwest 0.13 的重定向策略是 - // client 级配置,每次 30x 跳转都要重新校验目标(字面 IP + DNS 解析), - // 公网 URL 经重定向指向内网地址的链路在此被切断。 - let client = match crate::services::system_proxy::async_client_builder() { - Ok(builder) => builder - .redirect(reqwest::redirect::Policy::custom(|attempt| { - if validate_image_proxy_redirect_target(attempt.url()) { - attempt.follow() - } else { - attempt.stop() - } - })) - .build(), - Err(error) => { - return error_response( - StatusCode::BAD_GATEWAY, - &format!("App proxy unavailable: {error}"), - &headers, - ); - } - }; - let client = match client { + // 终审在 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 @@ -374,6 +356,101 @@ fn is_blocked_image_proxy_ipv6(ip: Ipv6Addr) -> bool { }) } +/// 生产底层解析:系统 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 { @@ -1391,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!({