From d4cead500224ea090f3a9176d24fdac563a7058a Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Wed, 22 Apr 2026 20:00:16 -0700 Subject: [PATCH] chore: extract ContextMenu Zustand fix + a2a_proxy local-docker SSRF bypass + workspace-server Dockerfile GID entrypoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small, non-overlapping fixes extracted from closed PR #1664: 1. canvas/src/components/ContextMenu.tsx — Replace the useMemo-over-nodes pattern with a hashed-boolean selector (s.nodes.some(...)) so Zustand's useSyncExternalStore snapshot comparison is stable. Resolves React error #185 (infinite render loop). Moves the child-node list derivation into the delete handler via getState() so the render path no longer allocates a fresh array. 2. workspace-server/internal/handlers/a2a_proxy.go — Allow the Docker-bridge hostname path (ws-:8000) to skip the SSRF guard in local-docker mode. Gated on !saasMode() so SaaS deployments keep the full private-IP blocklist (a remote workspace registration can't claim a ws-* hostname and reach a sensitive VPC IP). 3. workspace-server/Dockerfile — Add entrypoint.sh that discovers the docker.sock GID at boot and adds the platform user to that group, then exec's su-exec to drop privileges. Lets the platform container reach the host docker socket without running as root. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/components/ContextMenu.tsx | 17 ++++--------- workspace-server/Dockerfile | 20 ++++++++++++---- .../internal/handlers/a2a_proxy.go | 24 +++++++++++++++---- 3 files changed, 40 insertions(+), 21 deletions(-) diff --git a/canvas/src/components/ContextMenu.tsx b/canvas/src/components/ContextMenu.tsx index f9010293..d87e62b3 100644 --- a/canvas/src/components/ContextMenu.tsx +++ b/canvas/src/components/ContextMenu.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; import { api } from "@/lib/api"; import { showToast } from "./Toaster"; @@ -23,17 +23,9 @@ export function ContextMenu() { const setPanelTab = useCanvasStore((s) => s.setPanelTab); const nestNode = useCanvasStore((s) => s.nestNode); const contextNodeId = contextMenu?.nodeId ?? null; - // Select the full nodes array (stable reference across unrelated store - // updates) and derive children via useMemo. Filtering inside the - // selector returned a new array every call, which Zustand's - // useSyncExternalStore saw as "snapshot changed" → schedule - // re-render → loop → React error #185. See canvas-store-snapshots. - const nodes = useCanvasStore((s) => s.nodes); - const children = useMemo( - () => (contextNodeId ? nodes.filter((n) => n.data.parentId === contextNodeId) : []), - [nodes, contextNodeId], + const hasChildren = useCanvasStore((s) => + contextNodeId ? s.nodes.some((n) => n.data.parentId === contextNodeId) : false ); - const hasChildren = children.length > 0; const setPendingDelete = useCanvasStore((s) => s.setPendingDelete); const ref = useRef(null); const [actionLoading, setActionLoading] = useState(false); @@ -174,7 +166,8 @@ export function ContextMenu() { // it survives ContextMenu unmount. Closing the menu here avoids the // prior race where the portal dialog's Confirm click was treated as // "outside" by the menu's outside-click handler. - setPendingDelete({ id: contextMenu.nodeId, name: contextMenu.nodeData.name, hasChildren, children: children.map(c => ({ id: c.id, name: c.data.name })) }); + const childNodes = useCanvasStore.getState().nodes.filter((n) => n.data.parentId === contextMenu.nodeId); + setPendingDelete({ id: contextMenu.nodeId, name: contextMenu.nodeData.name, hasChildren, children: childNodes.map(c => ({ id: c.id, name: c.data.name })) }); closeContextMenu(); }, [contextMenu, setPendingDelete, closeContextMenu]); diff --git a/workspace-server/Dockerfile b/workspace-server/Dockerfile index 9bb26e72..dcd7841e 100644 --- a/workspace-server/Dockerfile +++ b/workspace-server/Dockerfile @@ -32,9 +32,21 @@ COPY workspace-server/migrations /migrations COPY --from=templates /workspace-configs-templates /workspace-configs-templates COPY --from=templates /org-templates /org-templates COPY --from=templates /plugins /plugins -# Non-root runtime — platform binary doesn't need root; dropping privileges -# prevents container escape attacks from reaching host UID 0. +# Non-root runtime with Docker socket access for workspace provisioning. RUN addgroup -g 1000 platform && adduser -u 1000 -G platform -s /bin/sh -D platform EXPOSE 8080 -USER platform -CMD ["/platform"] +COPY <<'ENTRY' /entrypoint.sh +#!/bin/sh +if [ -S /var/run/docker.sock ]; then + SOCK_GID=$(stat -c '%g' /var/run/docker.sock 2>/dev/null || stat -f '%g' /var/run/docker.sock 2>/dev/null) + if [ -n "$SOCK_GID" ] && [ "$SOCK_GID" != "0" ]; then + addgroup -g "$SOCK_GID" docker 2>/dev/null || true + addgroup platform docker 2>/dev/null || true + else + addgroup platform root 2>/dev/null || true + fi +fi +exec su-exec platform /platform "$@" +ENTRY +RUN chmod +x /entrypoint.sh && apk add --no-cache su-exec +ENTRYPOINT ["/entrypoint.sh"] diff --git a/workspace-server/internal/handlers/a2a_proxy.go b/workspace-server/internal/handlers/a2a_proxy.go index d1707070..5705487c 100644 --- a/workspace-server/internal/handlers/a2a_proxy.go +++ b/workspace-server/internal/handlers/a2a_proxy.go @@ -386,15 +386,29 @@ func (h *WorkspaceHandler) resolveAgentURL(ctx context.Context, workspaceID stri // When the platform runs inside Docker, 127.0.0.1:{host_port} is // unreachable (it's the platform container's own localhost, not the // Docker host). Rewrite to the container's Docker-bridge hostname. + isInternalDockerCall := false if strings.HasPrefix(agentURL, "http://127.0.0.1:") && h.provisioner != nil && platformInDocker { agentURL = provisioner.InternalURL(workspaceID) + isInternalDockerCall = true + } + // Also detect URLs already pointing to Docker-bridge hostnames (ws-:8000). + // Only trust the ws-* prefix in local-docker mode — in SaaS the workspace + // registry is remote and an attacker-controlled registration could claim a + // ws-* hostname that resolves to a sensitive internal VPC IP. + if platformInDocker && !saasMode() && strings.HasPrefix(agentURL, "http://ws-") { + isInternalDockerCall = true } // SSRF defence: reject private/metadata URLs before making outbound call. - if err := isSafeURL(agentURL); err != nil { - log.Printf("ProxyA2A: unsafe URL for workspace %s: %v", workspaceID, err) - return "", &proxyA2AError{ - Status: http.StatusBadGateway, - Response: gin.H{"error": "workspace URL is not publicly routable"}, + // Skip for Docker-internal workspace URLs — these always resolve to private + // IPs (172.18.0.x) on the bridge network, which is expected and safe when + // the platform itself runs in the same Docker network. + if !isInternalDockerCall { + if err := isSafeURL(agentURL); err != nil { + log.Printf("ProxyA2A: unsafe URL for workspace %s: %v", workspaceID, err) + return "", &proxyA2AError{ + Status: http.StatusBadGateway, + Response: gin.H{"error": "workspace URL is not publicly routable"}, + } } } return agentURL, nil