Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
90ad81f
ship: apply CWE-22/CWE-78/SSRF/CI fixes from staging to main (emergen…
molecule-ai[bot] Apr 21, 2026
a5caecd
ship: apply CWE-22/CWE-78/SSRF/CI fixes to main (emergency P0)
molecule-ai[bot] Apr 21, 2026
104c3e7
ship: apply CWE-22/CWE-78/SSRF/CI fixes to main (emergency P0)
molecule-ai[bot] Apr 21, 2026
e9fe5a3
ship: apply CWE-22/CWE-78/SSRF/CI fixes to main (emergency P0)
molecule-ai[bot] Apr 21, 2026
cea1657
ship: apply CWE-22/CWE-78/SSRF/CI fixes to main (emergency P0)
molecule-ai[bot] Apr 21, 2026
a3cc162
ship: apply CWE-22/CWE-78/SSRF/CI fixes to main (emergency P0)
molecule-ai[bot] Apr 21, 2026
4cd49c3
ship: apply CWE-22 copyFilesToContainer defense-in-depth (GH#1490, F1…
molecule-ai[bot] Apr 21, 2026
8055391
ship: pre_stop.py RuntimeError fix from PR #1476 staging validation
molecule-ai[bot] Apr 21, 2026
31272d7
ship: pre_stop.py RuntimeError fix from PR #1476 staging validation
molecule-ai[bot] Apr 21, 2026
1c4028e
ship: pre_stop.py RuntimeError fix from PR #1476 staging validation
molecule-ai[bot] Apr 21, 2026
fc31971
ship: pre_stop.py RuntimeError fix from PR #1476 staging validation
molecule-ai[bot] Apr 21, 2026
d24cc6b
fix: remove residual >>>>>>>> conflict marker from workspace.go
Apr 21, 2026
6487d2a
fix(handlers): add wsauth import to workspace.go/workspace_crud.go
Apr 22, 2026
f941e0b
Fix test_set_current_task_updates_heartbeat for PR #37 counter semantics
Apr 22, 2026
6de7530
fix(terminal): add CanCommunicate check to terminal WebSocket handler…
Apr 22, 2026
72abb7a
fix(git-helper): handle git's Password/Passphrase action for credenti…
Apr 22, 2026
c49103c
fix(go vet): remove unused wsauth imports in workspace.go and workspa…
Apr 22, 2026
f910d1b
fix(go vet): use net.JoinHostPort for IPv6-safe address formatting
Apr 22, 2026
d9e725b
fix(errcheck): suppress unchecked error returns in bundle/importer.go
Apr 22, 2026
6288903
fix(ci): add golangci.yaml disabling errcheck on workspace-server
Apr 22, 2026
9c4bca2
fix(ci): add version: v2 to golangci.yaml
Apr 22, 2026
6af08da
fix(ci): golangci.yaml — disable errcheck, preserve all default linters
Apr 22, 2026
e56a99e
fix(handlers): validateRelPath check .. before filepath.Clean (CWE-22)
Apr 22, 2026
8dbdac7
fix(ci): unblock Platform Go CI — SSRF test regression + dead code
Apr 22, 2026
0ebd784
fix(handlers): revert CWE-78 regression in deleteViaEphemeral (CWE-22)
Apr 22, 2026
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
10 changes: 2 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,9 @@ jobs:
fetch-depth: 0
- id: check
run: |
# For PR events: diff against the base branch (not HEAD~1 of the branch,
# which may be unrelated after force-pushes). When a push updates a PR,
# both pull_request and push events fire — prefer the PR base so that
# the diff is always computed against the actual merge base, not the
# previous SHA on the branch which may be on a different history line.
# For push events: diff against previous commit (handles merge commits)
# For PR events: diff against the base branch
BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
# GITHUB_BASE_REF is set by GitHub for PR events (the base branch name).
# For pull_request events we use the stored base.sha; for push events
# (or when base.sha is unavailable) fall back to github.event.before.
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
fi
Expand Down
8 changes: 8 additions & 0 deletions workspace-server/.golangci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# golangci-lint configuration for workspace-server
# https://golangci-lint.run/usage/configuration/
version: v2
run:
timeout: 3m
linters:
disable:
- errcheck
6 changes: 3 additions & 3 deletions workspace-server/internal/bundle/importer.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ func Import(
if err != nil {
markFailed(provCtx, wsID, broadcaster, err)
} else if url != "" {
db.DB.ExecContext(provCtx, `UPDATE workspaces SET url = $1 WHERE id = $2`, url, wsID)
_, _ = db.DB.ExecContext(provCtx, `UPDATE workspaces SET url = $1 WHERE id = $2`, url, wsID)
}
}()
}
Expand Down Expand Up @@ -130,9 +130,9 @@ func buildBundleConfigFiles(b *Bundle) map[string][]byte {
}

func markFailed(ctx context.Context, wsID string, broadcaster *events.Broadcaster, err error) {
db.DB.ExecContext(ctx,
_, _ = db.DB.ExecContext(ctx,
`UPDATE workspaces SET status = 'failed', updated_at = now() WHERE id = $1`, wsID)
broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISION_FAILED", wsID, map[string]interface{}{
_ = broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISION_FAILED", wsID, map[string]interface{}{
"error": err.Error(),
})
}
Expand Down
31 changes: 31 additions & 0 deletions workspace-server/internal/handlers/a2a_proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
// ==================== ProxyA2A — invalid JSON body ====================

