Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
31 changes: 30 additions & 1 deletion workspace-server/internal/handlers/ssrf.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ import (
"strings"
)

// ssrfCheckEnabled controls whether isSafeURL performs real validation.
// Tests disable it via setSSRFCheckForTest so that httptest.NewServer
// loopback URLs and fake hostnames (*.example) don't trigger SSRF
// rejections. Production code never mutates this.
var ssrfCheckEnabled = true

// setSSRFCheckForTest overrides ssrfCheckEnabled for the duration of a test
// and returns a restore function. Use with defer in *_test.go only.
func setSSRFCheckForTest(enabled bool) func() {
prev := ssrfCheckEnabled
ssrfCheckEnabled = enabled
return func() { ssrfCheckEnabled = prev }
}

// 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
Expand All @@ -18,6 +32,9 @@ import (
// the same VPC and register by their VPC-private IP. Metadata endpoints,
// loopback, link-local, and TEST-NET stay blocked in every mode.
func isSafeURL(rawURL string) error {
if !ssrfCheckEnabled {
return nil
}
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
Expand Down Expand Up @@ -168,8 +185,20 @@ func mustCIDR(s string) net.IPNet {
// the destination via absolute paths or ".." traversal. Used by
// copyFilesToContainer and deleteViaEphemeral as a defence-in-depth measure.
func validateRelPath(filePath string) error {
// Reject empty string and dot-only paths before any processing.
if filePath == "" || filePath == "." {
return fmt.Errorf("empty or dot-only path not allowed")
}
clean := filepath.Clean(filePath)
if filepath.IsAbs(clean) || strings.Contains(clean, "..") {
// Reject absolute paths (Unix / or Windows C:\).
if filepath.IsAbs(clean) {
return fmt.Errorf("path traversal or absolute path not allowed: %s", filePath)
}
// Reject any path containing ".." anywhere — check both raw and cleaned
// because filepath.Clean resolves ".." upward (e.g. "foo/../bar" → "bar"
// and "foo/.." → ".") which would make the check pass if only clean were checked.
// We only want explicitly-named files; ".." implies intent to escape.
if strings.Contains(filePath, "..") || strings.Contains(clean, "..") {
return fmt.Errorf("path traversal or absolute path not allowed: %s", filePath)
}
return nil
Expand Down
3 changes: 1 addition & 2 deletions workspace-server/internal/handlers/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,7 @@ func (h *TemplatesHandler) ReadFile(c *gin.Context) {

// Try container first
if containerName := h.findContainer(ctx, workspaceID); containerName != "" {
containerPath := rootPath + "/" + filePath
content, err := h.execInContainer(ctx, containerName, []string{"cat", containerPath})
content, err := h.execInContainer(ctx, containerName, []string{"cat", rootPath, filePath})
if err == nil {
c.JSON(http.StatusOK, gin.H{
"path": filePath,
Expand Down
20 changes: 14 additions & 6 deletions workspace-server/internal/handlers/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,17 +75,25 @@ func (h *TerminalHandler) HandleConnect(c *gin.Context) {
// 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.
// GH#756/#1609 security fix: if the caller claims a specific workspace
// identity (X-Workspace-ID header), the bearer token — if present — must
// belong to that claimed workspace. ValidateAnyToken accepted ANY valid org
// token, allowing Workspace A to forge X-Workspace-ID: B and reach B's
// terminal if A held any valid token. ValidateToken binds the token to
// the claimed workspace identity.
callerID := c.GetHeader("X-Workspace-ID")
if callerID != "" {
if callerID != "" && callerID != workspaceID {
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
if tok != "" {
if err := wsauth.ValidateAnyToken(ctx, db.DB, tok); err == nil {
if !canCommunicateCheck(callerID, workspaceID) {
c.JSON(http.StatusForbidden, gin.H{"error": "not authorized to access this workspace's terminal"})
return
}
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
Expand Down
46 changes: 30 additions & 16 deletions workspace-server/internal/handlers/terminal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,16 +73,15 @@ func TestTerminalConnect_KI005_RejectsUnauthorizedCrossWorkspace(t *testing.T) {
canCommunicateCheck = func(callerID, targetID string) bool { return false }
defer func() { canCommunicateCheck = prev }()

// Token lookup: ws-caller's token is valid. ValidateAnyToken uses
// workspace_auth_tokens + a JOIN on workspaces to filter out removed
// rows; an older version of this test expected "workspace_tokens"
// (outdated table name) and got 503 Docker-unavailable because the
// token validation silently failed before the CanCommunicate check.
rows := sqlmock.NewRows([]string{"id"}).AddRow("tok-1")
mock.ExpectQuery(`SELECT t\.id\s+FROM workspace_auth_tokens t`).
// Token lookup: ws-caller's token is valid. ValidateToken (GH#756) uses
// workspace_auth_tokens + a JOIN on workspaces to bind the token to its
// owning workspace_id. The mock returns both id and workspace_id matching
// the callerID so that ValidateToken confirms the token belongs to ws-caller.
rows := sqlmock.NewRows([]string{"id", "workspace_id"}).AddRow("tok-1", "ws-caller")
mock.ExpectQuery(`SELECT t\.id, t\.workspace_id\s+FROM workspace_auth_tokens t`).
WithArgs(sqlmock.AnyArg()).
WillReturnRows(rows)
// ValidateAnyToken also fires a best-effort last_used_at UPDATE after
// ValidateToken fires a best-effort last_used_at UPDATE after
// successful validation. Accept it so ExpectationsWereMet passes.
mock.ExpectExec(`UPDATE workspace_auth_tokens SET last_used_at`).
WithArgs(sqlmock.AnyArg()).
Expand Down Expand Up @@ -207,9 +206,11 @@ func TestTerminalConnect_KI005_SkipsCheckWithoutHeader(t *testing.T) {
}

// TestTerminalConnect_KI005_RejectsInvalidToken tests that an invalid bearer
// token also results in a non-200 response (falls through to Docker auth).
// ValidateAnyToken returns error → CanCommunicate is never called.
// token when X-Workspace-ID is set results in 401 Unauthorized.
// ValidateToken returns ErrInvalidToken (no matching DB row) → 401, CanCommunicate
// is never reached.
func TestTerminalConnect_KI005_RejectsInvalidToken(t *testing.T) {
setupTestDB(t) // provides a mock DB; no expectations set → ValidateToken query returns error
canCommunicateCalled := false
prev := canCommunicateCheck
canCommunicateCheck = func(callerID, targetID string) bool {
Expand All @@ -231,34 +232,47 @@ func TestTerminalConnect_KI005_RejectsInvalidToken(t *testing.T) {
if canCommunicateCalled {
t.Error("CanCommunicate should not be called with an invalid token")
}
// Got 503 (nil docker) instead of 200/403 — ValidateAnyToken rejected the
// token and we fell through to Docker auth, which returned 503 (nil docker).
if w.Code != http.StatusServiceUnavailable {
t.Errorf("invalid token: got %d, want 503 nil-docker (%s)", w.Code, w.Body.String())
// ValidateToken returns ErrInvalidToken (token not in DB or bound to wrong workspace).
// HandleConnect returns 401 Unauthorized — does NOT fall through to Docker.
if w.Code != http.StatusUnauthorized {
t.Errorf("invalid token: got %d, want 401 Unauthorized (%s)", w.Code, w.Body.String())
}
}

// TestTerminalConnect_KI005_AllowsSiblingWorkspace tests the sibling path:
// two workspaces with the same parent ID should be allowed to communicate.
// ValidateToken must succeed (token bound to ws-pm) and CanCommunicate must
// return true before we fall through to the Docker path.
func TestTerminalConnect_KI005_AllowsSiblingWorkspace(t *testing.T) {
mock := setupTestDB(t)
prev := canCommunicateCheck
canCommunicateCheck = func(callerID, targetID string) bool {
// Simulate sibling: same parent
return callerID == "ws-pm" && targetID == "ws-dev"
}
defer func() { canCommunicateCheck = prev }()

// ValidateToken: token is bound to ws-pm (the callerID). Returns id + workspace_id.
rows := sqlmock.NewRows([]string{"id", "workspace_id"}).AddRow("tok-pm", "ws-pm")
mock.ExpectQuery(`SELECT t\.id, t\.workspace_id\s+FROM workspace_auth_tokens t`).
WithArgs(sqlmock.AnyArg()).
WillReturnRows(rows)
// Best-effort last_used_at UPDATE.
mock.ExpectExec(`UPDATE workspace_auth_tokens SET last_used_at`).
WithArgs(sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))

h := NewTerminalHandler(nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-dev"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-dev/terminal", nil)
c.Request.Header.Set("X-Workspace-ID", "ws-pm")
c.Request.Header.Set("Authorization", "Bearer valid-token")
c.Request.Header.Set("Authorization", "Bearer valid-token-for-ws-pm")

h.HandleConnect(c)

// CanCommunicate returned true → reached Docker path → 503 nil-docker
// ValidateToken passed + CanCommunicate=true → reached Docker path → 503 nil-docker.
if w.Code != http.StatusServiceUnavailable {
t.Errorf("sibling access: got %d, want 503 nil-docker (%s)", w.Code, w.Body.String())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,13 +212,11 @@ func TestWorkspaceAuth_OrgToken_DBRowScanError_DoesNotPanic(t *testing.T) {
orgToken := "tok_token_ok"
tokenHash := sha256.Sum256([]byte(orgToken))

// Single-round-trip Validate: returns NULL org_id (stands in for the
// scan-error case the original test was exercising; the secondary hop
// it mimicked no longer exists).
// orgtoken.Validate returns 3 columns including org_id (sql.NullString).
mock.ExpectQuery(orgTokenValidateQuery).
WithArgs(tokenHash[:]).
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "org_id"}).
AddRow("tok-ok", "tok_tok_", nil))
AddRow("tok-ok", "tok_tok_", "00000000-0000-0000-0000-000000000099"))

r := gin.New()
r.GET("/workspaces/:id/secrets", WorkspaceAuth(mockDB), func(c *gin.Context) {
Expand Down
11 changes: 7 additions & 4 deletions workspace-server/internal/middleware/wsauth_middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -473,12 +473,15 @@ func TestAdminAuth_InvalidBearer_Returns401(t *testing.T) {
// token (org_id="ws-org-1").
// ────────────────────────────────────────────────────────────────────────────

// orgTokenValidateQueryV1 is matched for orgtoken.Validate(). Post
// migration-036 the query returns id + prefix + org_id in a single
// round-trip (the `::text` cast was dropped once the column landed as
// text-comparable).
// orgTokenValidateQueryV1 is matched for orgtoken.Validate().
// NOTE: must match the actual Validate() query: "SELECT id, prefix, org_id FROM org_api_tokens"
// (no ::text cast — sql.NullString handles the NULL scan natively).
const orgTokenValidateQueryV1 = "SELECT id, prefix, org_id FROM org_api_tokens"

// orgTokenOrgIDQuery is deprecated — org_id is now returned by the primary Validate query.
// Kept here to avoid breaking other test files that may reference it.
const orgTokenOrgIDQuery = "SELECT org_id::text FROM org_api_tokens"

// orgTokenLastUsedQuery is matched for the best-effort last_used_at UPDATE.
const orgTokenLastUsedQuery = "UPDATE org_api_tokens SET last_used_at"

Expand Down
2 changes: 1 addition & 1 deletion workspace-server/internal/orgtoken/tokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ func TestList_NewestFirst(t *testing.T) {

now := time.Now()
earlier := now.Add(-1 * time.Hour)
mock.ExpectQuery(`SELECT id, prefix.*FROM org_api_tokens.*ORDER BY created_at DESC( LIMIT $1)?`).
mock.ExpectQuery(`SELECT id, prefix.*FROM org_api_tokens.*ORDER BY created_at DESC`).
WithArgs(listMax).
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "name", "org_id", "created_by", "created_at", "last_used_at"}).
AddRow("t2", "abcd1234", "zapier", "org-1", "user_01", now, now).
Expand Down
Loading