diff --git a/cmd/internal/agentcontainer/daemon.go b/cmd/internal/agentcontainer/daemon.go index cdf0d91ed..bfd74e1e9 100644 --- a/cmd/internal/agentcontainer/daemon.go +++ b/cmd/internal/agentcontainer/daemon.go @@ -20,6 +20,7 @@ import ( "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/platform/client" + "github.com/devsy-org/devsy/pkg/sharedfile" "github.com/devsy-org/devsy/pkg/ts" "github.com/spf13/cobra" "golang.org/x/sync/errgroup" @@ -98,15 +99,8 @@ func (cmd *DaemonCmd) setupTimeout() (time.Duration, error) { return 0, fmt.Errorf("failed to parse timeout duration: %w", err) } if timeoutDuration > 0 { - if err := os.WriteFile( // #nosec G306 - config2.ContainerActivityFile, - nil, - 0o666, - ); err != nil { - return 0, fmt.Errorf("failed to create activity file: %w", err) - } - if err := os.Chmod(config2.ContainerActivityFile, 0o666); err != nil { // #nosec G302 - return 0, fmt.Errorf("failed to set activity file permissions: %w", err) + if err := sharedfile.EnsureMode(config2.ContainerActivityFile, 0o666); err != nil { + return 0, fmt.Errorf("failed to ensure activity file: %w", err) } } diff --git a/cmd/internal/agentworkspace/setup_gpg.go b/cmd/internal/agentworkspace/setup_gpg.go index c0c9d10ba..4083dd05d 100644 --- a/cmd/internal/agentworkspace/setup_gpg.go +++ b/cmd/internal/agentworkspace/setup_gpg.go @@ -13,6 +13,7 @@ import ( "github.com/devsy-org/devsy/pkg/gitcredentials" "github.com/devsy-org/devsy/pkg/gpg" "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/sharedfile" "github.com/gofrs/flock" "github.com/spf13/cobra" ) @@ -97,13 +98,26 @@ func (cmd *SetupGPGCmd) Run(ctx context.Context) error { return nil } +// gpgSetupLockMode is 0666 — flock's default 0600 would lock out whichever +// of root/remoteUser did not create the file. +const gpgSetupLockMode = 0o666 + // acquireGPGSetupLock takes the cross-process lock guarding setup-gpg. On // success it returns a func that releases the lock. func acquireGPGSetupLock(ctx context.Context) (func(), error) { lockCtx, cancel := context.WithTimeout(ctx, gpgSetupLockTimeout) defer cancel() - lock := flock.New(gpgSetupLockPath) + // Repairs a lock file a pre-existing binary left at a restrictive mode, + // which this process (running as whichever user didn't create it) can't + // fix itself once flock.TryLockContext below fails with EACCES. + if err := sharedfile.WidenWithSudoFallback( + lockCtx, gpgSetupLockPath, gpgSetupLockMode, + ); err != nil { + return nil, fmt.Errorf("widen stale lock file: %w", err) + } + + lock := flock.New(gpgSetupLockPath, flock.SetPermissions(gpgSetupLockMode)) locked, err := lock.TryLockContext(lockCtx, 200*time.Millisecond) if err != nil { if ctx.Err() != nil { @@ -121,6 +135,13 @@ func acquireGPGSetupLock(ctx context.Context) (func(), error) { return nil, fmt.Errorf("timed out waiting for another gpg setup to finish") } + // flock.SetPermissions is subject to the process umask on create; + // widen again to guarantee the mode regardless of who created it. + if err := sharedfile.WidenIfNeeded(gpgSetupLockPath, gpgSetupLockMode); err != nil { + _ = lock.Unlock() + return nil, fmt.Errorf("set lock file permissions: %w", err) + } + return func() { _ = lock.Unlock() }, nil } diff --git a/cmd/internal/agentworkspace/setup_gpg_test.go b/cmd/internal/agentworkspace/setup_gpg_test.go index 93f3f30cd..28f2c00cc 100644 --- a/cmd/internal/agentworkspace/setup_gpg_test.go +++ b/cmd/internal/agentworkspace/setup_gpg_test.go @@ -3,6 +3,7 @@ package agentworkspace import ( "context" "errors" + "os" "path/filepath" "testing" "time" @@ -101,3 +102,102 @@ func TestAcquireGPGSetupLock_ReturnsCancellationErrorWhenCallerCancels(t *testin err, ) } + +func TestAcquireGPGSetupLock_FileIsWorldLockable(t *testing.T) { + origPath, origTimeout := gpgSetupLockPath, gpgSetupLockTimeout + gpgSetupLockPath = filepath.Join(t.TempDir(), "setup-gpg.lock") + gpgSetupLockTimeout = time.Second + defer func() { gpgSetupLockPath, gpgSetupLockTimeout = origPath, origTimeout }() + + unlock, err := acquireGPGSetupLock(context.Background()) + require.NoError(t, err) + unlock() + + info, err := os.Stat(gpgSetupLockPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o666), info.Mode().Perm(), + "lock file must be 0666 so any container user (root or the workspace's "+ + "remoteUser) can create/open it; the default flock mode of 0600 lets "+ + "whichever user runs setup-gpg first lock out every other user "+ + "with EACCES") +} + +func TestAcquireGPGSetupLock_SecondAcquireOfAlready0666FileDoesNotChmod(t *testing.T) { + origPath, origTimeout := gpgSetupLockPath, gpgSetupLockTimeout + gpgSetupLockPath = filepath.Join(t.TempDir(), "setup-gpg.lock") + gpgSetupLockTimeout = time.Second + defer func() { gpgSetupLockPath, gpgSetupLockTimeout = origPath, origTimeout }() + + unlock, err := acquireGPGSetupLock(context.Background()) + require.NoError(t, err) + unlock() + + info, err := os.Stat(gpgSetupLockPath) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o666), info.Mode().Perm()) + + reacquire, err := acquireGPGSetupLock(context.Background()) + require.NoError(t, err, "second acquisition of an already-0666 lock file must succeed") + reacquire() +} + +func TestAcquireGPGSetupLock_FixesWrongMode(t *testing.T) { + origPath, origTimeout := gpgSetupLockPath, gpgSetupLockTimeout + gpgSetupLockPath = filepath.Join(t.TempDir(), "setup-gpg.lock") + gpgSetupLockTimeout = time.Second + defer func() { gpgSetupLockPath, gpgSetupLockTimeout = origPath, origTimeout }() + + unlock, err := acquireGPGSetupLock(context.Background()) + require.NoError(t, err) + unlock() + + // #nosec G302 -- intentional: simulating a wrong pre-existing mode + require.NoError(t, os.Chmod(gpgSetupLockPath, 0o644)) + + reacquire, err := acquireGPGSetupLock(context.Background()) + require.NoError(t, err) + defer reacquire() + + info, err := os.Stat(gpgSetupLockPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o666), info.Mode().Perm(), + "acquireGPGSetupLock must fix a wrong mode back to 0666") +} + +func TestAcquireGPGSetupLock_WidensStaleRestrictiveLockFile(t *testing.T) { + origPath, origTimeout := gpgSetupLockPath, gpgSetupLockTimeout + gpgSetupLockPath = filepath.Join(t.TempDir(), "setup-gpg.lock") + gpgSetupLockTimeout = time.Second + defer func() { gpgSetupLockPath, gpgSetupLockTimeout = origPath, origTimeout }() + + require.NoError(t, os.WriteFile(gpgSetupLockPath, nil, 0o600)) + + unlock, err := acquireGPGSetupLock(context.Background()) + require.NoError(t, err, "a stale restrictively-moded lock file must not block acquisition") + defer unlock() + + info, err := os.Stat(gpgSetupLockPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o666), info.Mode().Perm()) +} + +func TestAcquireGPGSetupLock_RejectsSymlink(t *testing.T) { + origPath, origTimeout := gpgSetupLockPath, gpgSetupLockTimeout + dir := t.TempDir() + gpgSetupLockPath = filepath.Join(dir, "setup-gpg.lock") + gpgSetupLockTimeout = time.Second + defer func() { gpgSetupLockPath, gpgSetupLockTimeout = origPath, origTimeout }() + + target := filepath.Join(dir, "target") + require.NoError(t, os.WriteFile(target, nil, 0o600)) + require.NoError(t, os.Symlink(target, gpgSetupLockPath)) + + _, err := acquireGPGSetupLock(context.Background()) + require.Error(t, err, + "opening the symlinked lock path with O_NOFOLLOW must fail, not follow it") + + info, err := os.Stat(target) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm(), + "the symlink target's mode must be untouched, not widened to 0666") +} diff --git a/cmd/internal/fleet_server.go b/cmd/internal/fleet_server.go index 734b766cc..3370f74f9 100644 --- a/cmd/internal/fleet_server.go +++ b/cmd/internal/fleet_server.go @@ -61,7 +61,7 @@ func (c *FleetServerCmd) Run(cmd *cobra.Command, _ []string) error { } // check if we had at least one fleet client connection, before - // this point, we don't check for connected/disconnected strings + // this point, we do not check for connected/disconnected strings initialized := firstConnection.FindStringSubmatch(string(log)) if len(initialized) == 0 { continue @@ -72,8 +72,7 @@ func (c *FleetServerCmd) Run(cmd *cobra.Command, _ []string) error { // if ouf last occurrence of notify if "Notify ID connected" // we have an active session, so let's keep alive if strings.Contains(connString[len(connString)-1][0], "is connected") { - file, _ := os.Create(config.ContainerActivityFile) - _ = file.Close() + touchActivityFile(config.ContainerActivityFile) } case <-cmd.Context().Done(): // context is done - either canceled or time is up for timeout diff --git a/cmd/internal/internal.go b/cmd/internal/internal.go index 08c95187a..d7366c03b 100644 --- a/cmd/internal/internal.go +++ b/cmd/internal/internal.go @@ -46,6 +46,7 @@ func NewInternalCmd(globalFlags *flags.GlobalFlags) *cobra.Command { cmd.AddCommand(withPreRun(NewGetImageCmd(globalFlags))) cmd.AddCommand(withPreRun(NewGetImagePlatformsCmd(globalFlags))) cmd.AddCommand(withPreRun(NewBrowserTunnelCmd(globalFlags))) + cmd.AddCommand(withPreRun(NewWidenSharedFileCmd(globalFlags))) return cmd } diff --git a/cmd/internal/ssh_server.go b/cmd/internal/ssh_server.go index 187c69d60..e58278e3b 100644 --- a/cmd/internal/ssh_server.go +++ b/cmd/internal/ssh_server.go @@ -5,7 +5,6 @@ import ( "encoding/base64" "errors" "fmt" - "io/fs" "os" "time" @@ -13,6 +12,7 @@ import ( "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/sharedfile" sshserver "github.com/devsy-org/devsy/pkg/ssh/server" "github.com/devsy-org/devsy/pkg/ssh/server/port" "github.com/devsy-org/devsy/pkg/stdio" @@ -233,22 +233,18 @@ func runActivityHeartbeat(ctx context.Context, path string) { } func ensureActivityFile(path string) error { - _, err := os.Stat(path) - if err == nil { - return nil - } - if !errors.Is(err, fs.ErrNotExist) { - return fmt.Errorf("stat: %w", err) - } - if err := os.WriteFile( - path, - nil, - activityFileMode, - ); err != nil { // #nosec G306 -- intentionally world-writable; multiple users update activity - return fmt.Errorf("create: %w", err) + return sharedfile.EnsureMode(path, activityFileMode) +} + +// touchActivityFile is for callers reporting activity on discrete events +// rather than runActivityHeartbeat's fixed interval (e.g. fleet-server). +func touchActivityFile(path string) { + if err := ensureActivityFile(path); err != nil { + log.Errorf("touch activity file: ensure file: %v", err) + return } - if err := os.Chmod(path, activityFileMode); err != nil { // #nosec G302 -- ditto - return fmt.Errorf("chmod: %w", err) + now := time.Now() + if err := os.Chtimes(path, now, now); err != nil { + log.Errorf("touch activity file: %v", err) } - return nil } diff --git a/cmd/internal/ssh_server_test.go b/cmd/internal/ssh_server_test.go index 27aaacc36..0dd4b5a94 100644 --- a/cmd/internal/ssh_server_test.go +++ b/cmd/internal/ssh_server_test.go @@ -15,6 +15,8 @@ import ( "github.com/devsy-org/devsy/pkg/token" "github.com/devsy-org/ssh" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func encodeTestToken(t *testing.T, tok token.Token) string { @@ -184,3 +186,58 @@ func TestRunActivityHeartbeatExitsOnContextCancel(t *testing.T) { t.Fatal("heartbeat did not exit within 2s of context cancel") } } + +func TestEnsureActivityFile_CreatesWorldWritableFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "devsy.activity") + + require.NoError(t, ensureActivityFile(path)) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o666), info.Mode().Perm(), + "the activity file must be 0666 so both the root browser-IDE tunnel and "+ + "a non-root GPG-forwarding tunnel can touch it") +} + +func TestEnsureActivityFile_NoOpsWhenFileAlreadyExists(t *testing.T) { + path := filepath.Join(t.TempDir(), "devsy.activity") + require.NoError(t, os.WriteFile(path, []byte("existing"), 0o600)) + + require.NoError(t, ensureActivityFile(path)) + + data, err := os.ReadFile(path) //nolint:gosec // test-owned temp path + require.NoError(t, err) + assert.Equal(t, "existing", string(data), + "ensureActivityFile must not truncate a file that already exists") + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o666), info.Mode().Perm(), + "a stale restrictive mode left by an existing file must still get widened") +} + +func TestTouchActivityFile_CreatesFileAndUpdatesMtime(t *testing.T) { + path := filepath.Join(t.TempDir(), "devsy.activity") + + touchActivityFile(path) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o666), info.Mode().Perm()) + assert.WithinDuration(t, time.Now(), info.ModTime(), 2*time.Second) +} + +func TestTouchActivityFile_UpdatesMtimeOfExistingFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "devsy.activity") + old := time.Now().Add(-time.Hour) + //nolint:gosec // test fixture, intentional + require.NoError(t, os.WriteFile(path, nil, 0o666)) + require.NoError(t, os.Chtimes(path, old, old)) + + touchActivityFile(path) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.WithinDuration(t, time.Now(), info.ModTime(), 2*time.Second, + "touchActivityFile must advance mtime on an already-existing file") +} diff --git a/cmd/internal/widen_shared_file.go b/cmd/internal/widen_shared_file.go new file mode 100644 index 000000000..09dfa7bbb --- /dev/null +++ b/cmd/internal/widen_shared_file.go @@ -0,0 +1,33 @@ +package cmdinternal + +import ( + "fmt" + "os" + "strconv" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/sharedfile" + "github.com/spf13/cobra" +) + +// NewWidenSharedFileCmd returns a internal command that runs +// sharedfile.WidenIfNeeded. sharedfile.WidenWithSudoFallback re-execs this +// (via sudo) so the actual mode change still goes through WidenIfNeeded's +// open-with-O_NOFOLLOW-then-fchmod, even when it needs root — `sudo chmod +// ` has no way to refuse following a symlink at path, so re-execing +// into this process is what keeps the escalated path symlink-safe. +func NewWidenSharedFileCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + return &cobra.Command{ + Use: "widen-shared-file ", + Short: "Widen a coordination file's permissions if needed", + Args: cobra.ExactArgs(2), + Hidden: true, + RunE: func(_ *cobra.Command, args []string) error { + mode, err := strconv.ParseUint(args[1], 8, 32) + if err != nil { + return fmt.Errorf("parse mode %q: %w", args[1], err) + } + return sharedfile.WidenIfNeeded(args[0], os.FileMode(mode)) + }, + } +} diff --git a/cmd/internal/widen_shared_file_test.go b/cmd/internal/widen_shared_file_test.go new file mode 100644 index 000000000..719174acc --- /dev/null +++ b/cmd/internal/widen_shared_file_test.go @@ -0,0 +1,35 @@ +package cmdinternal + +import ( + "os" + "path/filepath" + "testing" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWidenSharedFileCmd_WidensModeArgument(t *testing.T) { + path := filepath.Join(t.TempDir(), "coord") + //nolint:gosec // test fixture, intentional + require.NoError(t, os.WriteFile(path, nil, 0o644)) + + cmd := NewWidenSharedFileCmd(&flags.GlobalFlags{}) + cmd.SetArgs([]string{path, "0666"}) + require.NoError(t, cmd.Execute()) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o666), info.Mode().Perm()) +} + +func TestWidenSharedFileCmd_RejectsInvalidMode(t *testing.T) { + path := filepath.Join(t.TempDir(), "coord") + //nolint:gosec // test fixture, intentional + require.NoError(t, os.WriteFile(path, nil, 0o644)) + + cmd := NewWidenSharedFileCmd(&flags.GlobalFlags{}) + cmd.SetArgs([]string{path, "not-a-mode"}) + require.Error(t, cmd.Execute()) +} diff --git a/e2e/tests/ide/browser_returns.go b/e2e/tests/ide/browser_returns.go index c704c5afa..8b1f094d1 100644 --- a/e2e/tests/ide/browser_returns.go +++ b/e2e/tests/ide/browser_returns.go @@ -9,6 +9,7 @@ import ( "net" "net/url" "os" + "path/filepath" "runtime" "strings" "syscall" @@ -21,9 +22,10 @@ import ( "github.com/onsi/gomega" ) -// setupBrowserIDE prepares a docker provider + workspace tempdir and registers -// the standard cleanup deferred to DeferCleanup. It returns the framework and -// the workspace tempDir path. +const gpgTestKeyFingerprint = "07F681B9FD6C3411F679BFD1F51769DB572DDD3F" + +// setupBrowserIDE prepares a docker provider and workspace tempdir and registers +// the standard cleanup deferred to DeferCleanup. func setupBrowserIDE(ctx context.Context, initialDir string) (*framework.Framework, string) { f := framework.NewDefaultFramework(initialDir + "/bin") tempDir, err := framework.CopyToTempDir("tests/ide/testdata") @@ -309,6 +311,60 @@ var _ = ginkgo.Describe( }, ) + ginkgo.It( + "does not collide on shared /tmp coordination files when GPG forwarding "+ + "races the browser-IDE tunnel under a non-root remoteUser", + ginkgo.Label("gpg"), + ginkgo.SpecTimeout(framework.TimeoutLong()), + func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + tempDir, err := framework.CopyToTempDir("tests/ide/testdata-gpg-nonroot") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir) + + err = f.DevsyProviderAdd(ctx, "docker") + framework.ExpectNoError(err) + err = f.DevsyProviderUse(ctx, "docker") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func(cleanupCtx context.Context) { + _ = f.DevsyWorkspaceDelete(cleanupCtx, tempDir) + }) + + ginkgo.GinkgoT().Setenv("GNUPGHOME", ginkgo.GinkgoT().TempDir()) + framework.ExpectNoError( + framework.ImportGpgKey( + filepath.Join( + initialDir, + "tests/ssh/testdata/gpg-forwarding/gpg-private.key", + ), + ), + ) + + stdout, stderr, err := f.DevsyUpStreamsRaw(ctx, tempDir, + "--ide=openvscode", "--ide-launch=headless", + names.Flag(names.SSHGPGForwarding), "--debug") + framework.ExpectNoError(err) + combined := stdout + stderr + + gomega.Expect(combined).NotTo(gomega.ContainSubstring("permission denied"), + "root and vscode sessions must not collide on /tmp/devsy-gpg-setup.lock; "+ + "got:\n%s", combined) + gomega.Expect(combined).NotTo(gomega.ContainSubstring("operation not permitted"), + "root and vscode sessions must not collide on /tmp/devsy.activity; "+ + "got:\n%s", combined) + gomega.Expect(combined).NotTo(gomega.ContainSubstring("continuing without it"), + "GPG agent forwarding must succeed end-to-end for a non-root remoteUser "+ + "browser IDE; got:\n%s", combined) + + sshCtx, cancelSSH := context.WithDeadline(ctx, time.Now().Add(30*time.Second)) + defer cancelSSH() + err = f.DevsySSHGpgSecretKeyForwarded(sshCtx, tempDir, gpgTestKeyFingerprint) + framework.ExpectNoError(err) + + framework.ExpectNoError(f.DevsyStop(ctx, tempDir)) + }, + ) + ginkgo.It( "does not log 'setup KubeConfig' on a workspace without kubeconfig forwarding", ginkgo.SpecTimeout(framework.TimeoutLong()), diff --git a/e2e/tests/ide/testdata-gpg-nonroot/.devcontainer.json b/e2e/tests/ide/testdata-gpg-nonroot/.devcontainer.json new file mode 100644 index 000000000..b639e5156 --- /dev/null +++ b/e2e/tests/ide/testdata-gpg-nonroot/.devcontainer.json @@ -0,0 +1,5 @@ +{ + "name": "GPG nonroot browser IDE", + "image": "ghcr.io/devsy-org/test-images/go:1", + "remoteUser": "vscode" +} diff --git a/pkg/devcontainer/setup/setup.go b/pkg/devcontainer/setup/setup.go index 3e2899e6c..3878e7044 100644 --- a/pkg/devcontainer/setup/setup.go +++ b/pkg/devcontainer/setup/setup.go @@ -23,6 +23,7 @@ import ( "github.com/devsy-org/devsy/pkg/envfile" "github.com/devsy-org/devsy/pkg/gitcredentials" "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/sharedfile" "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" ) @@ -210,21 +211,31 @@ func writeResultFile(cfg *ContainerSetupConfig) { return } - existing, _ := os.ReadFile(pkgconfig.DevContainerResultPath) - if string(rawBytes) == string(existing) { - return + if err := writeResultFileTo(pkgconfig.DevContainerResultPath, rawBytes); err != nil { + log.Warnf("error write result to %s: %v", pkgconfig.DevContainerResultPath, err) } +} - if err := os.MkdirAll( // #nosec G301 - filepath.Dir(pkgconfig.DevContainerResultPath), - 0o755, - ); err != nil { - log.Warnf("error create %s: %v", filepath.Dir(pkgconfig.DevContainerResultPath), err) +// writeResultFileTo writes rawBytes to path at 0644: readable by any +// container user, not just root, since getContainerResult and +// portOptionsFromResult read it over sessions authenticated as either. +// Goes through sharedfile rather than raw os.ReadFile/os.WriteFile so a +// symlink or FIFO planted at this fixed, predictable path is rejected +// rather than followed or hung on, matching the coordination files this +// package's other callers protect the same way. +func writeResultFileTo(path string, rawBytes []byte) error { + existing, _ := sharedfile.ReadFile(path) + if string(rawBytes) == string(existing) { + // Widen even when skipping the write: a stale file left at a + // restrictive mode by a pre-fix binary must still get readable by + // the other session's user, not just on the next content change. + return sharedfile.WidenWithSudoFallback(context.Background(), path, 0o644) } - if err := os.WriteFile(pkgconfig.DevContainerResultPath, rawBytes, 0o600); err != nil { - log.Warnf("error write result to %s: %v", pkgconfig.DevContainerResultPath, err) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { // #nosec G301 + return fmt.Errorf("create %s: %w", filepath.Dir(path), err) } + return sharedfile.WriteFile(path, rawBytes, 0o644) } func setupWorkspaceOwnership(cfg *ContainerSetupConfig) error { diff --git a/pkg/devcontainer/setup/setup_test.go b/pkg/devcontainer/setup/setup_test.go index 8b283a9d2..2fd3de80f 100644 --- a/pkg/devcontainer/setup/setup_test.go +++ b/pkg/devcontainer/setup/setup_test.go @@ -2,6 +2,7 @@ package setup import ( "context" + "os" "path/filepath" "testing" @@ -104,3 +105,88 @@ func TestSetupKubeConfig_NonEmptyPayloadEmitsInfoLog(t *testing.T) { ) } } + +func TestWriteResultFileTo_ProducesWorldReadableFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "result.json") + + if err := writeResultFileTo(path, []byte(`{"ok":true}`)); err != nil { + t.Fatalf("writeResultFileTo: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if got := info.Mode().Perm(); got != 0o644 { + t.Errorf("mode = %o, want 0644 (must be readable by any container user)", got) + } +} + +func TestWriteResultFileTo_SkipsWriteWhenContentUnchanged(t *testing.T) { + path := filepath.Join(t.TempDir(), "result.json") + content := []byte(`{"ok":true}`) + + if err := writeResultFileTo(path, content); err != nil { + t.Fatalf("first write: %v", err) + } + first, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + + if err := writeResultFileTo(path, content); err != nil { + t.Fatalf("second write: %v", err) + } + second, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + + if !second.ModTime().Equal(first.ModTime()) { + t.Errorf( + "mtime changed on unchanged content: first=%v second=%v", + first.ModTime(), second.ModTime(), + ) + } +} + +func TestWriteResultFileTo_WidensExistingRestrictiveMode(t *testing.T) { + path := filepath.Join(t.TempDir(), "result.json") + // #nosec G306 -- intentional: simulating a pre-fix file left at 0600 + if err := os.WriteFile(path, []byte(`{"old":true}`), 0o600); err != nil { + t.Fatalf("seed file: %v", err) + } + + if err := writeResultFileTo(path, []byte(`{"new":true}`)); err != nil { + t.Fatalf("writeResultFileTo: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if got := info.Mode().Perm(); got != 0o644 { + t.Errorf("mode = %o, want 0644 (a stale 0600 file must be widened)", got) + } +} + +func TestWriteResultFileTo_WidensStaleModeEvenWhenContentUnchanged(t *testing.T) { + path := filepath.Join(t.TempDir(), "result.json") + content := []byte(`{"ok":true}`) + // #nosec G306 -- intentional: simulating a pre-fix file left at 0600 + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatalf("seed file: %v", err) + } + + if err := writeResultFileTo(path, content); err != nil { + t.Fatalf("writeResultFileTo: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if got := info.Mode().Perm(); got != 0o644 { + t.Errorf("mode = %o, want 0644 even though content was already up to date", got) + } +} diff --git a/pkg/devcontainer/setup/setup_unix_test.go b/pkg/devcontainer/setup/setup_unix_test.go new file mode 100644 index 000000000..9bf13b158 --- /dev/null +++ b/pkg/devcontainer/setup/setup_unix_test.go @@ -0,0 +1,64 @@ +//go:build linux || darwin || unix + +package setup + +import ( + "os" + "path/filepath" + "syscall" + "testing" + "time" +) + +// TestWriteResultFileTo_RejectsSymlinkWithoutFollowing guards against a +// symlink planted at DevContainerResultPath's fixed, predictable path +// redirecting the write onto an arbitrary target — the same class of +// attack pkg/sharedfile's other callers (the GPG lock, the activity file) +// already defend against. +func TestWriteResultFileTo_RejectsSymlinkWithoutFollowing(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "result.json") + decoyTarget := filepath.Join(dir, "decoy") + if err := os.WriteFile(decoyTarget, []byte("untouched"), 0o600); err != nil { + t.Fatalf("seed decoy: %v", err) + } + if err := os.Symlink(decoyTarget, path); err != nil { + t.Fatalf("symlink: %v", err) + } + + if err := writeResultFileTo(path, []byte(`{"ok":true}`)); err == nil { + t.Fatal("expected writeResultFileTo to reject a symlinked path, got nil error") + } + + //nolint:gosec // test-owned temp path + content, err := os.ReadFile(decoyTarget) + if err != nil { + t.Fatalf("stat decoy: %v", err) + } + if string(content) != "untouched" { + t.Errorf("decoy content = %q, want unchanged %q", content, "untouched") + } +} + +// TestWriteResultFileTo_RejectsFIFOWithoutBlocking guards against a FIFO +// planted at the result-file path hanging the container's own setup phase +// forever: opening a FIFO for read/write with no matching peer blocks +// indefinitely without O_NONBLOCK. +func TestWriteResultFileTo_RejectsFIFOWithoutBlocking(t *testing.T) { + path := filepath.Join(t.TempDir(), "result.json") + if err := syscall.Mkfifo(path, 0o666); err != nil { + t.Fatalf("mkfifo: %v", err) + } + + done := make(chan error, 1) + go func() { done <- writeResultFileTo(path, []byte(`{"ok":true}`)) }() + + select { + case err := <-done: + if err == nil { + t.Fatal("expected writeResultFileTo to reject a FIFO, got nil error") + } + case <-time.After(2 * time.Second): + t.Fatal("writeResultFileTo blocked for 2s+ opening a FIFO with no peer") + } +} diff --git a/pkg/gpg/forward_test.go b/pkg/gpg/forward_test.go index 7786c9ba9..6e98fb3cb 100644 --- a/pkg/gpg/forward_test.go +++ b/pkg/gpg/forward_test.go @@ -29,6 +29,23 @@ func TestBuildForwardArgs(t *testing.T) { assert.Equal(t, expected, got) } +func TestBuildForwardArgs_NonRootUser(t *testing.T) { + got := buildForwardArgs("vscode", "test-context", "test-workspace") + expected := []string{ + "workspace", + "ssh", + "--ssh-gpg-forwarding=true", + "--agent-forwarding=true", + "--start-services=true", + "--user", "vscode", + "--context", "test-context", + "test-workspace", + "--log-output=raw", + "--command", "sleep infinity", + } + assert.Equal(t, expected, got) +} + func TestSuperviseForward_RestartsUntilCancelled(t *testing.T) { runs := filepath.Join(t.TempDir(), "runs") ctx, cancel := context.WithCancel(context.Background()) diff --git a/pkg/ide/opener/browser_tunnel_test.go b/pkg/ide/opener/browser_tunnel_test.go index 46c86c6b6..08faf7b90 100644 --- a/pkg/ide/opener/browser_tunnel_test.go +++ b/pkg/ide/opener/browser_tunnel_test.go @@ -13,13 +13,14 @@ import ( "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/tunnel" + "github.com/stretchr/testify/assert" ) -// containsAdjacent returns true if args contains needle followed immediately -// by value. -func containsAdjacent(args []string, needle, value string) bool { +// containsAdjacent reports whether flag appears immediately followed by +// value in args, e.g. ["--user", "vscode"]. +func containsAdjacent(args []string, flag, value string) bool { for i := 0; i < len(args)-1; i++ { - if args[i] == needle && args[i+1] == value { + if args[i] == flag && args[i+1] == value { return true } } @@ -105,6 +106,18 @@ func TestBuildHelperArgs_OpenBrowser(t *testing.T) { } } +func TestBuildHelperArgs_IncludesResolvedUser(t *testing.T) { + args := buildHelperArgs("default", "my-workspace", tunnel.BrowserTunnelParams{ + User: "vscode", + TargetURL: "http://localhost:1234", + }, false) + + joined := strings.Join(args, " ") + assert.Contains(t, joined, "--user vscode", + "the detached browser-tunnel helper must run its RunServices/backhaul "+ + "connections as the resolved workspace user, not be left empty/root") +} + // setupTempHome redirects the path manager to a temp HOME so the workspace // dir is writable and isolated from the real user's devsy data. func setupTempHome(t *testing.T) { diff --git a/pkg/ide/opener/opener.go b/pkg/ide/opener/opener.go index ff3a26333..bd17384ce 100644 --- a/pkg/ide/opener/opener.go +++ b/pkg/ide/opener/opener.go @@ -470,6 +470,7 @@ func startFleet(ctx context.Context, params IDEParams) (string, error) { sshCmd, err := tunnel.CreateSSHCommand( ctx, params.Client, + params.User, []string{names.Flag(names.Command), "cat " + fleet.FleetURLFileName}, ) if err != nil { diff --git a/pkg/sharedfile/sharedfile.go b/pkg/sharedfile/sharedfile.go new file mode 100644 index 000000000..867ac7019 --- /dev/null +++ b/pkg/sharedfile/sharedfile.go @@ -0,0 +1,143 @@ +// Package sharedfile hardens the standard Unix pattern of coordinating +// between processes running as different users through a world-writable +// file in a shared, trusted location (e.g. /tmp) — the same idea behind +// /tmp/.X11-unix or /var/run/utmp. A devsy container runs commands over SSH +// sessions authenticated as either root or the workspace's remoteUser +// (pkg/ssh/server/ssh_container.go sets the process credential directly +// from the authenticated session user), so any file two of those sessions +// both touch needs its permissions to survive being created by either one. +// +// The pattern has two failure modes this package exists to close: +// - Whichever process creates the file first can lock every other user +// out, because file creation is subject to the process umask. +// - A symlink planted at the file's path redirects Chmod onto an +// arbitrary target, since Chmod follows symlinks — dangerous for a +// fixed, predictable, world-writable path any container user can +// pre-create. +package sharedfile + +import ( + "errors" + "fmt" + "io" + "os" +) + +// EnsureMode ensures path exists with exactly mode permissions, creating it +// if absent. Skips the chmod when the file's mode already matches: chmod +// requires ownership (or root) even when the requested mode would not +// change, so skipping it when unnecessary avoids EPERM for a non-owning +// acquirer of an already-correctly-moded file. +// +// Rejects a path that resolves to a symlink rather than following it. +func EnsureMode(path string, mode os.FileMode) error { + if err := createIfMissing(path, mode); err != nil { + return err + } + return WidenIfNeeded(path, mode) +} + +// WidenIfNeeded chmods path to mode if its current mode differs, skipping +// the chmod entirely when it is already correct. Opens path without +// following a symlink and chmods the resulting descriptor rather than the +// path, so a symlink swapped in after a check-then-chmod by path could not +// redirect the chmod onto an arbitrary target. +func WidenIfNeeded(path string, mode os.FileMode) error { + f, err := openNoFollowRegular(path, os.O_RDONLY, 0) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + info, err := f.Stat() + if err != nil { + return fmt.Errorf("stat %s: %w", path, err) + } + if info.Mode().Perm() == mode.Perm() { + return nil + } + if err := f.Chmod(mode); err != nil { + return fmt.Errorf("chmod %s: %w", path, err) + } + return nil +} + +// ReadFile reads path the same way os.ReadFile does, but refuses to follow +// a symlink or block on a FIFO planted at path. +func ReadFile(path string) ([]byte, error) { + f, err := openNoFollowRegular(path, os.O_RDONLY, 0) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + return io.ReadAll(f) +} + +// WriteFile writes data to path at mode the same way os.WriteFile does, +// creating path if absent, but refuses to follow a symlink or block on a +// FIFO already at path. O_NOFOLLOW still applies when O_CREATE is also +// set: an existing symlink is rejected rather than followed, while a +// genuinely missing path is created as a fresh regular file. +func WriteFile(path string, data []byte, mode os.FileMode) (err error) { + f, err := openNoFollowRegular(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + defer func() { + if closeErr := f.Close(); err == nil && closeErr != nil { + err = fmt.Errorf("close %s: %w", path, closeErr) + } + }() + + if _, err := f.Write(data); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + info, err := f.Stat() + if err != nil { + return fmt.Errorf("stat %s: %w", path, err) + } + if info.Mode().Perm() != mode.Perm() { + if err := f.Chmod(mode); err != nil { + return fmt.Errorf("chmod %s: %w", path, err) + } + } + return nil +} + +// openNoFollowRegular opens path with flag (plus the no-follow/non-blocking +// guards openNoFollow always adds) and rejects the result if it is not a +// regular file — a FIFO would otherwise pass the open (O_NONBLOCK just +// keeps that from hanging) and reach a caller expecting file content. +// createMode is only used when flag includes os.O_CREATE. +func openNoFollowRegular(path string, flag int, createMode os.FileMode) (*os.File, error) { + f, err := openNoFollow(path, flag, createMode) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + info, err := f.Stat() + if err != nil { + _ = f.Close() + return nil, fmt.Errorf("stat %s: %w", path, err) + } + if !info.Mode().IsRegular() { + _ = f.Close() + return nil, fmt.Errorf( + "refusing to use %s: not a regular file (mode %s)", path, info.Mode(), + ) + } + return f, nil +} + +// createIfMissing creates path at mode if it does not already exist. Leaves +// an existing file untouched. +func createIfMissing(path string, mode os.FileMode) error { + //nolint:gosec // callers intentionally create a fixed, world-accessible coordination file + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) + if err != nil { + if errors.Is(err, os.ErrExist) { + return nil + } + return fmt.Errorf("create %s: %w", path, err) + } + return f.Close() +} diff --git a/pkg/sharedfile/sharedfile_supported.go b/pkg/sharedfile/sharedfile_supported.go new file mode 100644 index 000000000..0cef4a44d --- /dev/null +++ b/pkg/sharedfile/sharedfile_supported.go @@ -0,0 +1,22 @@ +//go:build linux || darwin || unix + +package sharedfile + +import ( + "os" + "syscall" +) + +// openNoFollow opens path without following a trailing symlink, so the +// caller can chmod/read/write the resulting descriptor's inode regardless +// of what path later resolves to. createMode is used only when flag +// includes os.O_CREATE. This always adds O_NOFOLLOW and O_NONBLOCK — the +// latter so a FIFO planted at path fails the open immediately +// (ENXIO/EAGAIN) instead of blocking forever waiting for a peer. The +// caller must still reject non-regular files after Stat: O_NONBLOCK only +// keeps the open from hanging, it does not stop a FIFO fd from being +// returned. +func openNoFollow(path string, flag int, createMode os.FileMode) (*os.File, error) { + //nolint:gosec // callers intentionally open a fixed coordination-file path + return os.OpenFile(path, flag|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, createMode) +} diff --git a/pkg/sharedfile/sharedfile_supported_test.go b/pkg/sharedfile/sharedfile_supported_test.go new file mode 100644 index 000000000..48e7a5086 --- /dev/null +++ b/pkg/sharedfile/sharedfile_supported_test.go @@ -0,0 +1,68 @@ +//go:build linux || darwin || unix + +package sharedfile + +import ( + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestWidenIfNeeded_RejectsFIFOWithoutBlocking is the regression test for +// CodeRabbit's finding: opening a FIFO planted at path with no writer +// blocks forever without O_NONBLOCK, letting any container user with +// write access to the coordination file's directory hang every future +// caller. WidenIfNeeded must return promptly with an error instead. +func TestWidenIfNeeded_RejectsFIFOWithoutBlocking(t *testing.T) { + path := filepath.Join(t.TempDir(), "coord") + require.NoError(t, syscall.Mkfifo(path, 0o666)) + + done := make(chan error, 1) + go func() { done <- WidenIfNeeded(path, 0o666) }() + + select { + case err := <-done: + require.Error(t, err, + "a FIFO at the coordination path must be rejected, not silently accepted") + assert.Contains(t, err.Error(), "not a regular file") + case <-time.After(2 * time.Second): + t.Fatal("WidenIfNeeded blocked for 2s+ opening a FIFO with no writer") + } +} + +func TestReadFile_RejectsFIFOWithoutBlocking(t *testing.T) { + path := filepath.Join(t.TempDir(), "coord") + require.NoError(t, syscall.Mkfifo(path, 0o666)) + + done := make(chan error, 1) + go func() { + _, err := ReadFile(path) + done <- err + }() + + select { + case err := <-done: + require.Error(t, err) + case <-time.After(2 * time.Second): + t.Fatal("ReadFile blocked for 2s+ opening a FIFO with no writer") + } +} + +func TestWriteFile_RejectsFIFOWithoutBlocking(t *testing.T) { + path := filepath.Join(t.TempDir(), "coord") + require.NoError(t, syscall.Mkfifo(path, 0o666)) + + done := make(chan error, 1) + go func() { done <- WriteFile(path, []byte("x"), 0o644) }() + + select { + case err := <-done: + require.Error(t, err) + case <-time.After(2 * time.Second): + t.Fatal("WriteFile blocked for 2s+ opening a FIFO with no reader") + } +} diff --git a/pkg/sharedfile/sharedfile_test.go b/pkg/sharedfile/sharedfile_test.go new file mode 100644 index 000000000..15b7ed911 --- /dev/null +++ b/pkg/sharedfile/sharedfile_test.go @@ -0,0 +1,187 @@ +package sharedfile + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEnsureMode_CreatesMissingFileAtMode(t *testing.T) { + path := filepath.Join(t.TempDir(), "coord") + + require.NoError(t, EnsureMode(path, 0o666)) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o666), info.Mode().Perm()) +} + +func TestEnsureMode_WidensExistingRestrictiveFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "coord") + require.NoError(t, os.WriteFile(path, []byte("hello"), 0o600)) + + require.NoError(t, EnsureMode(path, 0o666)) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o666), info.Mode().Perm()) + content, err := os.ReadFile(path) //nolint:gosec // test-owned temp path + require.NoError(t, err) + assert.Equal(t, "hello", string(content), "widening mode must not touch existing content") +} + +func TestCreateIfMissing_LeavesExistingFileUntouched(t *testing.T) { + path := filepath.Join(t.TempDir(), "coord") + //nolint:gosec // test fixture, intentional + require.NoError(t, os.WriteFile(path, []byte("existing"), 0o644)) + + require.NoError(t, createIfMissing(path, 0o666)) + + content, err := os.ReadFile(path) //nolint:gosec // test-owned temp path + require.NoError(t, err) + assert.Equal(t, "existing", string(content), + "createIfMissing must not truncate a file a concurrent creator just wrote") +} + +func TestWidenIfNeeded_SkipsChmodWhenModeAlreadyCorrect(t *testing.T) { + path := filepath.Join(t.TempDir(), "coord") + //nolint:gosec + require.NoError(t, os.WriteFile(path, nil, 0o666)) + //nolint:gosec + require.NoError(t, os.Chmod(path, 0o666)) + + require.NoError(t, WidenIfNeeded(path, 0o666)) +} + +func TestWidenIfNeeded_SucceedsOnAlreadyCorrectModeWithoutWriteAccess(t *testing.T) { + path := filepath.Join(t.TempDir(), "coord") + //nolint:gosec // test fixture, intentional + require.NoError(t, os.WriteFile(path, nil, 0o444)) + //nolint:gosec // test fixture, intentional + require.NoError(t, os.Chmod(path, 0o444)) + + require.NoError(t, WidenIfNeeded(path, 0o444), + "confirming an already-correct mode must not require write access to the file") +} + +func TestWidenIfNeeded_WidensNarrowerMode(t *testing.T) { + path := filepath.Join(t.TempDir(), "coord") + require.NoError(t, os.WriteFile(path, nil, 0o644)) //nolint:gosec + require.NoError(t, WidenIfNeeded(path, 0o666)) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o666), info.Mode().Perm()) +} + +func TestWidenIfNeeded_StatErrorPropagates(t *testing.T) { + err := WidenIfNeeded(filepath.Join(t.TempDir(), "does-not-exist"), 0o666) + require.Error(t, err) +} + +func TestWidenIfNeeded_RejectsSymlinkWithoutTouchingTarget(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target") + link := filepath.Join(dir, "link") + require.NoError(t, os.WriteFile(target, nil, 0o600)) + require.NoError(t, os.Symlink(target, link)) + + err := WidenIfNeeded(link, 0o666) + require.Error(t, err, "opening link with O_NOFOLLOW must fail (ELOOP), not follow to target") + + info, err := os.Stat(target) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm(), + "the symlink target's mode must be untouched, not widened") +} + +func TestWidenIfNeeded_SymlinkSwappedAfterOpenCannotRedirectChmod(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "coord") + decoyTarget := filepath.Join(dir, "decoy") + require.NoError(t, os.WriteFile(path, nil, 0o644)) //nolint:gosec + require.NoError(t, os.WriteFile(decoyTarget, nil, 0o600)) + + f, err := os.OpenFile(path, os.O_WRONLY, 0) //nolint:gosec // test-owned temp path + require.NoError(t, err) + require.NoError(t, os.Remove(path)) + require.NoError(t, os.Symlink(decoyTarget, path)) + + require.NoError(t, f.Chmod(0o666), "chmod on an already-open fd must not be redirected") + require.NoError(t, f.Close()) + + decoyInfo, err := os.Stat(decoyTarget) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), decoyInfo.Mode().Perm(), + "the symlink planted after open must not have been affected") +} + +func TestReadFile_ReadsExistingContent(t *testing.T) { + path := filepath.Join(t.TempDir(), "coord") + //nolint:gosec // test fixture, intentional + require.NoError(t, os.WriteFile(path, []byte("hello"), 0o644)) + + got, err := ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "hello", string(got)) +} + +func TestReadFile_RejectsSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target") + link := filepath.Join(dir, "link") + require.NoError(t, os.WriteFile(target, []byte("secret"), 0o600)) + require.NoError(t, os.Symlink(target, link)) + + _, err := ReadFile(link) + require.Error(t, err) +} + +func TestWriteFile_CreatesNewFileAtMode(t *testing.T) { + path := filepath.Join(t.TempDir(), "coord") + + require.NoError(t, WriteFile(path, []byte("hello"), 0o644)) + + //nolint:gosec // test-owned temp path + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "hello", string(content)) + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o644), info.Mode().Perm()) +} + +func TestWriteFile_OverwritesExistingContentAndWidensMode(t *testing.T) { + path := filepath.Join(t.TempDir(), "coord") + // #nosec G306 -- test fixture, intentional + require.NoError(t, os.WriteFile(path, []byte("old"), 0o600)) + + require.NoError(t, WriteFile(path, []byte("new"), 0o644)) + + //nolint:gosec // test-owned temp path + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "new", string(content)) + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o644), info.Mode().Perm()) +} + +func TestWriteFile_RejectsSymlinkWithoutTouchingTarget(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target") + link := filepath.Join(dir, "link") + require.NoError(t, os.WriteFile(target, []byte("untouched"), 0o600)) + require.NoError(t, os.Symlink(target, link)) + + err := WriteFile(link, []byte("attacker-controlled"), 0o644) + require.Error(t, err) + + //nolint:gosec // test-owned temp path + content, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "untouched", string(content)) +} diff --git a/pkg/sharedfile/sharedfile_unsupported.go b/pkg/sharedfile/sharedfile_unsupported.go new file mode 100644 index 000000000..c6e718e54 --- /dev/null +++ b/pkg/sharedfile/sharedfile_unsupported.go @@ -0,0 +1,14 @@ +//go:build windows + +package sharedfile + +import ( + "fmt" + "os" + "runtime" +) + +// openNoFollow has no symlink-safe open on Windows. +func openNoFollow(string, int, os.FileMode) (*os.File, error) { + return nil, fmt.Errorf("sharedfile: not supported on %s", runtime.GOOS) +} diff --git a/pkg/sharedfile/sudo.go b/pkg/sharedfile/sudo.go new file mode 100644 index 000000000..d2e3716d2 --- /dev/null +++ b/pkg/sharedfile/sudo.go @@ -0,0 +1,52 @@ +package sharedfile + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + + "github.com/devsy-org/devsy/pkg/log" +) + +// WidenWithSudoFallback behaves like WidenIfNeeded, but on EPERM (path +// exists at the wrong mode and this process does not own it) falls back to +// re-execing ` internal widen-shared-file` under a non-interactive +// sudo, so the escalated mode change still goes through WidenIfNeeded's +// O_NOFOLLOW open rather than a plain `sudo chmod ` — chmod(1) has no +// way to refuse following a symlink at its target path. The fallback's +// failure is logged, not returned: this is a best-effort repair, and the +// caller's own lock acquisition will surface the real permission error if +// the repair fails. +func WidenWithSudoFallback(ctx context.Context, path string, mode os.FileMode) error { + err := WidenIfNeeded(path, mode) + if err == nil { + return nil + } + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if !errors.Is(err, fs.ErrPermission) { + return err + } + + execPath, err := os.Executable() + if err != nil { + log.Debugf("resolve self for sudo widen fallback (non-fatal): %v", err) + return nil + } + + // -n: fail immediately instead of prompting if sudo needs a password, + // so a caller holding a timeout-bounded lock cannot hang forever. + //nolint:gosec // execPath is the current binary; path is a fixed coordination-file path + cmd := exec.CommandContext( + ctx, "sudo", "-n", execPath, "internal", "widen-shared-file", + path, fmt.Sprintf("%04o", mode.Perm()), + ) + if sudoErr := cmd.Run(); sudoErr != nil { + log.Debugf("sudo widen-shared-file %s (non-fatal): %v", path, sudoErr) + } + return nil +} diff --git a/pkg/sharedfile/sudo_test.go b/pkg/sharedfile/sudo_test.go new file mode 100644 index 000000000..2760a8385 --- /dev/null +++ b/pkg/sharedfile/sudo_test.go @@ -0,0 +1,40 @@ +package sharedfile + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWidenWithSudoFallback_NoOpsWhenFileMissing(t *testing.T) { + path := filepath.Join(t.TempDir(), "does-not-exist") + + err := WidenWithSudoFallback(context.Background(), path, 0o666) + require.NoError(t, err, "a missing file is the caller's job to create, not this") +} + +func TestWidenWithSudoFallback_NoOpsWhenModeAlreadyCorrect(t *testing.T) { + path := filepath.Join(t.TempDir(), "coord") + require.NoError(t, os.WriteFile(path, nil, 0o600)) + //nolint:gosec // test fixture, intentional + require.NoError(t, os.Chmod(path, 0o666)) + + err := WidenWithSudoFallback(context.Background(), path, 0o666) + require.NoError(t, err) +} + +func TestWidenWithSudoFallback_WidensOwnedFileWithoutSudo(t *testing.T) { + path := filepath.Join(t.TempDir(), "coord") + require.NoError(t, os.WriteFile(path, nil, 0o600)) + + require.NoError(t, WidenWithSudoFallback(context.Background(), path, 0o666)) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o666), info.Mode().Perm(), + "a file this process owns must be widened directly, without needing sudo") +} diff --git a/pkg/tunnel/browser.go b/pkg/tunnel/browser.go index 7594f158a..6d7743487 100644 --- a/pkg/tunnel/browser.go +++ b/pkg/tunnel/browser.go @@ -71,7 +71,7 @@ func startBrowserTunnelSSH(ctx context.Context, p BrowserTunnelParams) error { writer := log.Writer(log.LevelDebug) defer func() { _ = writer.Close() }() - sshCmd, err := CreateSSHCommand(ctx, p.Client, []string{ + sshCmd, err := CreateSSHCommand(ctx, p.Client, p.User, []string{ names.FlagValue(names.LogOutput, "raw"), names.FlagValue(names.ReuseSSHAuthSock, p.AuthSockID), names.Flag(names.Stdio), @@ -242,10 +242,13 @@ func isTransientBackhaulErr(err error) bool { return exitErr.ExitCode() == exitcode.Retryable } -// CreateSSHCommand builds an exec.Cmd that runs `devsy ssh` with the given arguments. +// CreateSSHCommand builds an exec.Cmd that runs `devsy ssh` with the given +// arguments. user both authenticates the session and is who every command +// on it runs as. Empty defaults to root. func CreateSSHCommand( ctx context.Context, client client2.BaseWorkspaceClient, + user string, extraArgs []string, ) (*exec.Cmd, error) { execPath, err := os.Executable() @@ -253,32 +256,47 @@ func CreateSSHCommand( return nil, err } - args := buildSSHCommandArgs( - client.Context(), - client.Workspace(), - log.DebugEnabled(), - extraArgs, - ) + args := buildSSHCommandArgs(sshCommandArgsParams{ + clientContext: client.Context(), + workspace: client.Workspace(), + user: user, + debug: log.DebugEnabled(), + extraArgs: extraArgs, + }) //nolint:gosec // execPath is the current binary, arguments are controlled return exec.CommandContext(ctx, execPath, args...), nil } +// sshCommandArgsParams bundles buildSSHCommandArgs' inputs so the function +// takes one argument instead of five. +type sshCommandArgsParams struct { + clientContext string + workspace string + user string + debug bool + extraArgs []string +} + // buildSSHCommandArgs constructs the argument list for `devsy ssh`. -func buildSSHCommandArgs(clientContext, workspace string, debug bool, extraArgs []string) []string { +func buildSSHCommandArgs(p sshCommandArgsParams) []string { + user := p.user + if user == "" { + user = "root" + } args := []string{ "workspace", "ssh", - names.FlagValue(names.User, "root"), + names.FlagValue(names.User, user), names.FlagFalse(names.AgentForwarding), names.FlagFalse(names.StartServices), names.Flag(names.Context), - clientContext, - workspace, + p.clientContext, + p.workspace, } - if debug { + if p.debug { args = append(args, names.Flag(names.Debug)) } - args = append(args, extraArgs...) + args = append(args, p.extraArgs...) return args } diff --git a/pkg/tunnel/browser_test.go b/pkg/tunnel/browser_test.go index abd8d2ab0..596e9d2fe 100644 --- a/pkg/tunnel/browser_test.go +++ b/pkg/tunnel/browser_test.go @@ -1,18 +1,60 @@ package tunnel import ( + "bytes" + "context" "errors" "fmt" "os/exec" + "strings" "testing" + client2 "github.com/devsy-org/devsy/pkg/client" "github.com/devsy-org/devsy/pkg/exitcode" + "github.com/devsy-org/devsy/pkg/provider" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +const ( + testUserRoot = "root" + testUserVSCode = "vscode" + testWorkspaceName = "my-workspace" +) + +type fakeWorkspaceClient struct{} + +func (fakeWorkspaceClient) Provider() string { return "" } +func (fakeWorkspaceClient) Context() string { return "" } + +func (fakeWorkspaceClient) RefreshOptions( + ctx context.Context, userOptions []string, reconfigure bool, +) error { + return nil +} + +func (fakeWorkspaceClient) Status( + ctx context.Context, options client2.StatusOptions, +) (client2.Status, error) { + return "", nil +} + +func (fakeWorkspaceClient) Stop(ctx context.Context, options client2.StopOptions) error { + return nil +} + +func (fakeWorkspaceClient) Delete(ctx context.Context, options client2.DeleteOptions) error { + return nil +} +func (fakeWorkspaceClient) Workspace() string { return "" } +func (fakeWorkspaceClient) WorkspaceConfig() *provider.Workspace { return nil } +func (fakeWorkspaceClient) Lock(ctx context.Context) error { return nil } +func (fakeWorkspaceClient) Unlock() {} + +var _ client2.BaseWorkspaceClient = fakeWorkspaceClient{} + // exitError runs a shell command that exits with the given code and returns -// the resulting error (which wraps *exec.ExitError). +// the resulting error. func exitError(t *testing.T, code int) error { t.Helper() // #nosec G204 -- test helper with controlled exit code argument @@ -21,51 +63,97 @@ func exitError(t *testing.T, code int) error { return err } -func baseSSHArgs(ctx, ws string) []string { +func baseSSHArgs(ctx, user, ws string) []string { return []string{ - "workspace", "ssh", "--user=root", "--agent-forwarding=false", + "workspace", "ssh", "--user=" + user, "--agent-forwarding=false", "--start-services=false", "--context", ctx, ws, } } +func baseParams(user string) sshCommandArgsParams { + return sshCommandArgsParams{ + clientContext: testCtxName, workspace: testWorkspaceName, user: user, + } +} + func TestBuildSSHCommandArgs(t *testing.T) { tests := []struct { - name string - context string - workspace string - debug bool - extraArgs []string - expected []string + name string + params sshCommandArgsParams + expected []string }{ { - name: "basic", context: "default", workspace: "my-workspace", - expected: baseSSHArgs("default", "my-workspace"), + name: "basic root user", + params: baseParams(testUserRoot), + expected: baseSSHArgs(testCtxName, testUserRoot, testWorkspaceName), + }, + { + name: "non-root workspace user", + params: baseParams(testUserVSCode), + expected: baseSSHArgs(testCtxName, testUserVSCode, testWorkspaceName), + }, + { + name: "empty user falls back to root", + params: baseParams(""), + expected: baseSSHArgs(testCtxName, testUserRoot, testWorkspaceName), }, { - name: "with debug", context: "default", workspace: "my-workspace", - debug: true, - expected: append(baseSSHArgs("default", "my-workspace"), "--debug"), + name: "with debug", + params: sshCommandArgsParams{ + clientContext: testCtxName, workspace: testWorkspaceName, + user: testUserVSCode, debug: true, + }, + expected: append( + baseSSHArgs(testCtxName, testUserVSCode, testWorkspaceName), "--debug", + ), }, { - name: "with extra args", context: "prod", workspace: "ws", - extraArgs: []string{"--stdio", "--log-output=raw"}, - expected: append(baseSSHArgs("prod", "ws"), "--stdio", "--log-output=raw"), + name: "with extra args", + params: sshCommandArgsParams{ + clientContext: "prod", workspace: "ws", user: testUserVSCode, + extraArgs: []string{"--stdio", "--log-output=raw"}, + }, + expected: append( + baseSSHArgs("prod", testUserVSCode, "ws"), "--stdio", "--log-output=raw", + ), }, { - name: "with debug and extra args", context: "default", workspace: "my-workspace", - debug: true, extraArgs: []string{"--stdio"}, - expected: append(baseSSHArgs("default", "my-workspace"), "--debug", "--stdio"), + name: "with debug and extra args", + params: sshCommandArgsParams{ + clientContext: testCtxName, workspace: testWorkspaceName, user: testUserVSCode, + debug: true, extraArgs: []string{"--stdio"}, + }, + expected: append( + baseSSHArgs(testCtxName, testUserVSCode, testWorkspaceName), "--debug", "--stdio", + ), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := buildSSHCommandArgs(tt.context, tt.workspace, tt.debug, tt.extraArgs) + got := buildSSHCommandArgs(tt.params) assert.Equal(t, tt.expected, got) }) } } +func TestBuildBackhaulCmd_UsesResolvedRemoteUser(t *testing.T) { + writer := &bytes.Buffer{} + cmd := buildBackhaulCmd(context.Background(), backhaulCmdParams{ + execPath: "/usr/bin/true", + remoteUser: testUserVSCode, + client: fakeWorkspaceClient{}, + authSockID: "sock123", + writer: writer, + }) + + joined := strings.Join(cmd.Args, " ") + assert.Contains(t, joined, "--user vscode", + "backhaul connection must use the resolved workspace user, not root, "+ + "so it doesn't fight the primary tunnel's ssh-server/setup-gpg sessions "+ + "over the shared /tmp coordination files") +} + func TestIsTransientBackhaulErr(t *testing.T) { transient := exitError(t, exitcode.Retryable) otherExit := exitError(t, 1)