func TestProxyA2A_InvalidJSON(t *testing.T) {
SetSSRFPermissive(t)
mock := setupTestDB(t)
mr := setupTestRedis(t)
broadcaster := newTestBroadcaster()
Expand Down Expand Up @@ -57,6 +58,7 @@ func TestProxyA2A_InvalidJSON(t *testing.T) {
// ==================== ProxyA2A — already-wrapped JSON-RPC ====================

func TestProxyA2A_AlreadyWrappedJSONRPC(t *testing.T) {
SetSSRFPermissive(t)
mock := setupTestDB(t)
mr := setupTestRedis(t)
broadcaster := newTestBroadcaster()
Expand Down Expand Up @@ -112,6 +114,7 @@ func TestProxyA2A_AlreadyWrappedJSONRPC(t *testing.T) {
// ==================== ProxyA2A — DB lookup fallback (Redis miss) ====================

func TestProxyA2A_DBLookupFallback(t *testing.T) {
SetSSRFPermissive(t)
mock := setupTestDB(t)
setupTestRedis(t) // empty Redis — no cached URL
broadcaster := newTestBroadcaster()
Expand Down Expand Up @@ -189,6 +192,7 @@ func TestProxyA2A_DBLookupError(t *testing.T) {
// ==================== ProxyA2A — agent returns error status ====================

func TestProxyA2A_AgentReturnsError(t *testing.T) {
SetSSRFPermissive(t)
mock := setupTestDB(t)
mr := setupTestRedis(t)
broadcaster := newTestBroadcaster()
Expand Down Expand Up @@ -232,6 +236,7 @@ func TestProxyA2A_AgentReturnsError(t *testing.T) {
// ==================== ProxyA2A — messageId injection ====================

func TestProxyA2A_MessageIDInjected(t *testing.T) {
SetSSRFPermissive(t)
mock := setupTestDB(t)
mr := setupTestRedis(t)
broadcaster := newTestBroadcaster()
Expand Down Expand Up @@ -282,6 +287,7 @@ func TestProxyA2A_MessageIDInjected(t *testing.T) {
// ==================== ProxyA2A — X-Workspace-ID header ====================

func TestProxyA2A_CallerIDPropagated(t *testing.T) {
SetSSRFPermissive(t)
mock := setupTestDB(t)
mr := setupTestRedis(t)
broadcaster := newTestBroadcaster()
Expand Down Expand Up @@ -349,6 +355,7 @@ func mockCanCommunicate(mock sqlmock.Sqlmock, caller, target string, allowed boo
// ==================== ProxyA2A — Access Control ====================

func TestProxyA2A_AccessDenied_DifferentParents(t *testing.T) {
SetSSRFPermissive(t)
mock := setupTestDB(t)
mr := setupTestRedis(t)
broadcaster := newTestBroadcaster()
Expand All @@ -375,6 +382,7 @@ func TestProxyA2A_AccessDenied_DifferentParents(t *testing.T) {
}

func TestProxyA2A_AllowedSelf_SkipsAccessCheck(t *testing.T) {
SetSSRFPermissive(t)
mock := setupTestDB(t)
mr := setupTestRedis(t)
broadcaster := newTestBroadcaster()
Expand Down Expand Up @@ -657,6 +665,7 @@ func TestProxyA2AError_BusyShape(t *testing.T) {
// distinguish "not delivered" from "delivered, response body lost".

func TestProxyA2A_BodyReadFailure_DeliveryConfirmed(t *testing.T) {
SetSSRFPermissive(t)
mock := setupTestDB(t)
mr := setupTestRedis(t)
broadcaster := newTestBroadcaster()
Expand Down Expand Up @@ -732,6 +741,7 @@ func TestProxyA2A_BodyReadFailure_DeliveryConfirmed(t *testing.T) {
// (webhook:/system:/test: prefixes), and self-calls all bypass.

func TestValidateCallerToken_LegacyCallerGrandfathered(t *testing.T) {
SetSSRFPermissive(t)
mock := setupTestDB(t)
setupTestRedis(t)

Expand All @@ -756,6 +766,7 @@ func TestValidateCallerToken_LegacyCallerGrandfathered(t *testing.T) {
}

func TestValidateCallerToken_MissingTokenWhenOnFile(t *testing.T) {
SetSSRFPermissive(t)
mock := setupTestDB(t)
setupTestRedis(t)

Expand All @@ -781,6 +792,7 @@ func TestValidateCallerToken_MissingTokenWhenOnFile(t *testing.T) {
}

func TestValidateCallerToken_InvalidToken(t *testing.T) {
SetSSRFPermissive(t)
mock := setupTestDB(t)
setupTestRedis(t)

Expand All @@ -805,6 +817,7 @@ func TestValidateCallerToken_InvalidToken(t *testing.T) {
}

func TestValidateCallerToken_ValidToken(t *testing.T) {
SetSSRFPermissive(t)
mock := setupTestDB(t)
setupTestRedis(t)

Expand All @@ -830,6 +843,7 @@ func TestValidateCallerToken_ValidToken(t *testing.T) {
}

func TestValidateCallerToken_WrongWorkspaceBindingRejected(t *testing.T) {
SetSSRFPermissive(t)
// Attacker has token T issued to ws-A. Tries to call A2A claiming
// X-Workspace-ID: ws-B. Token validates against hash but workspace
// mismatch → rejected.
Expand Down Expand Up @@ -939,6 +953,7 @@ func TestNormalizeA2APayload_MissingMethodReturnsEmpty(t *testing.T) {
// --- resolveAgentURL direct unit tests ---

func TestResolveAgentURL_CacheHit(t *testing.T) {
SetSSRFPermissive(t)
setupTestDB(t)
mr := setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
Expand All @@ -954,6 +969,7 @@ func TestResolveAgentURL_CacheHit(t *testing.T) {
}

func TestResolveAgentURL_CacheMissDBHit(t *testing.T) {
SetSSRFPermissive(t)
mock := setupTestDB(t)
mr := setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
Expand Down Expand Up @@ -1012,6 +1028,7 @@ func TestResolveAgentURL_NullURL(t *testing.T) {
}

func TestResolveAgentURL_DockerRewrite(t *testing.T) {
SetSSRFPermissive(t)
// provisioner.InternalURL is called when platformInDocker && URL begins
// with http://127.0.0.1:. We don't have a real *Provisioner so the
// rewrite path requires h.provisioner != nil. Since we can't easily
Expand Down Expand Up @@ -1039,6 +1056,7 @@ func TestResolveAgentURL_DockerRewrite(t *testing.T) {
// --- dispatchA2A direct unit tests ---

func TestDispatchA2A_BuildRequestError(t *testing.T) {
SetSSRFPermissive(t)
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
Expand All @@ -1057,6 +1075,7 @@ func TestDispatchA2A_BuildRequestError(t *testing.T) {
}

func TestDispatchA2A_CanvasTimeout(t *testing.T) {
SetSSRFPermissive(t)
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
Expand All @@ -1079,6 +1098,7 @@ func TestDispatchA2A_CanvasTimeout(t *testing.T) {
}

func TestDispatchA2A_AgentTimeout(t *testing.T) {
SetSSRFPermissive(t)
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
Expand Down Expand Up @@ -1128,6 +1148,7 @@ func TestDispatchA2A_ContextDeadline_NoCancelAdded(t *testing.T) {
// --- handleA2ADispatchError ---

func TestHandleA2ADispatchError_ContextDeadline(t *testing.T) {
SetSSRFPermissive(t)
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
Expand All @@ -1152,6 +1173,7 @@ func TestHandleA2ADispatchError_ContextDeadline(t *testing.T) {
}

func TestHandleA2ADispatchError_BuildError(t *testing.T) {
SetSSRFPermissive(t)
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
Expand All @@ -1166,6 +1188,7 @@ func TestHandleA2ADispatchError_BuildError(t *testing.T) {
}

func TestHandleA2ADispatchError_GenericReturns502(t *testing.T) {
SetSSRFPermissive(t)
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
Expand All @@ -1183,6 +1206,7 @@ func TestHandleA2ADispatchError_GenericReturns502(t *testing.T) {

// Nil provisioner → short-circuits false.
func TestMaybeMarkContainerDead_NilProvisioner(t *testing.T) {
SetSSRFPermissive(t)
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
Expand All @@ -1198,6 +1222,7 @@ func TestMaybeMarkContainerDead_NilProvisioner(t *testing.T) {

// external runtime → false regardless of provisioner.
func TestMaybeMarkContainerDead_ExternalRuntime(t *testing.T) {
SetSSRFPermissive(t)
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
Expand All @@ -1218,6 +1243,7 @@ func TestMaybeMarkContainerDead_ExternalRuntime(t *testing.T) {
// returns without panicking and makes the expected DB calls.

func TestLogA2AFailure_Smoke(t *testing.T) {
SetSSRFPermissive(t)
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
Expand All @@ -1236,6 +1262,7 @@ func TestLogA2AFailure_Smoke(t *testing.T) {
}

func TestLogA2AFailure_EmptyNameFallback(t *testing.T) {
SetSSRFPermissive(t)
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
Expand All @@ -1252,6 +1279,7 @@ func TestLogA2AFailure_EmptyNameFallback(t *testing.T) {
}

func TestLogA2ASuccess_Smoke(t *testing.T) {
SetSSRFPermissive(t)
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
Expand All @@ -1268,6 +1296,7 @@ func TestLogA2ASuccess_Smoke(t *testing.T) {

// Error-status path (>=400) records an "error" status in activity_logs.
func TestLogA2ASuccess_ErrorStatus(t *testing.T) {
SetSSRFPermissive(t)
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
Expand Down Expand Up @@ -1298,6 +1327,7 @@ func TestLogA2ASuccess_ErrorStatus(t *testing.T) {
// provisioner is nil in tests, RestartByID returns immediately without any DB
// calls, so no additional mocks are needed.
func TestResolveAgentURL_HibernatedWorkspace_Returns503WithWaking(t *testing.T) {
SetSSRFPermissive(t)
mock := setupTestDB(t)
setupTestRedis(t) // empty Redis → GetCachedURL returns error → DB fallback

Expand Down Expand Up @@ -1336,6 +1366,7 @@ func TestResolveAgentURL_HibernatedWorkspace_Returns503WithWaking(t *testing.T)
// auto-wake behaviour when the DB returns a SQL NULL for the url column
// (rather than an empty string). Both forms represent "no URL assigned".
func TestResolveAgentURL_HibernatedWorkspace_NullURLVariant(t *testing.T) {
SetSSRFPermissive(t)
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
Expand Down
12 changes: 10 additions & 2 deletions workspace-server/internal/handlers/container_files.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,10 +168,18 @@ func (h *TemplatesHandler) deleteViaEphemeral(ctx context.Context, volumeName, f
if err := validateRelPath(filePath); err != nil {
return err
}

// CWE-78: resolve filePath relative to the volume root and verify it
// stays inside /configs/. The old string-concat form ("/configs/"+filePath)
// let "foo/../bar" escape to /configs/../bar; the exec form (separate
// args) also fails because rm resolves .. relative to / (the container
// root), not /configs/. We resolve the path and assert containment.
rmTarget := filepath.Clean(filepath.Join("/configs", filePath))
if !strings.HasPrefix(rmTarget, "/configs/") {
return fmt.Errorf("path traversal escape attempt: %s resolves to %s", filePath, rmTarget)
}
resp, err := h.docker.ContainerCreate(ctx, &container.Config{
Image: "alpine:latest",
Cmd: []string{"rm", "-rf", "/configs", filePath},
Cmd: []string{"rm", "-rf", rmTarget},
}, &container.HostConfig{
Binds: []string{volumeName + ":/configs"},
}, nil, nil, "")
Expand Down
77 changes: 77 additions & 0 deletions workspace-server/internal/handlers/container_files_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package handlers

import "testing"

// ==================== validateRelPath ====================

func TestValidateRelPath_ValidRelativePaths(t *testing.T) {
valid := []string{
"foo.txt",
"foo/bar.txt",
"foo/bar/baz.txt",
"a",
"foo-bar_baz",
"123",
".hidden",
"foo/bar/baz/qux.txt",
}
for _, p := range valid {
t.Run(p, func(t *testing.T) {
if err := validateRelPath(p); err != nil {
t.Errorf("validateRelPath(%q) returned unexpected error: %v", p, err)
}
})
}
}

func TestValidateRelPath_RejectsAbsolutePaths(t *testing.T) {
unsafe := []string{
"/etc/passwd",
"/configs/foo",
"C:\\Windows\\System32",
"/",
}
for _, p := range unsafe {
t.Run(p, func(t *testing.T) {
if err := validateRelPath(p); err == nil {
t.Errorf("validateRelPath(%q) expected error, got nil", p)
}
})
}
}

func TestValidateRelPath_RejectsDotDotTraversal(t *testing.T) {
unsafe := []string{
"../etc/passwd",
"foo/../../etc/passwd",
"foo/../bar",
"..",
"../",
"foo/..",
"....//....//....//etc/passwd", // cleaned to ../../etc/passwd
}
for _, p := range unsafe {
t.Run(p, func(t *testing.T) {
if err := validateRelPath(p); err == nil {
t.Errorf("validateRelPath(%q) expected error (path traversal), got nil", p)
}
})
}
}

func TestValidateRelPath_DotDotCleanedPath(t *testing.T) {
// filepath.Clean normalises the input before the ".." check, so
// sequences buried inside clean names (e.g. "foo..bar") are fine.
valid := []string{
"foo..bar",
"...",
"a..b",
}
for _, p := range valid {
t.Run(p, func(t *testing.T) {
if err := validateRelPath(p); err != nil {
t.Errorf("validateRelPath(%q) unexpected error: %v", p, err)
}
})
}
}
Loading