forked from molecule-ai/molecule-core
Merge origin/main into staging: resolve conflicts with main's test + security fixes
Conflicts resolved (took main's versions): - canvas/src/app/__tests__/orgs-page.test.tsx (act() wrappers, PR #1350) - canvas/src/components/Canvas.tsx (100px proximity threshold, PR #1357) - canvas/src/components/__tests__/ContextMenu.keyboard.test.tsx (hasChildren fix) - workspace-server/internal/handlers/container_files.go (CWE-22/CWE-78 fixes, PRs #1281/#1310) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
e9615af169
@ -15,6 +15,7 @@
|
|||||||
* - Polling: provisioning orgs schedule a 5s refresh (fake timers)
|
* - Polling: provisioning orgs schedule a 5s refresh (fake timers)
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { act } from "react";
|
||||||
import { render, screen, cleanup } from "@testing-library/react";
|
import { render, screen, cleanup } from "@testing-library/react";
|
||||||
|
|
||||||
// ── Hoisted mocks ────────────────────────────────────────────────────────────
|
// ── Hoisted mocks ────────────────────────────────────────────────────────────
|
||||||
@ -127,12 +128,9 @@ describe("/orgs — auth guard", () => {
|
|||||||
describe("/orgs — error state", () => {
|
describe("/orgs — error state", () => {
|
||||||
it("shows error + Retry button when /cp/orgs fails", async () => {
|
it("shows error + Retry button when /cp/orgs fails", async () => {
|
||||||
mockFetchSession.mockResolvedValue({ userId: "u-1" });
|
mockFetchSession.mockResolvedValue({ userId: "u-1" });
|
||||||
mockFetch.mockImplementationOnce(() =>
|
mockFetch.mockResolvedValueOnce(notOk(500, "db down"));
|
||||||
Promise.reject(new Error("GET /cp/orgs: 500"))
|
|
||||||
);
|
|
||||||
render(<OrgsPage />);
|
render(<OrgsPage />);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
|
||||||
await vi.runAllTimersAsync();
|
|
||||||
expect(screen.getByText(/Error:/)).toBeTruthy();
|
expect(screen.getByText(/Error:/)).toBeTruthy();
|
||||||
expect(screen.getByRole("button", { name: /retry/i })).toBeTruthy();
|
expect(screen.getByRole("button", { name: /retry/i })).toBeTruthy();
|
||||||
});
|
});
|
||||||
@ -143,7 +141,7 @@ describe("/orgs — empty list", () => {
|
|||||||
mockFetchSession.mockResolvedValue({ userId: "u-1" });
|
mockFetchSession.mockResolvedValue({ userId: "u-1" });
|
||||||
mockFetch.mockResolvedValueOnce(okJson({ orgs: [] }));
|
mockFetch.mockResolvedValueOnce(okJson({ orgs: [] }));
|
||||||
render(<OrgsPage />);
|
render(<OrgsPage />);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
|
||||||
expect(screen.getByText(/don't have any organizations/i)).toBeTruthy();
|
expect(screen.getByText(/don't have any organizations/i)).toBeTruthy();
|
||||||
expect(screen.getByRole("button", { name: /create organization/i })).toBeTruthy();
|
expect(screen.getByRole("button", { name: /create organization/i })).toBeTruthy();
|
||||||
});
|
});
|
||||||
@ -170,7 +168,7 @@ describe("/orgs — CTAs by status", () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
render(<OrgsPage />);
|
render(<OrgsPage />);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
|
||||||
const link = screen.getByRole("link", { name: /open/i }) as HTMLAnchorElement;
|
const link = screen.getByRole("link", { name: /open/i }) as HTMLAnchorElement;
|
||||||
expect(link.href).toBe("https://acme.moleculesai.app/");
|
expect(link.href).toBe("https://acme.moleculesai.app/");
|
||||||
});
|
});
|
||||||
@ -193,7 +191,7 @@ describe("/orgs — CTAs by status", () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
render(<OrgsPage />);
|
render(<OrgsPage />);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
|
||||||
const link = screen.getByRole("link", {
|
const link = screen.getByRole("link", {
|
||||||
name: /complete payment/i,
|
name: /complete payment/i,
|
||||||
}) as HTMLAnchorElement;
|
}) as HTMLAnchorElement;
|
||||||
@ -218,7 +216,7 @@ describe("/orgs — CTAs by status", () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
render(<OrgsPage />);
|
render(<OrgsPage />);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
|
||||||
const link = screen.getByRole("link", {
|
const link = screen.getByRole("link", {
|
||||||
name: /contact support/i,
|
name: /contact support/i,
|
||||||
}) as HTMLAnchorElement;
|
}) as HTMLAnchorElement;
|
||||||
@ -247,7 +245,7 @@ describe("/orgs — post-checkout banner", () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
render(<OrgsPage />);
|
render(<OrgsPage />);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
|
||||||
expect(screen.getByText(/Payment confirmed/i)).toBeTruthy();
|
expect(screen.getByText(/Payment confirmed/i)).toBeTruthy();
|
||||||
// URL must be rewritten to drop the ?checkout flag so reload doesn't re-show the banner
|
// URL must be rewritten to drop the ?checkout flag so reload doesn't re-show the banner
|
||||||
expect(replaceState).toHaveBeenCalled();
|
expect(replaceState).toHaveBeenCalled();
|
||||||
@ -259,7 +257,7 @@ describe("/orgs — post-checkout banner", () => {
|
|||||||
mockFetchSession.mockResolvedValue({ userId: "u-1" });
|
mockFetchSession.mockResolvedValue({ userId: "u-1" });
|
||||||
mockFetch.mockResolvedValueOnce(okJson({ orgs: [] }));
|
mockFetch.mockResolvedValueOnce(okJson({ orgs: [] }));
|
||||||
render(<OrgsPage />);
|
render(<OrgsPage />);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
|
||||||
expect(screen.getByText(/don't have any organizations/i)).toBeTruthy();
|
expect(screen.getByText(/don't have any organizations/i)).toBeTruthy();
|
||||||
expect(screen.queryByText(/Payment confirmed/i)).toBeNull();
|
expect(screen.queryByText(/Payment confirmed/i)).toBeNull();
|
||||||
});
|
});
|
||||||
@ -270,7 +268,7 @@ describe("/orgs — fetch includes credentials + timeout signal", () => {
|
|||||||
mockFetchSession.mockResolvedValue({ userId: "u-1" });
|
mockFetchSession.mockResolvedValue({ userId: "u-1" });
|
||||||
mockFetch.mockResolvedValueOnce(okJson({ orgs: [] }));
|
mockFetch.mockResolvedValueOnce(okJson({ orgs: [] }));
|
||||||
render(<OrgsPage />);
|
render(<OrgsPage />);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
|
||||||
const callArgs = mockFetch.mock.calls.find((c) =>
|
const callArgs = mockFetch.mock.calls.find((c) =>
|
||||||
String(c[0]).includes("/cp/orgs")
|
String(c[0]).includes("/cp/orgs")
|
||||||
);
|
);
|
||||||
|
|||||||
@ -30,7 +30,6 @@ import { SearchDialog } from "./SearchDialog";
|
|||||||
import { Toaster } from "./Toaster";
|
import { Toaster } from "./Toaster";
|
||||||
import { Toolbar } from "./Toolbar";
|
import { Toolbar } from "./Toolbar";
|
||||||
import { ConfirmDialog } from "./ConfirmDialog";
|
import { ConfirmDialog } from "./ConfirmDialog";
|
||||||
import { DeleteCascadeConfirmDialog } from "./DeleteCascadeConfirmDialog";
|
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { showToast } from "./Toaster";
|
import { showToast } from "./Toaster";
|
||||||
// Phase 20 components
|
// Phase 20 components
|
||||||
@ -39,14 +38,6 @@ import { SettingsPanel, DeleteConfirmDialog } from "./settings";
|
|||||||
import { BatchActionBar } from "./BatchActionBar";
|
import { BatchActionBar } from "./BatchActionBar";
|
||||||
import { ProvisioningTimeout } from "./ProvisioningTimeout";
|
import { ProvisioningTimeout } from "./ProvisioningTimeout";
|
||||||
|
|
||||||
// Drag-to-nest proximity: nodes must be within this many pixels (center-to-center)
|
|
||||||
// to trigger the "Nest Workspace" dialog. The default ReactFlow intersection
|
|
||||||
// detection uses bounding-box overlap which fires from large distances when
|
|
||||||
// nodes have large CSS min-width/min-height values.
|
|
||||||
const NEST_PROXIMITY_THRESHOLD = 150; // px — ~60% of a collapsed node width
|
|
||||||
const DEFAULT_NODE_WIDTH = 245; // px — approx mid-range of min-w-[210px] / max-w-[280px]
|
|
||||||
const DEFAULT_NODE_HEIGHT = 110; // px — approx min-height for a collapsed node
|
|
||||||
|
|
||||||
const nodeTypes = {
|
const nodeTypes = {
|
||||||
workspaceNode: WorkspaceNode,
|
workspaceNode: WorkspaceNode,
|
||||||
};
|
};
|
||||||
@ -85,6 +76,7 @@ function CanvasInner() {
|
|||||||
const nestNode = useCanvasStore((s) => s.nestNode);
|
const nestNode = useCanvasStore((s) => s.nestNode);
|
||||||
const isDescendant = useCanvasStore((s) => s.isDescendant);
|
const isDescendant = useCanvasStore((s) => s.isDescendant);
|
||||||
const dragStartParentRef = useRef<string | null>(null);
|
const dragStartParentRef = useRef<string | null>(null);
|
||||||
|
const { getIntersectingNodes } = useReactFlow();
|
||||||
|
|
||||||
const onNodeDragStart: OnNodeDrag<Node<WorkspaceNodeData>> = useCallback(
|
const onNodeDragStart: OnNodeDrag<Node<WorkspaceNodeData>> = useCallback(
|
||||||
(_event, node) => {
|
(_event, node) => {
|
||||||
@ -95,30 +87,25 @@ function CanvasInner() {
|
|||||||
|
|
||||||
const onNodeDrag: OnNodeDrag<Node<WorkspaceNodeData>> = useCallback(
|
const onNodeDrag: OnNodeDrag<Node<WorkspaceNodeData>> = useCallback(
|
||||||
(_event, node) => {
|
(_event, node) => {
|
||||||
const { nodes: allNodes } = useCanvasStore.getState();
|
// Only consider nodes within a proximity threshold as nest targets.
|
||||||
const nodeCenterX = node.position.x + (node.measured?.width ?? DEFAULT_NODE_WIDTH) / 2;
|
// Without this check, getIntersectingNodes returns any node whose bounding
|
||||||
const nodeCenterY = node.position.y + (node.measured?.height ?? DEFAULT_NODE_HEIGHT) / 2;
|
// boxes overlap — which can be hundreds of pixels away on a sparse canvas,
|
||||||
|
// causing accidental nesting when the user drags a node across the board.
|
||||||
let closest: string | null = null;
|
const thresholdPx = 100;
|
||||||
let closestDist = NEST_PROXIMITY_THRESHOLD;
|
const threshold = thresholdPx * thresholdPx; // compare squared distances
|
||||||
|
let nearest: { id: string; dist: number } | null = null;
|
||||||
for (const n of allNodes) {
|
for (const candidate of getIntersectingNodes(node)) {
|
||||||
if (n.id === node.id || isDescendant(node.id, n.id)) continue;
|
if (candidate.id === node.id || isDescendant(node.id, candidate.id)) continue;
|
||||||
const otherWidth = n.measured?.width ?? DEFAULT_NODE_WIDTH;
|
const dx = candidate.position.x - node.position.x;
|
||||||
const otherHeight = n.measured?.height ?? DEFAULT_NODE_HEIGHT;
|
const dy = candidate.position.y - node.position.y;
|
||||||
const otherCenterX = n.position.x + otherWidth / 2;
|
const dist2 = dx * dx + dy * dy;
|
||||||
const otherCenterY = n.position.y + otherHeight / 2;
|
if (dist2 <= threshold && (!nearest || dist2 < nearest.dist)) {
|
||||||
const dist = Math.sqrt(
|
nearest = { id: candidate.id, dist: dist2 };
|
||||||
(nodeCenterX - otherCenterX) ** 2 + (nodeCenterY - otherCenterY) ** 2
|
|
||||||
);
|
|
||||||
if (dist < closestDist) {
|
|
||||||
closestDist = dist;
|
|
||||||
closest = n.id;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setDragOverNode(closest);
|
setDragOverNode(nearest?.id ?? null);
|
||||||
},
|
},
|
||||||
[isDescendant, setDragOverNode]
|
[getIntersectingNodes, isDescendant, setDragOverNode]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Confirmation dialog state for structure changes
|
// Confirmation dialog state for structure changes
|
||||||
@ -130,23 +117,20 @@ function CanvasInner() {
|
|||||||
const pendingDelete = useCanvasStore((s) => s.pendingDelete);
|
const pendingDelete = useCanvasStore((s) => s.pendingDelete);
|
||||||
const setPendingDelete = useCanvasStore((s) => s.setPendingDelete);
|
const setPendingDelete = useCanvasStore((s) => s.setPendingDelete);
|
||||||
const removeNode = useCanvasStore((s) => s.removeNode);
|
const removeNode = useCanvasStore((s) => s.removeNode);
|
||||||
// Cascade guard: when deleting a workspace with children, the operator must
|
|
||||||
// tick "I understand the cascade" before Delete All becomes active.
|
|
||||||
const [cascadeConfirmChecked, setCascadeConfirmChecked] = useState(false);
|
|
||||||
const confirmDelete = useCallback(async () => {
|
const confirmDelete = useCallback(async () => {
|
||||||
if (!pendingDelete) return;
|
if (!pendingDelete) return;
|
||||||
// If hasChildren and checkbox not ticked, do nothing — user must confirm
|
|
||||||
if (pendingDelete.hasChildren && !cascadeConfirmChecked) return;
|
|
||||||
const { id } = pendingDelete;
|
const { id } = pendingDelete;
|
||||||
setPendingDelete(null);
|
setPendingDelete(null);
|
||||||
setCascadeConfirmChecked(false);
|
|
||||||
try {
|
try {
|
||||||
await api.del(`/workspaces/${id}?confirm=true`);
|
await api.del(`/workspaces/${id}?confirm=true`);
|
||||||
removeNode(id);
|
removeNode(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e instanceof Error ? e.message : "Delete failed", "error");
|
showToast(e instanceof Error ? e.message : "Delete failed", "error");
|
||||||
}
|
}
|
||||||
}, [pendingDelete, cascadeConfirmChecked, setPendingDelete, removeNode]);
|
}, [pendingDelete, setPendingDelete, removeNode]);
|
||||||
|
|
||||||
|
// Cascade guard: include child count in the warning message when the workspace
|
||||||
|
// has children, so the user understands the blast radius before clicking Delete All.
|
||||||
const cascadeMessage = pendingDelete?.hasChildren
|
const cascadeMessage = pendingDelete?.hasChildren
|
||||||
? `⚠️ Deleting "${pendingDelete.name}" will permanently delete all child workspaces and their data. This cannot be undone.`
|
? `⚠️ Deleting "${pendingDelete.name}" will permanently delete all child workspaces and their data. This cannot be undone.`
|
||||||
: null;
|
: null;
|
||||||
@ -413,31 +397,17 @@ function CanvasInner() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Confirmation dialog for workspace delete — driven by store */}
|
{/* Confirmation dialog for workspace delete — driven by store */}
|
||||||
{/* When the workspace has children, render an inline cascade guard instead
|
<ConfirmDialog
|
||||||
of the generic ConfirmDialog so we can show the child list and require
|
open={!!pendingDelete}
|
||||||
an explicit checkbox before Delete All activates. */}
|
title={pendingDelete?.hasChildren ? "Delete Workspace and Children" : "Delete Workspace"}
|
||||||
{pendingDelete ? (
|
message={pendingDelete?.hasChildren
|
||||||
pendingDelete.hasChildren ? (
|
? `⚠️ Deleting "${pendingDelete?.name}" will permanently delete all of its child workspaces and their data. This cannot be undone.`
|
||||||
<DeleteCascadeConfirmDialog
|
: `Permanently delete "${pendingDelete?.name}"? This will stop the container and remove all configuration. This action cannot be undone.`}
|
||||||
name={pendingDelete.name}
|
confirmLabel={pendingDelete?.hasChildren ? "Delete All" : "Delete"}
|
||||||
children={pendingDelete.children}
|
confirmVariant="danger"
|
||||||
checked={cascadeConfirmChecked}
|
onConfirm={confirmDelete}
|
||||||
onCheckedChange={setCascadeConfirmChecked}
|
onCancel={() => setPendingDelete(null)}
|
||||||
onConfirm={confirmDelete}
|
/>
|
||||||
onCancel={() => { setPendingDelete(null); setCascadeConfirmChecked(false); }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ConfirmDialog
|
|
||||||
open={true}
|
|
||||||
title="Delete Workspace"
|
|
||||||
message={`Permanently delete "${pendingDelete.name}"? This will stop the container and remove all configuration. This action cannot be undone.`}
|
|
||||||
confirmLabel="Delete"
|
|
||||||
confirmVariant="danger"
|
|
||||||
onConfirm={confirmDelete}
|
|
||||||
onCancel={() => setPendingDelete(null)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Settings Panel — global secrets management drawer */}
|
{/* Settings Panel — global secrets management drawer */}
|
||||||
<SettingsPanel workspaceId={settingsWorkspaceId} />
|
<SettingsPanel workspaceId={settingsWorkspaceId} />
|
||||||
|
|||||||
@ -202,6 +202,18 @@ describe("BudgetSection — progress bar", () => {
|
|||||||
const bar = screen.getByRole("progressbar");
|
const bar = screen.getByRole("progressbar");
|
||||||
expect(bar.getAttribute("aria-valuenow")).toBe("30");
|
expect(bar.getAttribute("aria-valuenow")).toBe("30");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows 0% progress bar when budget_used is absent from the response", async () => {
|
||||||
|
// Regression: budget_used is optional (provisioning-stuck workspaces return
|
||||||
|
// partial shapes). Without the `?? 0` guard the progressPct calculation
|
||||||
|
// throws a TypeScript strict-null error and the build fails.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await renderLoaded({ budget_limit: 1000, budget_remaining: null } as any);
|
||||||
|
const bar = screen.getByRole("progressbar");
|
||||||
|
expect(bar.getAttribute("aria-valuenow")).toBe("0");
|
||||||
|
const fill = screen.getByTestId("budget-progress-fill") as HTMLDivElement;
|
||||||
|
expect(fill.style.width).toBe("0%");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Input pre-fill ────────────────────────────────────────────────────────────
|
// ── Input pre-fill ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -16,6 +16,7 @@ afterEach(() => {
|
|||||||
// ── Shared fitView spy — must be set up before vi.mock hoisting ──────────────
|
// ── Shared fitView spy — must be set up before vi.mock hoisting ──────────────
|
||||||
const mockFitView = vi.fn();
|
const mockFitView = vi.fn();
|
||||||
const mockFitBounds = vi.fn();
|
const mockFitBounds = vi.fn();
|
||||||
|
const mockGetIntersectingNodes = vi.fn(() => []);
|
||||||
|
|
||||||
vi.mock("@xyflow/react", () => {
|
vi.mock("@xyflow/react", () => {
|
||||||
const ReactFlow = ({
|
const ReactFlow = ({
|
||||||
@ -44,7 +45,7 @@ vi.mock("@xyflow/react", () => {
|
|||||||
fitView: mockFitView,
|
fitView: mockFitView,
|
||||||
fitBounds: mockFitBounds,
|
fitBounds: mockFitBounds,
|
||||||
setViewport: vi.fn(),
|
setViewport: vi.fn(),
|
||||||
getIntersectingNodes: vi.fn(() => []),
|
getIntersectingNodes: mockGetIntersectingNodes,
|
||||||
setCenter: vi.fn(),
|
setCenter: vi.fn(),
|
||||||
}),
|
}),
|
||||||
applyNodeChanges: vi.fn((_: unknown, nodes: unknown) => nodes),
|
applyNodeChanges: vi.fn((_: unknown, nodes: unknown) => nodes),
|
||||||
@ -127,6 +128,46 @@ describe("Canvas — molecule:pan-to-node event handler", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockFitView.mockClear();
|
mockFitView.mockClear();
|
||||||
mockFitBounds.mockClear();
|
mockFitBounds.mockClear();
|
||||||
|
mockGetIntersectingNodes.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Nest proximity threshold (#1052) ─────────────────────────────────────
|
||||||
|
// onNodeDrag filters getIntersectingNodes results by distance <= 100px.
|
||||||
|
// We test this by verifying that getIntersectingNodes is called and
|
||||||
|
// setDragOverNode receives the correct nearest-within-threshold ID.
|
||||||
|
|
||||||
|
it("setDragOverNode is NOT called when all intersecting nodes are >100px away", () => {
|
||||||
|
const setDragOverNode = vi.fn();
|
||||||
|
mockStoreState.setDragOverNode = setDragOverNode;
|
||||||
|
mockGetIntersectingNodes.mockReturnValueOnce([
|
||||||
|
{ id: "far-ws", position: { x: 500, y: 500 } },
|
||||||
|
]);
|
||||||
|
render(<Canvas />);
|
||||||
|
// Trigger onNodeDrag by dispatching a drag start event on a node
|
||||||
|
const canvas = document.querySelector('[data-testid="react-flow"]');
|
||||||
|
expect(canvas).toBeTruthy();
|
||||||
|
// The component renders with getIntersectingNodes returning the far node.
|
||||||
|
// Since it's >100px away, setDragOverNode should never have been called
|
||||||
|
// with "far-ws" from the drag handler.
|
||||||
|
// Note: we verify the mock is configured correctly but the actual filter
|
||||||
|
// logic is exercised in the component — the regression test is visual:
|
||||||
|
// drag a node 200px+ from any target and confirm no "Nest Workspace" dialog.
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getIntersectingNodes is called on drag events", () => {
|
||||||
|
mockGetIntersectingNodes.mockReturnValueOnce([]);
|
||||||
|
render(<Canvas />);
|
||||||
|
mockGetIntersectingNodes.mockClear();
|
||||||
|
// Trigger drag — dispatch node drag event
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("molecule:pan-to-node", { detail: { nodeId: "ws-1" } })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// getIntersectingNodes is called on mouse drag (tested via implementation)
|
||||||
|
expect(mockGetIntersectingNodes).not.toHaveBeenCalled();
|
||||||
|
// (No DOM drag event in jsdom — the regression is confirmed by the
|
||||||
|
// Canvas.tsx change itself; the test confirms the mock hook is wired.)
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls fitView with the provisioned nodeId after a 100ms debounce", async () => {
|
it("calls fitView with the provisioned nodeId after a 100ms debounce", async () => {
|
||||||
|
|||||||
@ -49,11 +49,8 @@ const mockStore = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("@/store/canvas", () => ({
|
vi.mock("@/store/canvas", () => ({
|
||||||
// PR #1243 refactored delete flow: hoists confirmation to Canvas-level dialog
|
useCanvasStore: vi.fn(
|
||||||
// via setPendingDelete, including hasChildren for correct warning text.
|
(selector: (s: typeof mockStore) => unknown) => selector(mockStore)
|
||||||
useCanvasStore: Object.assign(
|
|
||||||
vi.fn((selector: (s: typeof mockStore) => unknown) => selector(mockStore)),
|
|
||||||
{ getState: () => mockStore }
|
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -225,14 +222,11 @@ describe("ContextMenu — keyboard accessibility", () => {
|
|||||||
const items = screen.getAllByRole("menuitem");
|
const items = screen.getAllByRole("menuitem");
|
||||||
const deleteItem = items.find((el) => el.textContent?.includes("Delete"))!;
|
const deleteItem = items.find((el) => el.textContent?.includes("Delete"))!;
|
||||||
fireEvent.click(deleteItem);
|
fireEvent.click(deleteItem);
|
||||||
expect(mockStore.setPendingDelete).toHaveBeenCalledWith(
|
expect(mockStore.setPendingDelete).toHaveBeenCalledWith({
|
||||||
expect.objectContaining({
|
id: "ws-1",
|
||||||
id: "ws-1",
|
name: "Alpha Workspace",
|
||||||
name: "Alpha Workspace",
|
hasChildren: false,
|
||||||
hasChildren: false,
|
});
|
||||||
children: [],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(closeContextMenu).toHaveBeenCalled();
|
expect(closeContextMenu).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -35,6 +35,9 @@ features:
|
|||||||
- title: Operational Control Plane
|
- title: Operational Control Plane
|
||||||
details: Registry, heartbeats, pause/resume/restart, approvals, activity logs, traces, terminal access, and runtime tiered provisioning.
|
details: Registry, heartbeats, pause/resume/restart, approvals, activity logs, traces, terminal access, and runtime tiered provisioning.
|
||||||
icon: "🛡️"
|
icon: "🛡️"
|
||||||
|
- title: Remote Agent Support
|
||||||
|
details: Register agents on any infrastructure — Docker, Fly Machines, bare metal, or laptops — and manage the full fleet from one canvas with bearer token auth and 30s heartbeat visibility.
|
||||||
|
icon: "🌐"
|
||||||
- title: Global Secrets
|
- title: Global Secrets
|
||||||
details: Platform-wide API keys can be inherited by every workspace, with workspace-level overrides when a role needs custom credentials.
|
details: Platform-wide API keys can be inherited by every workspace, with workspace-level overrides when a role needs custom credentials.
|
||||||
icon: "🔐"
|
icon: "🔐"
|
||||||
@ -71,3 +74,5 @@ features:
|
|||||||
- [Deploy AI Agents on Fly.io — or Any Cloud — with One Config Change](/blog/deploy-anywhere) *(2026-04-17)*
|
- [Deploy AI Agents on Fly.io — or Any Cloud — with One Config Change](/blog/deploy-anywhere) *(2026-04-17)*
|
||||||
- [Give Your AI Agent a Real Browser: MCP + Chrome DevTools](/blog/browser-automation-ai-agents-mcp) *(2026-04-20)*
|
- [Give Your AI Agent a Real Browser: MCP + Chrome DevTools](/blog/browser-automation-ai-agents-mcp) *(2026-04-20)*
|
||||||
- [Give Your AI Agent a Git Repository: Molecule AI + Cloudflare Artifacts](/blog/cloudflare-artifacts-molecule-ai) *(2026-04-21)*
|
- [Give Your AI Agent a Git Repository: Molecule AI + Cloudflare Artifacts](/blog/cloudflare-artifacts-molecule-ai) *(2026-04-21)*
|
||||||
|
- [One Canvas, Every Agent: Remote AI Agents and Fleet Visibility](/blog/remote-workspaces) *(2026-04-20)*
|
||||||
|
- [Skills Over Bundled Tools: Why Composable AI Beats Platform Primitives](/blog/skills-vs-bundled-tools-ai-agent-platforms) *(2026-04-21)*
|
||||||
|
|||||||
@ -104,13 +104,13 @@ The issue #1126 acceptance criteria specifies: "Coordinate with PMM (issue #1116
|
|||||||
| # | Action | Owner | Status |
|
| # | Action | Owner | Status |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1 | Keyword research (this brief) | SEO Analyst | ✅ Draft done |
|
| 1 | Keyword research (this brief) | SEO Analyst | ✅ Draft done |
|
||||||
| 2 | PMM positioning review | PMM (issue #1116) | ⏸ Pending |
|
| 2 | PMM positioning review | PMM (issue #1116) | ⏸ Holding — PMM Slack: "Phase 30 position holding" |
|
||||||
| 3 | Expand blog post with step-by-step | Content Marketer | ⏸ Pending PMM |
|
| 3 | Expand blog post with step-by-step | Content Marketer | ⏸ Pending PMM |
|
||||||
| 4 | Draft tutorial: "Register a Remote Agent" | Content Marketer | ⏸ Pending |
|
| 4 | Draft tutorial: "Register a Remote Agent" | SEO Analyst | ✅ Done — `docs/tutorials/register-remote-agent.md`, pushed to molecule-core@main |
|
||||||
| 5 | Draft tutorial: "Self-Hosted AI Agents" | Content Marketer | ⏸ Pending |
|
| 5 | Draft tutorial: "Self-Hosted AI Agents" | SEO Analyst | ✅ Done — `docs/tutorials/self-hosted-ai-agents.md`, pushed to molecule-core@main |
|
||||||
| 6 | Update workspace-runtime.md | DevRel | ⏸ Flag to DevRel |
|
| 6 | Update workspace-runtime.md | DevRel | ✅ Done — remote agent registration section already on main |
|
||||||
| 7 | Audit/create external-agent-registration.md | DevRel | ⏸ Flag to DevRel |
|
| 7 | Audit/create external-agent-registration.md | DevRel | ✅ Done — already on main, full coverage |
|
||||||
| 8 | Update quickstart.md | DevRel | ⏸ Flag to DevRel |
|
| 8 | Update quickstart.md + docs/index.md | DevRel | ✅ Done — Remote Agent path in quickstart; docs/index.md updated with Remote Agents feature card + blog links |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
191
docs/pages/api/workspace-files.mdx
Normal file
191
docs/pages/api/workspace-files.mdx
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
---
|
||||||
|
title: Workspace File Copy API
|
||||||
|
description: API reference for the workspace file copy and write operations, including CWE-22 path traversal protection.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Workspace File Copy API
|
||||||
|
|
||||||
|
> **Source:** `workspace-server/internal/handlers/container_files.go` + `templates.go`
|
||||||
|
> **Handler:** `TemplatesHandler.WriteFile` → `copyFilesToContainer`
|
||||||
|
> **Security:** CWE-22 path traversal protection (PRs #1267, #1270, #1271)
|
||||||
|
|
||||||
|
`copyFilesToContainer` is the internal Go implementation that powers workspace file write operations. It is not called directly by API clients — clients reach it through the HTTP handler `PUT /workspaces/:id/files/*path`.
|
||||||
|
|
||||||
|
## Endpoint Overview
|
||||||
|
|
||||||
|
`PUT /workspaces/:id/files/*path` writes a single file to a workspace container or its config volume.
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /workspaces/:id/files/*path
|
||||||
|
Authorization: Bearer <workspace-token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"content": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The handler (`TemplatesHandler.WriteFile`) validates the path, then routes to one of two backends:
|
||||||
|
|
||||||
|
| Workspace state | Backend | Method |
|
||||||
|
|---|---|---|
|
||||||
|
| Container running | Docker `CopyToContainer` (tar) | `copyFilesToContainer` |
|
||||||
|
| Container offline | Ephemeral Alpine container | `writeViaEphemeral` → `copyFilesToContainer` |
|
||||||
|
|
||||||
|
Both paths use `copyFilesToContainer` internally. The ephemeral container path mounts the config volume as `/configs` and calls the same function, so CWE-22 protection applies regardless of container state.
|
||||||
|
|
||||||
|
## Function Signature
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (h *TemplatesHandler) copyFilesToContainer(
|
||||||
|
ctx context.Context,
|
||||||
|
containerName string,
|
||||||
|
destPath string,
|
||||||
|
files map[string]string, // filename → content
|
||||||
|
) error
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `ctx` | `context.Context` | Request-scoped context |
|
||||||
|
| `containerName` | `string` | Docker container name or ID |
|
||||||
|
| `destPath` | `string` | Target directory inside the container (typically `/configs`) |
|
||||||
|
| `files` | `map[string]string` | Map of relative filenames to file contents |
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
### `containerName`
|
||||||
|
|
||||||
|
The running container for the workspace. Resolved by `TemplatesHandler.findContainer`, which checks three candidates in order:
|
||||||
|
|
||||||
|
1. Platform provisioner naming convention (`ws-<uuid>`)
|
||||||
|
2. The full workspace container ID
|
||||||
|
3. The workspace name from the database (spaces replaced with dashes)
|
||||||
|
|
||||||
|
If the container is not running, `findContainer` returns `""` and the handler falls back to `writeViaEphemeral`.
|
||||||
|
|
||||||
|
### `destPath`
|
||||||
|
|
||||||
|
The directory inside the container where files are written. In normal operation this is `/configs`, which is mounted from the platform-managed config volume. All file operations are constrained to this volume.
|
||||||
|
|
||||||
|
### `files` (`map[string]string`)
|
||||||
|
|
||||||
|
A map of relative filenames to their string content. File names are **relative paths only** — absolute paths and `..` traversal sequences are rejected before the tar header is written.
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
### CWE-22 Path Traversal Protection
|
||||||
|
|
||||||
|
**PRs #1267, #1270, #1271** added path traversal protection at the tar-archive-write boundary.
|
||||||
|
|
||||||
|
Before these PRs, `copyFilesToContainer` used raw map keys as tar header names without validation:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Before — UNSAFE
|
||||||
|
header := &tar.Header{
|
||||||
|
Name: name, // name came directly from map key
|
||||||
|
Mode: 0644,
|
||||||
|
Size: int64(len(data)),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A malicious caller embedding `../` in a file name could write outside the volume mount. Now:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// After — SAFE (PRs #1267 / #1270)
|
||||||
|
clean := filepath.Clean(name)
|
||||||
|
if filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") {
|
||||||
|
return fmt.Errorf("unsafe file path in archive: %s", name)
|
||||||
|
}
|
||||||
|
archiveName := filepath.Join(destPath, name)
|
||||||
|
header := &tar.Header{
|
||||||
|
Name: archiveName, // always inside destPath
|
||||||
|
Mode: 0644,
|
||||||
|
Size: int64(len(data)),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The validation works in three stages:
|
||||||
|
|
||||||
|
1. **`filepath.Clean`** normalizes the path (removes redundant separators, resolves `.`).
|
||||||
|
2. **Absolute path check** (`filepath.IsAbs`) rejects any path that resolves to an absolute OS path.
|
||||||
|
3. **`..` prefix check** (`strings.HasPrefix`) rejects paths that would escape the destination via parent-directory traversal.
|
||||||
|
|
||||||
|
The resulting `archiveName` is always inside `destPath`, so the tar header can never write outside the mounted volume regardless of input.
|
||||||
|
|
||||||
|
> **Defense in depth:** `WriteFile` (the HTTP handler) also calls `validateRelPath(filePath)` **before** passing the path to `copyFilesToContainer`. This closes the gap for any future caller that bypasses the handler-level check. Do not remove handler-level `validateRelPath` when modifying this code.
|
||||||
|
|
||||||
|
### Handler-Level Validation (`validateRelPath`)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func validateRelPath(relPath string) error {
|
||||||
|
clean := filepath.Clean(relPath)
|
||||||
|
if filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") {
|
||||||
|
return fmt.Errorf("path traversal blocked: %s", relPath)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`validateRelPath` is called at the start of every file operation handler (`WriteFile`, `ReadFile`, `DeleteFile`, `ListFiles`). Invalid paths return `400 Bad Request` with `{"error": "invalid path"}`.
|
||||||
|
|
||||||
|
Allowed root paths are also allow-listed: `root` must be one of `/configs`, `/workspace`, `/home`, or `/plugins`. Other values return `400 Bad Request`.
|
||||||
|
|
||||||
|
## Error Codes
|
||||||
|
|
||||||
|
`copyFilesToContainer` returns errors directly. The `WriteFile` HTTP handler wraps them:
|
||||||
|
|
||||||
|
| HTTP status | Condition | Response body |
|
||||||
|
|---|---|---|
|
||||||
|
| `400 Bad Request` | `validateRelPath` rejects the path (traversal attempt) | `{"error": "invalid path"}` |
|
||||||
|
| `400 Bad Request` | Malformed JSON body | `{"error": "invalid request body"}` |
|
||||||
|
| `404 Not Found` | Workspace not found in database | `{"error": "workspace not found"}` |
|
||||||
|
| `500 Internal Server Error` | Docker unavailable | `{"error": "failed to write file: docker not available"}` |
|
||||||
|
| `500 Internal Server Error` | Tar header write failure | `{"error": "failed to write file: failed to write tar header for <name>: ..."}` |
|
||||||
|
| `500 Internal Server Error` | Docker `CopyToContainer` failure | `{"error": "failed to write file: <docker error>"}` |
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
### Write a file to a workspace
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT https://platform.example.com/workspaces/ws-abc123/files/claude.md \
|
||||||
|
-H "Authorization: Bearer <workspace-token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"content": "# My Agent\n\nThis agent specializes in code review.\n"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success response (`200 OK`):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "saved",
|
||||||
|
"path": "claude.md"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Path traversal rejected
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT https://platform.example.com/workspaces/ws-abc123/files/../../etc/passwd \
|
||||||
|
-H "Authorization: Bearer <workspace-token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"content": "hacked"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rejection response (`400 Bad Request`):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "invalid path"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Platform API Reference](./platform-api.md) — full API endpoint table
|
||||||
|
- [Workspace Runtime](../agent-runtime/workspace-runtime.md) — runtime environment model
|
||||||
|
- `workspace-server/internal/handlers/templates.go` — `WriteFile`, `validateRelPath`
|
||||||
|
- `workspace-server/internal/handlers/container_files.go` — `copyFilesToContainer`, `writeViaEphemeral`
|
||||||
@ -71,7 +71,7 @@ func Import(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Store runtime in DB
|
// Store runtime in DB
|
||||||
_ = db.DB.ExecContext(ctx, `UPDATE workspaces SET runtime = $1 WHERE id = $2`, bundleRuntime, wsID)
|
_, _ = db.DB.ExecContext(ctx, `UPDATE workspaces SET runtime = $1 WHERE id = $2`, bundleRuntime, wsID)
|
||||||
|
|
||||||
// Provision the container if provisioner is available
|
// Provision the container if provisioner is available
|
||||||
if prov != nil {
|
if prov != nil {
|
||||||
|
|||||||
@ -6,9 +6,12 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@ -731,6 +734,125 @@ func parseUsageFromA2AResponse(body []byte) (inputTokens, outputTokens int64) {
|
|||||||
return 0, 0
|
return 0, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isSafeURL validates that a URL resolves to a publicly-routable address,
|
||||||
|
// preventing A2A requests from being redirected to internal/cloud-metadata
|
||||||
|
// infrastructure (SSRF, CWE-918). Workspace URLs come from DB/Redis caches
|
||||||
|
// so we validate before making any outbound HTTP call.
|
||||||
|
func isSafeURL(rawURL string) error {
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
// Reject non-HTTP(S) schemes.
|
||||||
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||||||
|
return fmt.Errorf("forbidden scheme: %s (only http/https allowed)", u.Scheme)
|
||||||
|
}
|
||||||
|
host := u.Hostname()
|
||||||
|
if host == "" {
|
||||||
|
return fmt.Errorf("empty hostname")
|
||||||
|
}
|
||||||
|
// Block direct IP addresses.
|
||||||
|
if ip := net.ParseIP(host); ip != nil {
|
||||||
|
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() {
|
||||||
|
return fmt.Errorf("forbidden loopback/unspecified IP: %s", ip)
|
||||||
|
}
|
||||||
|
if isPrivateOrMetadataIP(ip) {
|
||||||
|
return fmt.Errorf("forbidden private/metadata IP: %s", ip)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// For hostnames, resolve and validate each returned IP.
|
||||||
|
addrs, err := net.LookupHost(host)
|
||||||
|
if err != nil {
|
||||||
|
// DNS resolution failure — block it. Could be an internal hostname.
|
||||||
|
return fmt.Errorf("DNS resolution blocked for hostname: %s (%v)", host, err)
|
||||||
|
}
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
return fmt.Errorf("DNS returned no addresses for: %s", host)
|
||||||
|
}
|
||||||
|
for _, addr := range addrs {
|
||||||
|
ip := net.ParseIP(addr)
|
||||||
|
if ip != nil && (ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || isPrivateOrMetadataIP(ip)) {
|
||||||
|
return fmt.Errorf("hostname %s resolves to forbidden IP: %s", host, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPrivateOrMetadataIP returns true for cloud-metadata / loopback / link-local
|
||||||
|
// ranges (always) and RFC-1918 / IPv6 ULA ranges (self-hosted only).
|
||||||
|
//
|
||||||
|
// In SaaS cross-EC2 mode (see saasMode() in registry.go) the tenant platform
|
||||||
|
// and its workspaces share a VPC, so workspaces register with their
|
||||||
|
// VPC-private IP — typically 172.31.x.x on AWS default VPCs. Blocking RFC-1918
|
||||||
|
// unconditionally would reject every legitimate registration. Cloud metadata
|
||||||
|
// (169.254.0.0/16, fe80::/10), loopback, and TEST-NET ranges stay blocked in
|
||||||
|
// both modes; they are never a legitimate agent URL.
|
||||||
|
//
|
||||||
|
// Both IPv4 and IPv6 are checked. The previous implementation returned false
|
||||||
|
// for every non-IPv4 input, which meant a registered `[::1]` or `[fe80::…]`
|
||||||
|
// URL would bypass the SSRF gate entirely.
|
||||||
|
func isPrivateOrMetadataIP(ip net.IP) bool {
|
||||||
|
// Always blocked — IPv4 cloud metadata + network-test ranges.
|
||||||
|
metadataRangesV4 := []string{
|
||||||
|
"169.254.0.0/16", // link-local / IMDSv1-v2
|
||||||
|
"100.64.0.0/10", // CGNAT — reachable via some VPC configs, not a legit agent URL
|
||||||
|
"192.0.2.0/24", // TEST-NET-1
|
||||||
|
"198.51.100.0/24", // TEST-NET-2
|
||||||
|
"203.0.113.0/24", // TEST-NET-3
|
||||||
|
}
|
||||||
|
// Always blocked — IPv6 cloud-metadata / loopback equivalents.
|
||||||
|
metadataRangesV6 := []string{
|
||||||
|
"::1/128", // loopback
|
||||||
|
"fe80::/10", // link-local (IMDS analogue)
|
||||||
|
"::ffff:0:0/96", // IPv4-mapped loopback (defence-in-depth; To4() below usually normalises first)
|
||||||
|
}
|
||||||
|
// RFC-1918 private — blocked in self-hosted, allowed in SaaS.
|
||||||
|
rfc1918RangesV4 := []string{
|
||||||
|
"10.0.0.0/8",
|
||||||
|
"172.16.0.0/12",
|
||||||
|
"192.168.0.0/16",
|
||||||
|
}
|
||||||
|
// RFC-4193 ULA — IPv6 analogue of RFC-1918. Same SaaS-mode treatment.
|
||||||
|
ulaRangesV6 := []string{
|
||||||
|
"fc00::/7",
|
||||||
|
}
|
||||||
|
|
||||||
|
contains := func(cidrs []string, target net.IP) bool {
|
||||||
|
for _, c := range cidrs {
|
||||||
|
_, n, err := net.ParseCIDR(c)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n.Contains(target) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer IPv4 semantics when the input is an IPv4 address encoded in any
|
||||||
|
// form (raw v4, ::ffff:a.b.c.d, etc.) — To4() normalises all of them.
|
||||||
|
if ip4 := ip.To4(); ip4 != nil {
|
||||||
|
if contains(metadataRangesV4, ip4) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if saasMode() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return contains(rfc1918RangesV4, ip4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// True IPv6 path.
|
||||||
|
if contains(metadataRangesV6, ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if saasMode() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return contains(ulaRangesV6, ip)
|
||||||
|
}
|
||||||
|
|
||||||
// readUsageMap extracts input_tokens / output_tokens from the "usage" key of m.
|
// readUsageMap extracts input_tokens / output_tokens from the "usage" key of m.
|
||||||
// Returns (0, 0, false) when the key is absent or contains no non-zero values.
|
// Returns (0, 0, false) when the key is absent or contains no non-zero values.
|
||||||
func readUsageMap(m map[string]json.RawMessage) (inputTokens, outputTokens int64, ok bool) {
|
func readUsageMap(m map[string]json.RawMessage) (inputTokens, outputTokens int64, ok bool) {
|
||||||
|
|||||||
@ -18,16 +18,6 @@ import (
|
|||||||
// maxExecOutput limits container exec output to 5MB to prevent OOM.
|
// maxExecOutput limits container exec output to 5MB to prevent OOM.
|
||||||
const maxExecOutput = 5 * 1024 * 1024
|
const maxExecOutput = 5 * 1024 * 1024
|
||||||
|
|
||||||
// validateRelPath checks that a relative path is safe to use inside a
|
|
||||||
// bind-mounted directory. Blocks absolute paths and ".." traversal.
|
|
||||||
func validateRelPath(filePath string) error {
|
|
||||||
clean := filepath.Clean(filePath)
|
|
||||||
if filepath.IsAbs(clean) || strings.Contains(clean, "..") {
|
|
||||||
return fmt.Errorf("unsafe path: %s", filePath)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// findContainer finds a running container for the workspace.
|
// findContainer finds a running container for the workspace.
|
||||||
// Checks provisioner name, full ID, and DB workspace name (same candidates as terminal handler).
|
// Checks provisioner name, full ID, and DB workspace name (same candidates as terminal handler).
|
||||||
func (h *TemplatesHandler) findContainer(ctx context.Context, workspaceID string) string {
|
func (h *TemplatesHandler) findContainer(ctx context.Context, workspaceID string) string {
|
||||||
@ -77,24 +67,26 @@ func (h *TemplatesHandler) execInContainer(ctx context.Context, containerName st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// copyFilesToContainer creates a tar archive from a map of files and copies it into a container.
|
// copyFilesToContainer creates a tar archive from a map of files and copies it into a container.
|
||||||
|
// The destPath is prepended to each file name. File names must be relative and must not escape
|
||||||
|
// destPath via ".." segments — otherwise the tar header name could escape the mounted volume.
|
||||||
func (h *TemplatesHandler) copyFilesToContainer(ctx context.Context, containerName, destPath string, files map[string]string) error {
|
func (h *TemplatesHandler) copyFilesToContainer(ctx context.Context, containerName, destPath string, files map[string]string) error {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
tw := tar.NewWriter(&buf)
|
tw := tar.NewWriter(&buf)
|
||||||
|
|
||||||
createdDirs := map[string]bool{}
|
createdDirs := map[string]bool{}
|
||||||
for name, content := range files {
|
for name, content := range files {
|
||||||
// CWE-22: reject absolute paths and path-traversal sequences
|
// Block absolute paths and traversal attempts at the archive-write boundary.
|
||||||
// before using the name in the tar header.
|
// Files are written inside destPath (typically /configs); anything that escapes
|
||||||
|
// via ".." or an absolute name could reach other volumes or system paths.
|
||||||
clean := filepath.Clean(name)
|
clean := filepath.Clean(name)
|
||||||
if filepath.IsAbs(clean) || strings.Contains(clean, "..") {
|
if filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") {
|
||||||
return fmt.Errorf("path traversal blocked: %s", name)
|
return fmt.Errorf("unsafe file path in archive: %s", name)
|
||||||
}
|
}
|
||||||
// Use the safe, cleaned name joined with destPath so the tar
|
// Prepend destPath so relative paths land inside the volume mount.
|
||||||
// header Name is always a relative path inside destPath.
|
archiveName := filepath.Join(destPath, name)
|
||||||
safeName := filepath.Join(destPath, clean)
|
|
||||||
|
|
||||||
// Create parent directories in tar (deduplicated)
|
// Create parent directories in tar (deduplicated)
|
||||||
dir := filepath.Dir(safeName)
|
dir := filepath.Dir(archiveName)
|
||||||
if dir != destPath && !createdDirs[dir] {
|
if dir != destPath && !createdDirs[dir] {
|
||||||
tw.WriteHeader(&tar.Header{
|
tw.WriteHeader(&tar.Header{
|
||||||
Typeflag: tar.TypeDir,
|
Typeflag: tar.TypeDir,
|
||||||
@ -106,7 +98,7 @@ func (h *TemplatesHandler) copyFilesToContainer(ctx context.Context, containerNa
|
|||||||
|
|
||||||
data := []byte(content)
|
data := []byte(content)
|
||||||
header := &tar.Header{
|
header := &tar.Header{
|
||||||
Name: safeName,
|
Name: archiveName,
|
||||||
Mode: 0644,
|
Mode: 0644,
|
||||||
Size: int64(len(data)),
|
Size: int64(len(data)),
|
||||||
}
|
}
|
||||||
@ -162,10 +154,9 @@ func (h *TemplatesHandler) deleteViaEphemeral(ctx context.Context, volumeName, f
|
|||||||
if h.docker == nil {
|
if h.docker == nil {
|
||||||
return fmt.Errorf("docker not available")
|
return fmt.Errorf("docker not available")
|
||||||
}
|
}
|
||||||
|
// CWE-78/CWE-22: validate before use. Also switches to exec form
|
||||||
// CWE-78/CWE-22: validate before use. Also switch to exec form
|
// ([]string{...}) so filePath is passed as a plain argument, not
|
||||||
// ([]string{...}) so filePath is passed as a plain argument — eliminates
|
// interpolated into a shell string — eliminates shell injection entirely.
|
||||||
// shell injection entirely.
|
|
||||||
if err := validateRelPath(filePath); err != nil {
|
if err := validateRelPath(filePath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -184,7 +175,7 @@ func (h *TemplatesHandler) deleteViaEphemeral(ctx context.Context, volumeName, f
|
|||||||
if err := h.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
|
if err := h.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Wait for rm to finish before removing the container
|
// Wait for the rm command to finish before removing the container
|
||||||
statusCh, errCh := h.docker.ContainerWait(ctx, resp.ID, container.WaitConditionNotRunning)
|
statusCh, errCh := h.docker.ContainerWait(ctx, resp.ID, container.WaitConditionNotRunning)
|
||||||
select {
|
select {
|
||||||
case <-statusCh:
|
case <-statusCh:
|
||||||
|
|||||||
@ -27,9 +27,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -826,75 +824,10 @@ func (h *MCPHandler) toolRecallMemory(ctx context.Context, workspaceID string, a
|
|||||||
return string(b), nil
|
return string(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isSafeURL validates that a URL resolves to a publicly-routable address,
|
// isSafeURL and isPrivateOrMetadataIP live in a2a_proxy.go -- same package,
|
||||||
// preventing A2A requests from being redirected to internal/cloud-metadata
|
// shared across MCP + A2A proxy call sites. Keeping a single copy avoids
|
||||||
// infrastructure (SSRF, CWE-918). Workspace URLs come from DB/Redis caches
|
// drift between the two SSRF gates when one is tightened and the other
|
||||||
// so we validate before making any outbound HTTP call.
|
// isn't.
|
||||||
func isSafeURL(rawURL string) error {
|
|
||||||
u, err := url.Parse(rawURL)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid URL: %w", err)
|
|
||||||
}
|
|
||||||
// Reject non-HTTP(S) schemes.
|
|
||||||
if u.Scheme != "http" && u.Scheme != "https" {
|
|
||||||
return fmt.Errorf("forbidden scheme: %s (only http/https allowed)", u.Scheme)
|
|
||||||
}
|
|
||||||
host := u.Hostname()
|
|
||||||
if host == "" {
|
|
||||||
return fmt.Errorf("empty hostname")
|
|
||||||
}
|
|
||||||
// Block direct IP addresses.
|
|
||||||
if ip := net.ParseIP(host); ip != nil {
|
|
||||||
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() {
|
|
||||||
return fmt.Errorf("forbidden loopback/unspecified IP: %s", ip)
|
|
||||||
}
|
|
||||||
if isPrivateOrMetadataIP(ip) {
|
|
||||||
return fmt.Errorf("forbidden private/metadata IP: %s", ip)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// For hostnames, resolve and validate each returned IP.
|
|
||||||
addrs, err := net.LookupHost(host)
|
|
||||||
if err != nil {
|
|
||||||
// DNS resolution failure — block it. Could be an internal hostname.
|
|
||||||
return fmt.Errorf("DNS resolution blocked for hostname: %s (%v)", host, err)
|
|
||||||
}
|
|
||||||
if len(addrs) == 0 {
|
|
||||||
return fmt.Errorf("DNS returned no addresses for: %s", host)
|
|
||||||
}
|
|
||||||
for _, addr := range addrs {
|
|
||||||
ip := net.ParseIP(addr)
|
|
||||||
if ip != nil && (ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || isPrivateOrMetadataIP(ip)) {
|
|
||||||
return fmt.Errorf("hostname %s resolves to forbidden IP: %s", host, ip)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// isPrivateOrMetadataIP returns true for RFC-1918 private, carrier-grade NAT,
|
|
||||||
// link-local, and cloud metadata ranges.
|
|
||||||
func isPrivateOrMetadataIP(ip net.IP) bool {
|
|
||||||
var privateRanges = []net.IPNet{
|
|
||||||
{IP: net.ParseIP("10.0.0.0"), Mask: net.CIDRMask(8, 32)},
|
|
||||||
{IP: net.ParseIP("172.16.0.0"), Mask: net.CIDRMask(12, 32)},
|
|
||||||
{IP: net.ParseIP("192.168.0.0"), Mask: net.CIDRMask(16, 32)},
|
|
||||||
{IP: net.ParseIP("169.254.0.0"), Mask: net.CIDRMask(16, 32)},
|
|
||||||
{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)},
|
|
||||||
{IP: net.ParseIP("192.0.2.0"), Mask: net.CIDRMask(24, 32)},
|
|
||||||
{IP: net.ParseIP("198.51.100.0"), Mask: net.CIDRMask(24, 32)},
|
|
||||||
{IP: net.ParseIP("203.0.113.0"), Mask: net.CIDRMask(24, 32)},
|
|
||||||
}
|
|
||||||
ip = ip.To4()
|
|
||||||
if ip == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, r := range privateRanges {
|
|
||||||
if r.Contains(ip) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Helpers
|
// Helpers
|
||||||
@ -998,3 +931,4 @@ func extractA2AText(body []byte) string {
|
|||||||
b, _ := json.Marshal(result)
|
b, _ := json.Marshal(result)
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@ -713,3 +714,146 @@ func TestExtractA2AText_InvalidJSON_ReturnRaw(t *testing.T) {
|
|||||||
t.Errorf("extractA2AText: expected raw fallback, got %q", got)
|
t.Errorf("extractA2AText: expected raw fallback, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== SSRF Defence — isSafeURL ====================
|
||||||
|
|
||||||
|
func TestIsSafeURL_AllowsHTTPS(t *testing.T) {
|
||||||
|
err := isSafeURL("https://api.openai.com/v1/models")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("isSafeURL: expected https://api.openai.com to be allowed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSafeURL_AllowsPublicHTTP(t *testing.T) {
|
||||||
|
err := isSafeURL("http://example.com/agent")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("isSafeURL: expected http://example.com to be allowed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSafeURL_BlocksFileScheme(t *testing.T) {
|
||||||
|
err := isSafeURL("file:///etc/passwd")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("isSafeURL: expected file:// to be blocked, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSafeURL_BlocksFtpScheme(t *testing.T) {
|
||||||
|
err := isSafeURL("ftp://internal-host/file")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("isSafeURL: expected ftp:// to be blocked, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSafeURL_BlocksLocalhost(t *testing.T) {
|
||||||
|
err := isSafeURL("http://127.0.0.1:8080/agent")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("isSafeURL: expected 127.0.0.1 to be blocked, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSafeURL_BlocksLocalhostV6(t *testing.T) {
|
||||||
|
err := isSafeURL("http://[::1]:8080/agent")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("isSafeURL: expected [::1] to be blocked, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSafeURL_Blocks169_254_Metadata(t *testing.T) {
|
||||||
|
err := isSafeURL("http://169.254.169.254/latest/meta-data/")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("isSafeURL: expected 169.254.169.254 to be blocked, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSafeURL_Blocks10xPrivate(t *testing.T) {
|
||||||
|
err := isSafeURL("http://10.0.0.1/agent")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("isSafeURL: expected 10.x.x.x to be blocked, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSafeURL_Blocks172Private(t *testing.T) {
|
||||||
|
err := isSafeURL("http://172.16.0.1/agent")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("isSafeURL: expected 172.16.0.0/12 to be blocked, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSafeURL_Blocks192_168Private(t *testing.T) {
|
||||||
|
err := isSafeURL("http://192.168.1.100/agent")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("isSafeURL: expected 192.168.x.x to be blocked, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSafeURL_BlocksEmptyHost(t *testing.T) {
|
||||||
|
err := isSafeURL("http:///")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("isSafeURL: expected empty hostname to be blocked, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSafeURL_BlocksInvalidURL(t *testing.T) {
|
||||||
|
err := isSafeURL("http://[invalid")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("isSafeURL: expected invalid URL to be blocked, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SSRF Defence — isPrivateOrMetadataIP ====================
|
||||||
|
|
||||||
|
func TestIsPrivateOrMetadataIP_10Range(t *testing.T) {
|
||||||
|
tests := []string{"10.0.0.0", "10.255.255.255", "10.1.2.3"}
|
||||||
|
for _, ip := range tests {
|
||||||
|
if !isPrivateOrMetadataIP(net.ParseIP(ip)) {
|
||||||
|
t.Errorf("isPrivateOrMetadataIP: expected %s to be private", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPrivateOrMetadataIP_172Range(t *testing.T) {
|
||||||
|
tests := []string{"172.16.0.0", "172.31.255.255", "172.20.1.1"}
|
||||||
|
for _, ip := range tests {
|
||||||
|
if !isPrivateOrMetadataIP(net.ParseIP(ip)) {
|
||||||
|
t.Errorf("isPrivateOrMetadataIP: expected %s to be private", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPrivateOrMetadataIP_192_168Range(t *testing.T) {
|
||||||
|
tests := []string{"192.168.0.0", "192.168.255.255", "192.168.1.1"}
|
||||||
|
for _, ip := range tests {
|
||||||
|
if !isPrivateOrMetadataIP(net.ParseIP(ip)) {
|
||||||
|
t.Errorf("isPrivateOrMetadataIP: expected %s to be private", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPrivateOrMetadataIP_169_254Metadata(t *testing.T) {
|
||||||
|
if !isPrivateOrMetadataIP(net.ParseIP("169.254.169.254")) {
|
||||||
|
t.Errorf("isPrivateOrMetadataIP: expected 169.254.169.254 to be metadata")
|
||||||
|
}
|
||||||
|
if !isPrivateOrMetadataIP(net.ParseIP("169.254.0.1")) {
|
||||||
|
t.Errorf("isPrivateOrMetadataIP: expected 169.254.0.1 to be metadata")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPrivateOrMetadataIP_100_64CarrierNAT(t *testing.T) {
|
||||||
|
if !isPrivateOrMetadataIP(net.ParseIP("100.64.0.1")) {
|
||||||
|
t.Errorf("isPrivateOrMetadataIP: expected 100.64.0.0/10 to be carrier-NAT private")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPrivateOrMetadataIP_PublicAllowed(t *testing.T) {
|
||||||
|
public := []net.IP{
|
||||||
|
net.ParseIP("8.8.8.8"),
|
||||||
|
net.ParseIP("1.1.1.1"),
|
||||||
|
net.ParseIP("34.117.59.81"),
|
||||||
|
}
|
||||||
|
for _, ip := range public {
|
||||||
|
if isPrivateOrMetadataIP(ip) {
|
||||||
|
t.Errorf("isPrivateOrMetadataIP: expected %s to be public", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -111,7 +111,7 @@ func (h *OrgTokenHandler) Revoke(c *gin.Context) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "token not found or already revoked"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "token not found or already revoked"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
actor := orgTokenActor(c)
|
actor, _ := orgTokenActor(c)
|
||||||
log.Printf("orgtoken: revoked id=%s by=%s", id, actor)
|
log.Printf("orgtoken: revoked id=%s by=%s", id, actor)
|
||||||
c.JSON(http.StatusOK, gin.H{"revoked": id})
|
c.JSON(http.StatusOK, gin.H{"revoked": id})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,9 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||||
@ -17,6 +19,55 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// blockedRange is a named CIDR block so the conditional blocklist in
|
||||||
|
// validateAgentURL reads as a slice of homogeneous values instead of
|
||||||
|
// repeated anonymous struct literals.
|
||||||
|
type blockedRange struct {
|
||||||
|
cidr string
|
||||||
|
label string
|
||||||
|
}
|
||||||
|
|
||||||
|
// saasMode reports whether this tenant platform is running in SaaS cross-EC2
|
||||||
|
// mode, where workspaces live on sibling EC2s in the same VPC and register
|
||||||
|
// themselves by their RFC-1918 VPC-private IP (typically 172.31.x.x on AWS
|
||||||
|
// default VPCs). In that shape, the SSRF hardening that blocks RFC-1918
|
||||||
|
// addresses would reject every legitimate workspace registration — the
|
||||||
|
// control plane provisioned these instances, so their intra-VPC URLs are
|
||||||
|
// trusted by construction.
|
||||||
|
//
|
||||||
|
// Resolution order:
|
||||||
|
// 1. MOLECULE_DEPLOY_MODE set — explicit operator flag is authoritative.
|
||||||
|
// Recognised values: "saas" → true. "self-hosted" / "selfhosted" /
|
||||||
|
// "standalone" → false. Any other non-empty value logs a warning and
|
||||||
|
// falls closed (false) so a typo like MOLECULE_DEPLOY_MODE=prod can't
|
||||||
|
// silently flip a self-hosted deployment into the relaxed SSRF posture.
|
||||||
|
// 2. MOLECULE_DEPLOY_MODE unset — fall back to the MOLECULE_ORG_ID presence
|
||||||
|
// signal for deployments that predate the explicit flag.
|
||||||
|
//
|
||||||
|
// Self-hosted / single-container deployments set neither and keep the strict
|
||||||
|
// blocklist.
|
||||||
|
func saasMode() bool {
|
||||||
|
raw := os.Getenv("MOLECULE_DEPLOY_MODE")
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed != "" {
|
||||||
|
switch strings.ToLower(trimmed) {
|
||||||
|
case "saas":
|
||||||
|
return true
|
||||||
|
case "self-hosted", "selfhosted", "standalone":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
// Warn-once so operators notice the typo without spamming logs.
|
||||||
|
saasModeWarnUnknownOnce.Do(func() {
|
||||||
|
log.Printf("saasMode: MOLECULE_DEPLOY_MODE=%q not recognised; falling back to strict (non-SaaS) mode. Valid values: saas | self-hosted.", raw)
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(os.Getenv("MOLECULE_ORG_ID")) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var saasModeWarnUnknownOnce sync.Once
|
||||||
|
|
||||||
type RegistryHandler struct {
|
type RegistryHandler struct {
|
||||||
broadcaster *events.Broadcaster
|
broadcaster *events.Broadcaster
|
||||||
}
|
}
|
||||||
@ -64,18 +115,27 @@ func validateAgentURL(rawURL string) error {
|
|||||||
}
|
}
|
||||||
hostname := parsed.Hostname()
|
hostname := parsed.Hostname()
|
||||||
|
|
||||||
blockedRanges := []struct {
|
// Link-local / loopback / IPv6 metadata classes are blocked in every
|
||||||
cidr string
|
// mode — they are never a legitimate agent URL and they cover the AWS/
|
||||||
label string
|
// GCP/Azure IMDS endpoints. RFC-1918 ranges are conditionally blocked:
|
||||||
}{
|
// in SaaS mode workspaces register with their VPC-private IP and the
|
||||||
|
// control plane is the source of truth for which instances exist, so
|
||||||
|
// allowing 10/8, 172.16/12, 192.168/16 is safe. In self-hosted mode
|
||||||
|
// we keep the strict blocklist — those deployments have no legitimate
|
||||||
|
// reason to accept private-range URLs from agents.
|
||||||
|
blockedRanges := []blockedRange{
|
||||||
{"169.254.0.0/16", "link-local address (cloud metadata endpoint)"},
|
{"169.254.0.0/16", "link-local address (cloud metadata endpoint)"},
|
||||||
{"127.0.0.0/8", "loopback address"},
|
{"127.0.0.0/8", "loopback address"},
|
||||||
{"10.0.0.0/8", "RFC-1918 private address"},
|
|
||||||
{"172.16.0.0/12", "RFC-1918 private address"},
|
|
||||||
{"192.168.0.0/16", "RFC-1918 private address"},
|
|
||||||
{"fe80::/10", "IPv6 link-local address (cloud metadata analogue)"},
|
{"fe80::/10", "IPv6 link-local address (cloud metadata analogue)"},
|
||||||
{"::1/128", "IPv6 loopback address"},
|
{"::1/128", "IPv6 loopback address"},
|
||||||
{"fc00::/7", "IPv6 ULA address (RFC-4193 private)"},
|
}
|
||||||
|
if !saasMode() {
|
||||||
|
blockedRanges = append(blockedRanges,
|
||||||
|
blockedRange{"10.0.0.0/8", "RFC-1918 private address"},
|
||||||
|
blockedRange{"172.16.0.0/12", "RFC-1918 private address"},
|
||||||
|
blockedRange{"192.168.0.0/16", "RFC-1918 private address"},
|
||||||
|
blockedRange{"fc00::/7", "IPv6 ULA address (RFC-4193 private)"},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: check a single IP against the blocklist.
|
// Helper: check a single IP against the blocklist.
|
||||||
|
|||||||
@ -7,6 +7,124 @@ import (
|
|||||||
|
|
||||||
// isSafeURL is defined in mcp.go.
|
// isSafeURL is defined in mcp.go.
|
||||||
// isPrivateOrMetadataIP is defined in mcp.go.
|
// isPrivateOrMetadataIP is defined in mcp.go.
|
||||||
|
// saasMode is defined in registry.go.
|
||||||
|
|
||||||
|
// TestSaasMode covers the env-resolution ladder so a self-hosted
|
||||||
|
// operator can't accidentally flip into SaaS mode by leaving a stale
|
||||||
|
// MOLECULE_ORG_ID around, and an explicit MOLECULE_DEPLOY_MODE wins
|
||||||
|
// over the legacy implicit signal.
|
||||||
|
func TestSaasMode(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
deployMode string
|
||||||
|
orgID string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"both unset", "", "", false},
|
||||||
|
{"legacy org id only", "", "7b2179dc-8cc6-4581-a3c6-c8bff4481086", true},
|
||||||
|
{"explicit saas", "saas", "", true},
|
||||||
|
{"explicit saas overrides missing org", "SaaS", "", true}, // case-insensitive
|
||||||
|
{"explicit self-hosted wins over legacy org id", "self-hosted", "some-org", false},
|
||||||
|
{"explicit selfhosted wins over legacy org id", "selfhosted", "some-org", false},
|
||||||
|
{"explicit standalone wins over legacy org id", "standalone", "some-org", false},
|
||||||
|
{"whitespace-only deploy mode falls through to legacy", " ", "some-org", true},
|
||||||
|
{"whitespace-only org id falls through to false", "", " ", false},
|
||||||
|
// Typo / unknown values: must fall closed (strict / self-hosted)
|
||||||
|
// instead of falling through to the MOLECULE_ORG_ID legacy signal.
|
||||||
|
// Any tenant deployment has MOLECULE_ORG_ID set, so a typo like
|
||||||
|
// MOLECULE_DEPLOY_MODE=prod used to silently flip into SaaS mode.
|
||||||
|
{"typo prod falls closed even with org id set", "prod", "some-org", false},
|
||||||
|
{"typo SaaS-mode falls closed even with org id set", "SaaS-mode", "some-org", false},
|
||||||
|
{"typo production falls closed", "production", "", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Setenv("MOLECULE_DEPLOY_MODE", tc.deployMode)
|
||||||
|
t.Setenv("MOLECULE_ORG_ID", tc.orgID)
|
||||||
|
if got := saasMode(); got != tc.want {
|
||||||
|
t.Errorf("saasMode() = %v, want %v (MOLECULE_DEPLOY_MODE=%q MOLECULE_ORG_ID=%q)",
|
||||||
|
got, tc.want, tc.deployMode, tc.orgID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsPrivateOrMetadataIP_SaaSMode covers the SaaS-mode relaxation:
|
||||||
|
// RFC-1918 and ULA ranges are allowed, but metadata / loopback / TEST-NET
|
||||||
|
// classes stay blocked in every mode. Regression guard for the core
|
||||||
|
// SaaS provisioning fix (issue: workspaces register with their VPC
|
||||||
|
// private IP, which is 172.31.x.x on AWS default VPCs).
|
||||||
|
func TestIsPrivateOrMetadataIP_SaaSMode(t *testing.T) {
|
||||||
|
t.Setenv("MOLECULE_DEPLOY_MODE", "saas")
|
||||||
|
t.Setenv("MOLECULE_ORG_ID", "")
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
ipStr string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// RFC-1918 must be ALLOWED in SaaS mode.
|
||||||
|
{"172.31 allowed in saas", "172.31.44.78", false},
|
||||||
|
{"10/8 allowed in saas", "10.0.0.5", false},
|
||||||
|
{"192.168 allowed in saas", "192.168.1.1", false},
|
||||||
|
// IPv6 ULA must be ALLOWED in SaaS mode (AWS IPv6 VPC analogue).
|
||||||
|
{"fd00 ULA allowed in saas", "fd12:3456:789a::1", false},
|
||||||
|
// Metadata stays BLOCKED even in SaaS mode.
|
||||||
|
{"169.254 still blocked", "169.254.169.254", true},
|
||||||
|
// 127/8 loopback is NOT checked by isPrivateOrMetadataIP itself --
|
||||||
|
// the caller (isSafeURL) checks ip.IsLoopback() separately. We assert
|
||||||
|
// the helper's own semantics here, not the aggregate gate.
|
||||||
|
{"127/8 not checked by this helper (isSafeURL covers it)", "127.0.0.1", false},
|
||||||
|
{"::1 still blocked (IPv6 metadata)", "::1", true},
|
||||||
|
{"fe80 still blocked", "fe80::1", true},
|
||||||
|
// TEST-NET stays blocked.
|
||||||
|
{"192.0.2.x still blocked", "192.0.2.5", true},
|
||||||
|
{"198.51.100.x still blocked", "198.51.100.5", true},
|
||||||
|
{"203.0.113.x still blocked", "203.0.113.5", true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ip := net.ParseIP(tc.ipStr)
|
||||||
|
if ip == nil {
|
||||||
|
t.Fatalf("ParseIP(%q) returned nil", tc.ipStr)
|
||||||
|
}
|
||||||
|
if got := isPrivateOrMetadataIP(ip); got != tc.want {
|
||||||
|
t.Errorf("isPrivateOrMetadataIP(%s) = %v, want %v", tc.ipStr, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsPrivateOrMetadataIP_IPv6 covers the IPv6 gap the previous
|
||||||
|
// implementation had — it returned false for every IPv6 literal
|
||||||
|
// unconditionally, which would let a registered [::1] or [fe80::…]
|
||||||
|
// URL bypass the SSRF check entirely.
|
||||||
|
func TestIsPrivateOrMetadataIP_IPv6(t *testing.T) {
|
||||||
|
t.Setenv("MOLECULE_DEPLOY_MODE", "")
|
||||||
|
t.Setenv("MOLECULE_ORG_ID", "")
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
ipStr string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"::1 loopback blocked", "::1", true},
|
||||||
|
{"fe80 link-local blocked", "fe80::1", true},
|
||||||
|
{"fe80 link-local with mac blocked", "fe80::a00:27ff:fe00:1", true},
|
||||||
|
{"fc00 ULA blocked (non-saas)", "fc00::1", true},
|
||||||
|
{"fd00 ULA blocked (non-saas)", "fd12::1", true},
|
||||||
|
{"public v6 allowed", "2606:4700:4700::1111", false}, // 1.1.1.1 v6
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ip := net.ParseIP(tc.ipStr)
|
||||||
|
if ip == nil {
|
||||||
|
t.Fatalf("ParseIP(%q) returned nil", tc.ipStr)
|
||||||
|
}
|
||||||
|
if got := isPrivateOrMetadataIP(ip); got != tc.want {
|
||||||
|
t.Errorf("isPrivateOrMetadataIP(%s) = %v, want %v", tc.ipStr, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsPrivateOrMetadataIP(t *testing.T) {
|
func TestIsPrivateOrMetadataIP(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
@ -34,7 +152,11 @@ func TestIsPrivateOrMetadataIP(t *testing.T) {
|
|||||||
// Must be allowed: public IP addresses
|
// Must be allowed: public IP addresses
|
||||||
{"8.8.8.8", "8.8.8.8", false},
|
{"8.8.8.8", "8.8.8.8", false},
|
||||||
{"1.1.1.1", "1.1.1.1", false},
|
{"1.1.1.1", "1.1.1.1", false},
|
||||||
{"203.0.113.254", "203.0.113.254", false}, // TEST-NET-3 max — above 203.0.113.0/24 range end
|
// Previously asserted (incorrectly) that 203.0.113.254 is public --
|
||||||
|
// the original test's comment claimed the address was "above 203.0.113.0/24
|
||||||
|
// range end", but 203.0.113.0/24 spans 203.0.113.0-255, so .254 IS in
|
||||||
|
// range and correctly blocked. Assertion flipped to match reality.
|
||||||
|
{"203.0.113.254 (TEST-NET-3)", "203.0.113.254", true},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|||||||
@ -247,10 +247,11 @@ func seedInitialMemories(ctx context.Context, workspaceID string, memories []mod
|
|||||||
log.Printf("seedInitialMemories: truncated memory content for %s (scope=%s) from %d to %d bytes",
|
log.Printf("seedInitialMemories: truncated memory content for %s (scope=%s) from %d to %d bytes",
|
||||||
workspaceID, scope, len(mem.Content), maxMemoryContentLength)
|
workspaceID, scope, len(mem.Content), maxMemoryContentLength)
|
||||||
}
|
}
|
||||||
|
redactedContent, _ := redactSecrets(workspaceID, content)
|
||||||
if _, err := db.DB.ExecContext(ctx, `
|
if _, err := db.DB.ExecContext(ctx, `
|
||||||
INSERT INTO agent_memories (workspace_id, content, scope, namespace)
|
INSERT INTO agent_memories (workspace_id, content, scope, namespace)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
`, workspaceID, redactSecrets(workspaceID, content), scope, awarenessNamespace); err != nil {
|
`, workspaceID, redactedContent, scope, awarenessNamespace); err != nil {
|
||||||
log.Printf("seedInitialMemories: failed to insert memory for %s (scope=%s): %v", workspaceID, scope, err)
|
log.Printf("seedInitialMemories: failed to insert memory for %s (scope=%s): %v", workspaceID, scope, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -333,13 +334,29 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
|
|||||||
// provisioning continues — the workspace will get 401 on its first heartbeat
|
// provisioning continues — the workspace will get 401 on its first heartbeat
|
||||||
// and can recover on the next restart.
|
// and can recover on the next restart.
|
||||||
func (h *WorkspaceHandler) issueAndInjectToken(ctx context.Context, workspaceID string, cfg *provisioner.WorkspaceConfig) {
|
func (h *WorkspaceHandler) issueAndInjectToken(ctx context.Context, workspaceID string, cfg *provisioner.WorkspaceConfig) {
|
||||||
// Revoke any existing live tokens. If this fails we bail out rather than
|
// Revoke any existing live tokens FIRST — this must run in both modes.
|
||||||
// issuing a second live token whose plaintext we can't also deliver.
|
// In SaaS mode the revoke is load-bearing on re-provision: without it,
|
||||||
|
// the previous workspace instance's live token sits in the DB, and
|
||||||
|
// RegistryHandler.requireWorkspaceToken on the fresh instance's first
|
||||||
|
// /registry/register would reject it (live token exists → no
|
||||||
|
// bootstrap allowance, but the new workspace has no plaintext because
|
||||||
|
// the CP provisioner doesn't carry cfg.ConfigFiles across user-data).
|
||||||
|
// Revoking clears the gate so the register handler's bootstrap path
|
||||||
|
// can mint a fresh token and return the plaintext in the response.
|
||||||
if err := wsauth.RevokeAllForWorkspace(ctx, db.DB, workspaceID); err != nil {
|
if err := wsauth.RevokeAllForWorkspace(ctx, db.DB, workspaceID); err != nil {
|
||||||
log.Printf("Provisioner: failed to revoke existing tokens for %s: %v — skipping auth-token injection", workspaceID, err)
|
log.Printf("Provisioner: failed to revoke existing tokens for %s: %v — skipping auth-token injection", workspaceID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaaS mode skips the IssueToken + ConfigFiles write because both
|
||||||
|
// only make sense on the Docker provisioner's volume-mount delivery
|
||||||
|
// path. The register handler mints a fresh token on first successful
|
||||||
|
// register and returns the plaintext in the response body for the
|
||||||
|
// runtime to persist locally.
|
||||||
|
if saasMode() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
token, err := wsauth.IssueToken(ctx, db.DB, workspaceID)
|
token, err := wsauth.IssueToken(ctx, db.DB, workspaceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Provisioner: failed to issue auth token for %s: %v — skipping auth-token injection", workspaceID, err)
|
log.Printf("Provisioner: failed to issue auth token for %s: %v — skipping auth-token injection", workspaceID, err)
|
||||||
@ -615,14 +632,13 @@ func (h *WorkspaceHandler) provisionWorkspaceCP(workspaceID, templatePath string
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("CPProvisioner: workspace %s started as machine %s via control plane", workspaceID, machineID)
|
log.Printf("CPProvisioner: workspace %s started as machine %s via control plane", workspaceID, machineID)
|
||||||
// Issue token so the agent can authenticate on boot
|
// Token issuance is deliberately deferred to the workspace's first
|
||||||
token, tokenErr := wsauth.IssueToken(ctx, db.DB, workspaceID)
|
// /registry/register call. Minting here without also delivering the
|
||||||
if tokenErr != nil {
|
// plaintext to the workspace (via user-data or a follow-up callback)
|
||||||
log.Printf("CPProvisioner: failed to issue token for %s: %v", workspaceID, tokenErr)
|
// would leave a live token in DB that the workspace has no copy of —
|
||||||
} else {
|
// RegistryHandler.requireWorkspaceToken would then 401 every
|
||||||
// Don't log any prefix of the token. Earlier H1 regression showed
|
// /registry/register attempt because the workspace is no longer in the
|
||||||
// this slice pattern (token[:8]) panics when a helper returns a
|
// "no live tokens → bootstrap-allowed" state. The register handler
|
||||||
// short value. Length alone is enough to confirm a token issued.
|
// already mints a token on first successful register and returns it in
|
||||||
log.Printf("CPProvisioner: issued auth token for workspace %s (len=%d)", workspaceID, len(token))
|
// the response body for the workspace to persist.
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -255,6 +255,7 @@ func (s *Scheduler) fireSchedule(ctx context.Context, sched scheduleRow) {
|
|||||||
if _, execErr := db.DB.ExecContext(context.Background(), `UPDATE workspace_schedules SET next_run_at=$1, updated_at=now() WHERE id=$2`, nextTime, sched.ID); execErr != nil {
|
if _, execErr := db.DB.ExecContext(context.Background(), `UPDATE workspace_schedules SET next_run_at=$1, updated_at=now() WHERE id=$2`, nextTime, sched.ID); execErr != nil {
|
||||||
log.Printf("Scheduler: panic-recovery next_run_at UPDATE failed for schedule %s: %v", sched.ID, execErr)
|
log.Printf("Scheduler: panic-recovery next_run_at UPDATE failed for schedule %s: %v", sched.ID, execErr)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user