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
9 changes: 6 additions & 3 deletions workspace-server/internal/handlers/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -402,8 +402,11 @@ func (h *TemplatesHandler) DeleteFile(c *gin.Context) {

// Delete via docker exec when container is running
if containerName := h.findContainer(ctx, workspaceID); containerName != "" {
containerPath := "/configs/" + filePath
_, err := h.execInContainer(ctx, containerName, []string{"rm", "-rf", containerPath})
// Two-arg exec form: rm -rf /configs filePath.
// Passes /configs as working dir and filePath as the target arg separately,
// so filePath is never interpolated into a shell path that could escape
// the /configs volume bind mount.
_, err := h.execInContainer(ctx, containerName, []string{"rm", "-rf", "/configs", filePath})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to delete: %v", err)})
return
Expand Down Expand Up @@ -459,7 +462,7 @@ func (h *TemplatesHandler) SharedContext(c *gin.Context) {
if err := validateRelPath(relPath); err != nil {
continue
}
content, err := h.execInContainer(ctx, containerName, []string{"cat", "/configs/" + relPath})
content, err := h.execInContainer(ctx, containerName, []string{"cat", "/configs", relPath})
if err != nil {
continue
}
Expand Down
31 changes: 31 additions & 0 deletions workspace-server/internal/handlers/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import (

"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/registry"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"github.com/creack/pty"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
Expand Down Expand Up @@ -45,6 +47,11 @@ var termUpgrader = websocket.Upgrader{
},
}

// canCommunicateCheck is the communication-authorization predicate used by
// HandleConnect to enforce the KI-005 workspace-hierarchy guard.
// Exposed as a package var so tests can stub it without DB fixtures.
var canCommunicateCheck = registry.CanCommunicate

type TerminalHandler struct {
docker *client.Client
}
Expand All @@ -60,6 +67,30 @@ func (h *TerminalHandler) HandleConnect(c *gin.Context) {
workspaceID := c.Param("id")
ctx := c.Request.Context()

// KI-005 fix: enforce CanCommunicate hierarchy check before granting
// terminal access. Without this, Workspace A can reach Workspace B's
// terminal if it knows B's UUID (enumeration via canvas, logs, delegation).
// Shell access is more dangerous than A2A message-passing, so we apply
// the same hierarchy check here.
callerID := c.GetHeader("X-Workspace-ID")
if callerID != "" && callerID != workspaceID {
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
if tok != "" {
// Verify the bearer token belongs to the claimed workspace identity.
// ValidateToken is more restrictive than ValidateAnyToken — it
// binds the token to the specific X-Workspace-ID claim, preventing
// Workspace A from forging B's identity with a valid org-scoped token.
if err := wsauth.ValidateToken(ctx, db.DB, callerID, tok); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token for claimed workspace"})
return
}
}
if !canCommunicateCheck(callerID, workspaceID) {
c.JSON(http.StatusForbidden, gin.H{"error": "not authorized to access this workspace's terminal"})
return
}
}

// Check for CP-provisioned workspace (instance_id persisted by
// provisionWorkspaceCP → migration 038). Null instance_id means the
// workspace runs as a local Docker container on this tenant.
Expand Down
143 changes: 143 additions & 0 deletions workspace-server/internal/handlers/terminal_auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package handlers

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/gin-gonic/gin"
)

// testCanCommunicateCalls stub for tests — tracks last call.
var testCanCommunicateCalls [][2]string

func init() {
gin.SetMode(gin.TestMode)
}

// stubCanCommunicate records calls and returns true (allows all) or false
// (denies all) based on the test scenario.
func stubCanCommunicate(callerID, targetID string) bool {
testCanCommunicateCalls = append(testCanCommunicateCalls, [2]string{callerID, targetID})
return len(testCanCommunicateCalls) > 0 && testCanCommunicateCalls[len(testCanCommunicateCalls)-1][0] != "ws-blocked"
}

// TestKI005_TerminalAuth_HierarchyGuard verifies that HandleConnect enforces
// CanCommunicate(callerID, workspaceID) before granting terminal access.
func TestKI005_TerminalAuth_HierarchyGuard(t *testing.T) {
// Save and restore the real canCommunicateCheck.
realCheck := canCommunicateCheck
canCommunicateCheck = stubCanCommunicate
testCanCommunicateCalls = nil
defer func() { canCommunicateCheck = realCheck }()

// No Docker client — we just check the auth rejection, not the connection.
h := &TerminalHandler{docker: nil}

tests := []struct {
name string
callerID string
targetID string
authHeader string
wantStatus int
wantError string
canCommResult bool // if set, override stub result
}{
{
name: "caller reaches own workspace — allowed",
callerID: "ws-1",
targetID: "ws-1",
authHeader: "",
wantStatus: http.StatusServiceUnavailable, // no docker, but auth passes
},
{
name: "different caller, CanCommunicate=true — allowed",
callerID: "ws-parent",
targetID: "ws-child",
authHeader: "",
wantStatus: http.StatusServiceUnavailable, // no docker, but auth passes
},
{
name: "caller explicitly blocked — forbidden",
callerID: "ws-blocked",
targetID: "ws-other",
authHeader: "",
wantStatus: http.StatusForbidden,
wantError: "not authorized to access this workspace's terminal",
},
{
name: "no X-Workspace-ID header — allowed (no identity claim)",
callerID: "",
targetID: "ws-1",
authHeader: "",
wantStatus: http.StatusServiceUnavailable,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/workspaces/"+tt.targetID+"/terminal", nil)
if tt.callerID != "" {
c.Request.Header.Set("X-Workspace-ID", tt.callerID)
}
if tt.authHeader != "" {
c.Request.Header.Set("Authorization", tt.authHeader)
}

// Re-stub to clear between subtests
testCanCommunicateCalls = nil

h.HandleConnect(c)

if w.Code != tt.wantStatus {
t.Errorf("HandleConnect: got %d, want %d; body: %s", w.Code, tt.wantStatus, w.Body.String())
}
if tt.wantError != "" {
body := w.Body.String()
if !strContains(body, tt.wantError) {
t.Errorf("HandleConnect: expected error containing %q, got %q", tt.wantError, body)
}
}
})
}
}

// TestKI005_TerminalAuth_NoHeaderNoCheck documents that when no X-Workspace-ID
// is provided, no hierarchy check is performed. This is intentional — canvas
// browser sessions without a workspace identity still need to reach terminals.
func TestKI005_TerminalAuth_NoHeaderNoCheck(t *testing.T) {
realCheck := canCommunicateCheck
canCommunicateCheck = func(callerID, targetID string) bool {
t.Errorf("canCommunicateCheck called with no X-Workspace-ID header — should not happen")
return false
}
defer func() { canCommunicateCheck = realCheck }()

h := &TerminalHandler{docker: nil}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/terminal", nil)
// No X-Workspace-ID header — no auth check

h.HandleConnect(c)

// Should proceed to Docker lookup (503 since no docker), not auth check
if w.Code == http.StatusUnauthorized || w.Code == http.StatusForbidden {
t.Errorf("HandleConnect without X-Workspace-ID: expected auth to pass, got %d", w.Code)
}
}

func strContains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && strContainsHelper(s, substr))
}

func strContainsHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
Loading
Loading