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 @@
+
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!({