Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

campaigns: widen permissions on mounted paths #366

Merged
merged 4 commits into from
Nov 5, 2020
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ All notable changes to `src-cli` are documented in this file.

### Fixed

- Campaign steps run in a container that does not run as root could fail on systems that do not map the running user ID to the container, most notably desktop Linux. This has been fixed: temporary files and workspaces mounted into the container now have sufficient permissions to allow the container user to execute the step. [#366](https://github.com/sourcegraph/src-cli/pull/366)

## 3.21.5

### Added
Expand Down
82 changes: 80 additions & 2 deletions internal/campaigns/archive_fetcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"path/filepath"
"strings"

"github.com/hashicorp/go-multierror"
"github.com/pkg/errors"
"github.com/sourcegraph/src-cli/internal/api"
"github.com/sourcegraph/src-cli/internal/campaigns/graphql"
Expand Down Expand Up @@ -67,6 +68,11 @@ func unzipToTempDir(ctx context.Context, zipFile, tempDir, tempFilePrefix string
if err != nil {
return "", err
}

if err := os.Chmod(volumeDir, 0777); err != nil {
LawnGnome marked this conversation as resolved.
Show resolved Hide resolved
return "", err
}

return volumeDir, unzip(zipFile, volumeDir)
}

Expand All @@ -86,6 +92,10 @@ func fetchRepositoryArchive(ctx context.Context, client api.Client, repo *graphq
return fmt.Errorf("unable to fetch archive (HTTP %d from %s)", resp.StatusCode, req.URL.String())
}

// Unlike the mkdirAll() calls elsewhere in this file, this is only giving
// us a temporary place on the filesystem to keep the archive. Since it's
// never mounted into the containers being run, we can keep these
// directories 0700 without issue.
if err := os.MkdirAll(filepath.Dir(dest), 0700); err != nil {
return err
}
Expand Down Expand Up @@ -130,13 +140,13 @@ func unzip(zipFile, dest string) error {
}

if f.FileInfo().IsDir() {
if err := os.MkdirAll(fpath, os.ModePerm); err != nil {
if err := mkdirAll(dest, f.Name, 0777); err != nil {
return err
}
continue
}

if err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
if err := mkdirAll(dest, filepath.Dir(f.Name), 0777); err != nil {
return err
}

Expand All @@ -145,6 +155,16 @@ func unzip(zipFile, dest string) error {
return err
}

// Since the container might not run as the same user, we need to ensure
// that the file is globally writable. If the execute bit is normally
// set on the zipped up file, let's ensure we propagate that to the
// group and other permission bits too.
if f.Mode()&0111 != 0 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch

outFile.Chmod(0777)
} else {
outFile.Chmod(0666)
}

rc, err := f.Open()
if err != nil {
outFile.Close()
Expand All @@ -166,3 +186,61 @@ func unzip(zipFile, dest string) error {

return nil
}

// Technically, this is a misnomer, since it might be a socket or block special,
// but errPathExistsAsNonDir is just ugly for an internal type.
type errPathExistsAsFile string

var _ error = errPathExistsAsFile("")

func (e errPathExistsAsFile) Error() string {
return fmt.Sprintf("path already exists, but not as a directory: %s", string(e))
}

// mkdirAll is essentially os.MkdirAll(filepath.Join(base, path), perm), but
// applies the given permission regardless of the user's umask.
func mkdirAll(base, path string, perm os.FileMode) error {
abs := filepath.Join(base, path)

// Create the directory if it doesn't exist.
st, err := os.Stat(abs)
if err != nil {
// It's expected that we'll get an error if the directory doesn't exist,
// so let's check that it's of the type we expect.
if !os.IsNotExist(err) {
return err
}

// Now we're clear to create the directory.
if err := os.MkdirAll(abs, perm); err != nil {
return err
}
} else if !st.IsDir() {
// The file/socket/whatever exists, but it's not a directory. That's
// definitely going to be an issue.
return errPathExistsAsFile(abs)
}

// If os.MkdirAll() was invoked earlier, then the permissions it set were
// subject to the umask. Let's walk the directories we may or may not have
// created and ensure their permissions look how we want.
return ensureAll(base, path, perm)
}

// ensureAll ensures that all directories under path have the expected
// permissions.
func ensureAll(base, path string, perm os.FileMode) error {
var errs *multierror.Error

// In plain English: for each directory in the path parameter, we should
// chmod that path to the permissions that are expected.
acc := []string{base}
for _, element := range strings.Split(path, string(os.PathSeparator)) {
acc = append(acc, element)
if err := os.Chmod(filepath.Join(acc...), perm); err != nil {
errs = multierror.Append(errs, err)
}
}

return errs.ErrorOrNil()
}
166 changes: 166 additions & 0 deletions internal/campaigns/archive_fetcher_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package campaigns

import (
"io/ioutil"
"os"
"path/filepath"
"testing"
)

func TestMkdirAll(t *testing.T) {
// TestEnsureAll does most of the heavy lifting here; we're just testing the
// MkdirAll scenarios here around whether the directory exists.

// Create a shared workspace.
base := mustCreateWorkspace(t)
defer os.RemoveAll(base)

t.Run("directory exists", func(t *testing.T) {
if err := os.MkdirAll(filepath.Join(base, "exist"), 0755); err != nil {
t.Fatal(err)
}

if err := mkdirAll(base, "exist", 0750); err != nil {
t.Errorf("unexpected non-nil error: %v", err)
}

if have := mustGetPerm(t, filepath.Join(base, "exist")); have != 0750 {
t.Errorf("unexpected permissions: have=%O want=%O", have, 0750)
}

if !isDir(t, filepath.Join(base, "exist")) {
t.Error("not a directory")
}
})

t.Run("directory does not exist", func(t *testing.T) {
if err := mkdirAll(base, "new", 0750); err != nil {
t.Errorf("unexpected non-nil error: %v", err)
}

if have := mustGetPerm(t, filepath.Join(base, "new")); have != 0750 {
t.Errorf("unexpected permissions: have=%O want=%O", have, 0750)
}

if !isDir(t, filepath.Join(base, "new")) {
t.Error("not a directory")
}
})

t.Run("directory exists, but is not a directory", func(t *testing.T) {
f, err := os.Create(filepath.Join(base, "file"))
if err != nil {
t.Fatal(err)
}
f.Close()

err = mkdirAll(base, "file", 0750)
if _, ok := err.(errPathExistsAsFile); !ok {
t.Errorf("unexpected error of type %T: %v", err, err)
}
})

t.Run("Stat generates a non-IsNotExist error", func(t *testing.T) {
// We'll create a directory and a file within it, remove the execute bit
// on the directory, and then stat() the file to cause a failure.
if err := os.MkdirAll(filepath.Join(base, "locked"), 0700); err != nil {
t.Fatal(err)
}

f, err := os.Create(filepath.Join(base, "locked", "file"))
if err != nil {
t.Fatal(err)
}
f.Close()

if err := os.Chmod(filepath.Join(base, "locked"), 0600); err != nil {
t.Fatal(err)
}

err = mkdirAll(base, "locked/file", 0750)
if err == nil {
t.Errorf("unexpected nil error")
} else if _, ok := err.(errPathExistsAsFile); ok {
t.Errorf("unexpected error of type %T: %v", err, err)
}
})
}

func TestEnsureAll(t *testing.T) {
// Create a workspace.
base := mustCreateWorkspace(t)
defer os.RemoveAll(base)

// Create three nested directories with 0700 permissions. We'll use Chmod
// explicitly to avoid any umask issues.
if err := os.MkdirAll(filepath.Join(base, "a", "b", "c"), 0700); err != nil {
t.Fatal(err)
}
dirs := []string{
filepath.Join(base, "a"),
filepath.Join(base, "a", "b"),
filepath.Join(base, "a", "b", "c"),
}
for _, dir := range dirs {
if err := os.Chmod(dir, 0700); err != nil {
t.Fatal(err)
}
}

// Now we'll set them to 0750 and see what happens.
if err := ensureAll(base, "a/b/c", 0750); err != nil {
t.Errorf("unexpected non-nil error: %v", err)
}
for _, dir := range dirs {
if have := mustGetPerm(t, dir); have != 0750 {
t.Errorf("unexpected permissions on %q: have=%O want=%O", dir, have, 0750)
}
}
if have := mustGetPerm(t, base); have != 0700 {
t.Errorf("unexpected permissions on base %q: have=%O want=%O", base, have, 0700)
}

// Finally, let's ensure we get an error when we try to ensure a directory
// that doesn't exist.
if err := ensureAll(base, "d", 0750); err == nil {
t.Errorf("unexpected nil error")
}
}

func mustCreateWorkspace(t *testing.T) string {
base, err := ioutil.TempDir("", "")
if err != nil {
t.Fatal(err)
}

// We'll explicitly set the base workspace to 0700 so we have a known
// environment for testing.
if err := os.Chmod(base, 0700); err != nil {
t.Fatal(err)
}

return base
}

func mustGetPerm(t *testing.T, file string) os.FileMode {
t.Helper()

st, err := os.Stat(file)
if err != nil {
t.Fatal(err)
}

// We really only need the lower bits here.
return st.Mode() & 0777
}

func isDir(t *testing.T, path string) bool {
t.Helper()

st, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}

return st.IsDir()
}
6 changes: 6 additions & 0 deletions internal/campaigns/run_steps.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ func runSteps(ctx context.Context, wc *WorkspaceCreator, repo *graphql.Repositor
}
defer os.Remove(runScriptFile.Name())

// This file needs to be readable within the container regardless of
// the user the container is running as.
if err := runScriptFile.Chmod(0644); err != nil {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does it not need to be executable?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Counter intuitively, no! The Docker command basically boils down to this:

docker run --entrypoint /bin/bash -- sha256:CONTAINER_ID /tmp/some-horrible-script-name

Since the shell is the entrypoint, only that needs to be executable, and the script being run is just a regular old command line parameter.

return nil, errors.Wrap(err, "setting permissions on the temporary file")
}

// Parse step.Run as a template...
tmpl, err := parseAsTemplate("step-run", step.Run, &stepContext)
if err != nil {
Expand Down