From def6a8f40225ffcd5fa07bcba1099df787ff2117 Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 23 Mar 2026 09:09:51 +0000 Subject: [PATCH] Fix workspace symlink escape validation --- workspace/service.go | 36 ++++++++++++++++++++++++++++++++++++ workspace/service_test.go | 13 +++++++++++++ 2 files changed, 49 insertions(+) diff --git a/workspace/service.go b/workspace/service.go index 9e81764..faa443e 100644 --- a/workspace/service.go +++ b/workspace/service.go @@ -3,7 +3,9 @@ package workspace import ( "crypto/sha256" "encoding/hex" + "errors" "os" + "path/filepath" "strings" "sync" @@ -204,6 +206,34 @@ func joinWithinRoot(root string, parts ...string) (string, error) { return "", os.ErrPermission } +func resolveWorkspacePath(rootPath, workspacePath string) error { + resolvedRoot, err := filepath.EvalSymlinks(rootPath) + if err != nil { + return err + } + + resolvedPath, err := filepath.EvalSymlinks(workspacePath) + if err != nil { + if !os.IsNotExist(err) { + return err + } + // The workspace may not exist yet during creation. Resolve the root and + // re-anchor the final entry under it so containment checks still compare + // canonical paths. + resolvedPath = filepath.Join(resolvedRoot, filepath.Base(workspacePath)) + } + + rel, err := filepath.Rel(resolvedRoot, resolvedPath) + if err != nil { + return err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return os.ErrPermission + } + + return nil +} + func (s *Service) workspacePath(op, name string) (string, error) { if name == "" { return "", coreerr.E(op, "workspace name is required", os.ErrInvalid) @@ -215,6 +245,12 @@ func (s *Service) workspacePath(op, name string) (string, error) { if core.PathDir(path) != s.rootPath { return "", coreerr.E(op, "invalid workspace name: "+name, os.ErrPermission) } + if err := resolveWorkspacePath(s.rootPath, path); err != nil { + if errors.Is(err, os.ErrPermission) { + return "", coreerr.E(op, "workspace path escapes root", err) + } + return "", coreerr.E(op, "failed to resolve workspace path", err) + } return path, nil } diff --git a/workspace/service_test.go b/workspace/service_test.go index 1fc7abe..f7148d5 100644 --- a/workspace/service_test.go +++ b/workspace/service_test.go @@ -67,6 +67,19 @@ func TestSwitchWorkspace_TraversalBlocked(t *testing.T) { assert.Empty(t, s.activeWorkspace) } +func TestSwitchWorkspace_SymlinkEscapeBlocked(t *testing.T) { + s, tempHome := newTestService(t) + + outside := t.TempDir() + linkPath := core.Path(tempHome, ".core", "workspaces", "escaped-link") + require.NoError(t, os.Symlink(outside, linkPath)) + + err := s.SwitchWorkspace("escaped-link") + require.Error(t, err) + assert.ErrorIs(t, err, os.ErrPermission) + assert.Empty(t, s.activeWorkspace) +} + func TestWorkspaceFileSet_TraversalBlocked(t *testing.T) { s, tempHome := newTestService(t)