Skip to content
Merged
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
6 changes: 3 additions & 3 deletions workspace-server/internal/artifacts/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ func TestForkRepo_Success(t *testing.T) {
return
}
var req map[string]interface{}
json.NewDecoder(r.Body).Decode(&req)
_ = json.NewDecoder(r.Body).Decode(&req)
if req["name"] != "forked-repo" {
http.Error(w, "unexpected fork name", http.StatusBadRequest)
return
Expand Down Expand Up @@ -234,7 +234,7 @@ func TestImportRepo_Success(t *testing.T) {
return
}
var req map[string]interface{}
json.NewDecoder(r.Body).Decode(&req)
_ = json.NewDecoder(r.Body).Decode(&req)
if req["url"] == "" {
http.Error(w, "url required", http.StatusBadRequest)
return
Expand Down Expand Up @@ -294,7 +294,7 @@ func TestCreateToken_Success(t *testing.T) {
return
}
var req map[string]interface{}
json.NewDecoder(r.Body).Decode(&req)
_ = json.NewDecoder(r.Body).Decode(&req)
if req["repo"] != "my-repo" {
http.Error(w, "unexpected repo", http.StatusBadRequest)
return
Expand Down
4 changes: 2 additions & 2 deletions workspace-server/internal/channels/channels_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,7 @@ func TestDisableChannelByChatID_WiredSetsEnabledFalse(t *testing.T) {
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
t.Cleanup(func() { mockDB.Close() })
t.Cleanup(func() { _ = mockDB.Close() })
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB })
Expand Down Expand Up @@ -757,7 +757,7 @@ func TestDisableChannelByChatID_NoRowsAffectedSkipsReload(t *testing.T) {
// bot), the UPDATE returns RowsAffected=0 and we skip the reload. Verifies
// we don't emit a spurious log or SELECT storm on unrelated kicked events.
mockDB, mock, _ := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
t.Cleanup(func() { mockDB.Close() })
t.Cleanup(func() { _ = mockDB.Close() })
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB })
Expand Down
4 changes: 2 additions & 2 deletions workspace-server/internal/channels/lark_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func TestLarkAdapter_SendMessage_HappyPath(t *testing.T) {
gotBody = string(b)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
w.Write([]byte(`{"code":0,"msg":"ok"}`))
_, _ = w.Write([]byte(`{"code":0,"msg":"ok"}`))
}))
defer srv.Close()

