Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ on:
pull_request:
branches: [main, staging]

# Cancel in-progress CI runs when a new commit arrives on the same ref.
# This prevents multiple stale runs from queuing behind each other and
# monopolising the self-hosted macOS arm64 runner.
# Workflow-level concurrency: one CI run per branch/PR at a time.
# `cancel-in-progress: false` queues new runs — avoids starving the
# single self-hosted macOS arm64 runner with run-spawning-run churn.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: false

jobs:
# Detect which paths changed so downstream jobs can skip when only
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ on:
# Weekly run picks up findings in code that hasn't been touched.
- cron: '30 1 * * 0'

# Workflow-level concurrency: only one CodeQL run per branch/PR at a time.
# `cancel-in-progress: false` queues new runs — the 45-min analysis is the
# longest CI occupant and fights the single mac mini runner the hardest.
concurrency:
group: codeql-${{ github.ref }}
cancel-in-progress: false

permissions:
actions: read
contents: read
Expand Down
15 changes: 9 additions & 6 deletions canvas/src/app/__tests__/orgs-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ function setLocation(href: string) {
}

beforeEach(() => {
// Always reset to real timers first. If a previous polling test failed
// before its finally-block ran, fake timers would still be active and
// vi.useFakeTimers() in the polling tests would be a no-op — causing
// setTimeout(0) to hang and the test to time out.
vi.useRealTimers();
vi.clearAllMocks();
// Reset mock return values so each test starts fresh.
// The mock functions (vi.fn) persist across tests; only their
Expand All @@ -95,10 +100,9 @@ beforeEach(() => {
});

afterEach(() => {
cleanup();
});

afterEach(() => {
// Ensure fake timers are never left active after a test — even one that
// failed before reaching its own finally-block.
vi.useRealTimers();
cleanup();
});

Expand Down Expand Up @@ -130,8 +134,7 @@ describe("/orgs — error state", () => {
mockFetchSession.mockResolvedValue({ userId: "u-1" });
mockFetch.mockResolvedValueOnce(notOk(500, "db down"));
render(<OrgsPage />);
await new Promise((r) => setTimeout(r, 50));
expect(screen.getByText(/Error:/)).toBeTruthy();
await waitFor(() => expect(screen.getByText(/Error:/)).toBeTruthy());
expect(screen.getByRole("button", { name: /retry/i })).toBeTruthy();
} finally {
vi.useFakeTimers();
Expand Down
5 changes: 4 additions & 1 deletion canvas/src/components/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,10 @@ 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 });
const hasChildren = !!useCanvasStore.getState().nodes.some(
(n) => n.data.parentId === contextMenu.nodeId
);
setPendingDelete({ id: contextMenu.nodeId, name: contextMenu.nodeData.name, hasChildren });
closeContextMenu();
}, [contextMenu, setPendingDelete, closeContextMenu]);

Expand Down
8 changes: 6 additions & 2 deletions canvas/src/components/__tests__/ContextMenu.keyboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ const mockStore = {
};

vi.mock("@/store/canvas", () => ({
useCanvasStore: vi.fn(
(selector: (s: typeof mockStore) => unknown) => selector(mockStore)
useCanvasStore: Object.assign(
vi.fn((selector: (s: typeof mockStore) => unknown) => selector(mockStore)),
{ getState: () => mockStore }
),
}));

Expand Down Expand Up @@ -222,7 +223,10 @@ describe("ContextMenu — keyboard accessibility", () => {
const items = screen.getAllByRole("menuitem");
const deleteItem = items.find((el) => el.textContent?.includes("Delete"))!;
fireEvent.click(deleteItem);
// PR #1243 refactored delete flow: hoists confirmation to Canvas-level dialog
// via setPendingDelete, including hasChildren for correct warning text.
expect(mockStore.setPendingDelete).toHaveBeenCalledWith({
hasChildren: false,
id: "ws-1",
name: "Alpha Workspace",
});
Expand Down
16 changes: 16 additions & 0 deletions workspace-server/internal/handlers/container_files.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ import (
// maxExecOutput limits container exec output to 5MB to prevent OOM.
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.
// Checks provisioner name, full ID, and DB workspace name (same candidates as terminal handler).
func (h *TemplatesHandler) findContainer(ctx context.Context, workspaceID string) string {
Expand Down Expand Up @@ -143,6 +153,12 @@ func (h *TemplatesHandler) deleteViaEphemeral(ctx context.Context, volumeName, f
return fmt.Errorf("docker not available")
}

// CWE-78: validate filePath before constructing the rm command so
// a path-traversal sequence cannot escape /configs.
if err := validateRelPath(filePath); err != nil {
return err
}

resp, err := h.docker.ContainerCreate(ctx, &container.Config{
Image: "alpine:latest",
Cmd: []string{"rm", "-rf", "/configs/" + filePath},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ func (h *PluginsHandler) resolveAndStage(ctx context.Context, req installRequest
// traversal attempts yield 400 rather than a resolver-level 502.
if source.Scheme == "local" {
if err := validatePluginName(source.Spec); err != nil {
return nil, newHTTPErr(http.StatusBadRequest, gin.H{"error": "invalid local plugin name"})
return nil, newHTTPErr(http.StatusBadRequest, gin.H{"error": "invalid plugin name"})
}
}

Expand Down Expand Up @@ -205,7 +205,7 @@ func (h *PluginsHandler) resolveAndStage(ctx context.Context, req installRequest
if _, err := dirSize(stagedDir, limit); err != nil {
cleanup()
return nil, newHTTPErr(http.StatusRequestEntityTooLarge, gin.H{
"error": "plugin directory exceeds size limit",
"error": "staged plugin exceeds size limit",
"source": source.Raw(),
})
}
Expand Down
8 changes: 6 additions & 2 deletions workspace-server/internal/handlers/workspace_provision.go
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,11 @@ func (h *WorkspaceHandler) provisionWorkspaceCP(workspaceID, templatePath string
if err := h.envMutators.Run(ctx, workspaceID, envVars); err != nil {
log.Printf("CPProvisioner: env mutator failed for %s: %v", workspaceID, err)
db.DB.ExecContext(ctx, `UPDATE workspaces SET status = 'failed', last_sample_error = $2, updated_at = now() WHERE id = $1`,
<<<<<<< HEAD
workspaceID, "plugin env mutator chain failed")
=======
workspaceID, "provisioning failed")
>>>>>>> f9fff93 (fix(security): replace err.Error() leaks with prod-safe messages (#1206))
return
}

Expand All @@ -598,10 +602,10 @@ func (h *WorkspaceHandler) provisionWorkspaceCP(workspaceID, templatePath string
if err != nil {
log.Printf("CPProvisioner: failed to start workspace %s: %v", workspaceID, err)
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISION_FAILED", workspaceID, map[string]interface{}{
"error": "workspace start failed",
"error": "provisioning failed",
})
db.DB.ExecContext(ctx, `UPDATE workspaces SET status = 'failed', last_sample_error = $2, updated_at = now() WHERE id = $1`,
workspaceID, "workspace start failed")
workspaceID, "provisioning failed")
return
}

Expand Down
Loading
Loading