Expand All @@ -115,7 +115,7 @@ func TestLarkAdapter_SendMessage_HappyPath(t *testing.T) {
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
_ = resp.Body.Close()

if gotPath != "/open-apis/bot/v2/hook/test" {
t.Errorf("path: got %q", gotPath)
Expand Down
8 changes: 4 additions & 4 deletions workspace-server/internal/channels/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ func (m *Manager) PausePollersForToken(workspaceID, botToken string) func() {
if err != nil {
return func() {}
}
defer rows.Close()
defer func() { _ = rows.Close() }()

var pausedIDs []string
m.mu.Lock()
Expand Down Expand Up @@ -193,7 +193,7 @@ func (m *Manager) Reload(ctx context.Context) {
log.Printf("Channels: reload query error: %v", err)
return
}
defer rows.Close()
defer func() { _ = rows.Close() }()

desired := make(map[string]ChannelRow)
for rows.Next() {
Expand All @@ -203,8 +203,8 @@ func (m *Manager) Reload(ctx context.Context) {
log.Printf("Channels: reload scan error: %v", err)
continue
}
json.Unmarshal(configJSON, &ch.Config)
json.Unmarshal(allowedJSON, &ch.AllowedUsers)
_ = json.Unmarshal(configJSON, &ch.Config)
_ = json.Unmarshal(allowedJSON, &ch.AllowedUsers)
// #319: decrypt at the boundary between DB (ciphertext) and the
// in-memory config adapters consume. A decrypt failure logs and
// skips the channel — downstream getUpdates would fail anyway
Expand Down
152 changes: 152 additions & 0 deletions workspace-server/internal/handlers/container_files_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package handlers

// container_files_test.go — CWE-22 regression suite for copyFilesToContainer.
//
// Vulnerability: copyFilesToContainer validated the raw filename before
// filepath.Join(destPath, name) but placed the post-join result in the tar
// header. A mid-path traversal such as "foo/../../../etc" passes the prefix
// check (does not start with "..") yet resolves to /etc after the join,
// escaping the volume mount and writing outside the container's filesystem.
//
// Fix (PR #1434): re-validate archiveName after filepath.Join using
// filepath.Clean, then use the cleaned result in the tar header.
// A Docker client is not required for these tests — the validation rejects
// unsafe paths before any Docker call is made.

import (
"context"
"errors"
"testing"
)

func TestCopyFilesToContainer_CWE22_RejectsTraversal(t *testing.T) {
// TemplatesHandler with nil docker — validation runs before any Docker call.
h := &TemplatesHandler{docker: nil}

ctx := context.Background()

tests := []struct {
label string
destPath string
files map[string]string
wantErr bool
errSubstr string // substring that must appear in error message
}{
// ── Legitimate paths ───────────────────────────────────────────────────
{
label: "simple_relative_path_ok",
destPath: "/configs",
files: map[string]string{"config.yaml": "key: value"},
wantErr: false,
},
{
label: "nested_relative_path_ok",
destPath: "/configs",
files: map[string]string{"subdir/script.sh": "#!/bin/sh"},
wantErr: false,
},
{
label: "dot_in_filename_ok",
destPath: "/configs",
files: map[string]string{"app.venv/config": "data"},
wantErr: false,
},
// ── CWE-22: absolute-path prefix ────────────────────────────────────────
{
label: "absolute_path_rejected",
destPath: "/configs",
files: map[string]string{"/etc/passwd": "malicious"},
wantErr: true,
errSubstr: "unsafe file path",
},
// ── CWE-22: leading ".." prefix ─────────────────────────────────────────
{
label: "leading_dotdot_rejected",
destPath: "/configs",
files: map[string]string{"../etc/passwd": "malicious"},
wantErr: true,
errSubstr: "unsafe file path",
},
// ── CWE-22: mid-path traversal (the regression case) ────────────────────
// "foo/../../../etc" does NOT start with ".." — passed the old check.
// After filepath.Join("/configs", "foo/../../../etc") → Clean → /etc
// (absolute), escaping the volume mount. Rejected by the post-join guard.
{
label: "mid_path_traversal_rejected",
destPath: "/configs",
files: map[string]string{"foo/../../../etc/cron.d/malicious": "* * * * * root echo pwned"},
wantErr: true,
errSubstr: "path escapes destination",
},
{
label: "mid_path_traversal_escapes_configs",
destPath: "/configs",
files: map[string]string{"x/y/../../../../../../../etc/shadow": "malicious"},
wantErr: true,
errSubstr: "path escapes destination",
},
{
label: "double_dotdot_in_subpath_rejected",
destPath: "/workspace",
files: map[string]string{"a/../../../workspace/somefile": "data"},
wantErr: true,
errSubstr: "path escapes destination",
},
// ── CWE-22: traversal targeting parent of destPath ───────────────────────
{
label: "escapes_destpath_via_traversal",
destPath: "/configs",
files: map[string]string{"..%2F..%2F..%2Fsecrets": "data"}, // URL-encoded "../" — still a traversal
wantErr: true,
errSubstr: "path escapes destination",
},
// ── Mixed: valid entry + traversal entry ────────────────────────────────
{
label: "one_traversal_in_map_rejected",
destPath: "/configs",
files: map[string]string{"good.txt": "valid", "foo/../../../evil": "bad"},
wantErr: true,
errSubstr: "path escapes destination",
},
}

for _, tc := range tests {
t.Run(tc.label, func(t *testing.T) {
err := h.copyFilesToContainer(ctx, "any-container", tc.destPath, tc.files)
if tc.wantErr {
if err == nil {
t.Errorf("want non-nil error, got nil")
return
}
if tc.errSubstr != "" && !errors.Is(err, context.DeadlineExceeded) &&
!contains(err.Error(), tc.errSubstr) {
t.Errorf("error %q does not contain %q", err.Error(), tc.errSubstr)
}
} else {
// wantErr == false: we expect nil from a nil-docker call.
// With nil docker the function will panic or return a docker-err
// only if the path check is bypassed. We use a strict check:
// any error other than a docker-initialized error means the path
// was incorrectly allowed.
if err != nil && contains(err.Error(), "unsafe") {
t.Errorf("want nil (path accepted), got error: %v", err)
}
}
})
}
}

// contains is a simple substring check (no external imports needed in this file).
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && searchSubstring(s, substr)))
}

func searchSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
35 changes: 34 additions & 1 deletion workspace-server/internal/handlers/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ import (

"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
"github.com/creack/pty"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/registry"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/creack/pty"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
Expand Down Expand Up @@ -78,12 +80,43 @@ func (h *TerminalHandler) HandleConnect(c *gin.Context) {
// handleLocalConnect attaches to a Docker container running on this
// tenant's Docker daemon. Original behavior preserved exactly.
func (h *TerminalHandler) handleLocalConnect(c *gin.Context, workspaceID string) {
// 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

// HandleConnect handles WS /workspaces/:id/terminal
func (h *TerminalHandler) HandleConnect(c *gin.Context) {
targetID := c.Param("id")
ctx := c.Request.Context()

// KI-005 fix: enforce CanCommunicate hierarchy check before granting
// terminal access. WorkspaceAuth validates the bearer's token, but the
// token is scoped to a specific workspace ID — Workspace A's token can
// reach Workspace A's terminal. Without CanCommunicate, Workspace A could
// also reach Workspace B's terminal if it knows B's UUID (enumeration
// via canvas, logs, or 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 != "" {
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
if tok != "" {
if err := wsauth.ValidateAnyToken(ctx, db.DB, tok); err == nil {
if !canCommunicateCheck(callerID, targetID) {
c.JSON(http.StatusForbidden, gin.H{"error": "not authorized to access this workspace's terminal"})
return
}
}
}
}

if h.docker == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Docker not available"})
return
}

ctx := c.Request.Context()
workspaceID := targetID

// Try multiple container name patterns:
// 1. Provisioner naming: ws-{id[:12]}
Expand Down
Loading
Loading