Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
98 changes: 66 additions & 32 deletions pkg/build/gobuild.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ import (
const (
defaultAppFilename = "ko-app"

defaultGoBin = "go" // defaults to first go binary found in PATH
goBinPathEnv = "KO_GO_PATH" // env lookup for optional relative or full go binary path
defaultGoBin = "go" // defaults to first go binary found in PATH
goBinPathEnv = "KO_GO_PATH" // env lookup for optional relative or full go binary path
koDataPathAllowedRootEnv = "KO_DATA_PATH_ALLOWED_ROOT" // optional override for the kodata symlink trust boundary
)

// GetBase takes an importpath and returns a base image reference and base image (or index).
Expand Down Expand Up @@ -692,7 +693,7 @@ func tarBinary(name, binary string, platform *v1.Platform, opts *layerOptions) (
return buf, nil
}

func (g *gobuild) kodataPath(ref reference) (string, error) {
func (g *gobuild) packageDir(ref reference) (string, error) {
dir := filepath.Clean(g.dir)
if dir == "." {
dir = ""
Expand All @@ -707,7 +708,51 @@ func (g *gobuild) kodataPath(ref reference) (string, error) {
if len(pkgs[0].GoFiles) == 0 {
return "", fmt.Errorf("package %s contains no Go files", pkgs[0])
}
return filepath.Join(filepath.Dir(pkgs[0].GoFiles[0]), "kodata"), nil
return filepath.Dir(pkgs[0].GoFiles[0]), nil
}

func resolveSymlinkRoot(root string) (string, error) {
resolvedRoot, err := filepath.EvalSymlinks(root)
if err != nil {
return "", fmt.Errorf("filepath.EvalSymlinks(%q): %w", root, err)
}
absRoot, err := filepath.Abs(resolvedRoot)
if err != nil {
return "", fmt.Errorf("filepath.Abs(%q): %w", resolvedRoot, err)
}
return absRoot, nil
}

func gitTopLevel(ctx context.Context, dir string) (string, error) {
/* #nosec */
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}

func resolveKODataAllowedRoot(ctx context.Context, packageDir, kodataRoot string) (string, error) {
if override := os.Getenv(koDataPathAllowedRootEnv); override != "" {
if !filepath.IsAbs(override) {
override = filepath.Join(packageDir, override)
}
return resolveSymlinkRoot(override)
}

if topLevel, err := gitTopLevel(ctx, packageDir); err == nil {
return resolveSymlinkRoot(topLevel)
}

if _, err := os.Stat(kodataRoot); err == nil {
return resolveSymlinkRoot(kodataRoot)
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("os.Stat(%q): %w", kodataRoot, err)
}

return kodataRoot, nil
}

// Where kodata lives in the image.
Expand Down Expand Up @@ -763,10 +808,10 @@ func writeFileToTar(tw *tar.Writer, name, evalPath string, size int64, modTime t
// walkRecursive performs a filepath.Walk of the given root directory adding it
// to the provided tar.Writer with root -> chroot. All symlinks are dereferenced,
// which is what leads to recursion when we encounter a directory symlink.
// absKodataRoot is the absolute path of the original kodata directory; symlinks
// that resolve to a path outside of it are rejected to prevent arbitrary host
// files from being packed into the container image.
func walkRecursive(tw *tar.Writer, root, chroot, absKodataRoot string, creationTime v1.Time, platform *v1.Platform) error {
// absAllowedRoot is the absolute path of the trusted boundary for resolved
// symlinks; targets outside of it are rejected to prevent arbitrary host files
// from being packed into the container image.
func walkRecursive(tw *tar.Writer, root, chroot, absAllowedRoot string, creationTime v1.Time, platform *v1.Platform) error {
return filepath.Walk(root, func(hostPath string, info os.FileInfo, err error) error {
if hostPath == root {
return nil
Expand Down Expand Up @@ -798,17 +843,17 @@ func walkRecursive(tw *tar.Writer, root, chroot, absKodataRoot string, creationT
return fmt.Errorf("filepath.EvalSymlinks(%q): %w", hostPath, err)
}

// Verify the resolved path remains within the kodata root. A symlink
// pointing outside the kodata directory would otherwise cause ko to pack
// Verify the resolved path remains within the allowed root. A symlink
// pointing outside the allowed root would otherwise cause ko to pack
// arbitrary host files (e.g. ~/.ssh/id_rsa, /etc/passwd) into the image.
absEvalPath, err := filepath.Abs(evalPath)
if err != nil {
return fmt.Errorf("filepath.Abs(%q): %w", evalPath, err)
}
absKodataRootWithSep := absKodataRoot + string(filepath.Separator)
if absEvalPath != absKodataRoot && !strings.HasPrefix(absEvalPath, absKodataRootWithSep) {
return fmt.Errorf("kodata symlink %q resolves to %q which is outside the kodata root %q",
hostPath, evalPath, absKodataRoot)
absAllowedRootWithSep := absAllowedRoot + string(filepath.Separator)
if absEvalPath != absAllowedRoot && !strings.HasPrefix(absEvalPath, absAllowedRootWithSep) {
return fmt.Errorf("kodata symlink %q resolves to %q which is outside the allowed root %q",
hostPath, evalPath, absAllowedRoot)
Comment thread
imjasonh marked this conversation as resolved.
}

// Get info of the symlink target.
Expand All @@ -822,7 +867,7 @@ func walkRecursive(tw *tar.Writer, root, chroot, absKodataRoot string, creationT
if err := writeDirToTar(tw, newPath, creationTime.Time); err != nil {
return fmt.Errorf("writing dir %q to tar: %w", newPath, err)
}
return walkRecursive(tw, evalPath, newPath, absKodataRoot, creationTime, platform)
return walkRecursive(tw, evalPath, newPath, absAllowedRoot, creationTime, platform)
}

// Regular file (or symlink to file): write to tar.
Expand All @@ -835,10 +880,11 @@ func (g *gobuild) tarKoData(ref reference, platform *v1.Platform) (*bytes.Buffer
tw := tar.NewWriter(buf)
defer tw.Close()

root, err := g.kodataPath(ref)
packageDir, err := g.packageDir(ref)
if err != nil {
return nil, err
}
root := filepath.Join(packageDir, "kodata")

creationTime := g.kodataCreationTime

Expand Down Expand Up @@ -868,23 +914,11 @@ func (g *gobuild) tarKoData(ref reference, platform *v1.Platform) (*bytes.Buffer
}
}

// Resolve the canonical absolute path of the kodata root so that symlink
// targets resolved by filepath.EvalSymlinks can be compared consistently.
// If the kodata directory does not exist, walkRecursive handles that
// gracefully (filepath.Walk skips missing roots), so we fall back to the
// raw path as the boundary — no traversal is possible without a root.
absKodataRoot := root
if _, statErr := os.Stat(root); statErr == nil {
resolvedRoot, err := filepath.EvalSymlinks(root)
if err != nil {
return nil, fmt.Errorf("filepath.EvalSymlinks(%q): %w", root, err)
}
absKodataRoot, err = filepath.Abs(resolvedRoot)
if err != nil {
return nil, fmt.Errorf("filepath.Abs(%q): %w", resolvedRoot, err)
}
absAllowedRoot, err := resolveKODataAllowedRoot(g.ctx, packageDir, root)
if err != nil {
return nil, err
}
return buf, walkRecursive(tw, root, chroot, absKodataRoot, creationTime, platform)
return buf, walkRecursive(tw, root, chroot, absAllowedRoot, creationTime, platform)
}

func createTemplateData(ctx context.Context, buildCtx buildContext) (map[string]any, error) {
Expand Down
115 changes: 95 additions & 20 deletions pkg/build/gobuild_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1710,11 +1710,15 @@ func TestDebugger(t *testing.T) {
}

// TestWalkRecursiveSymlinkTraversal verifies that walkRecursive rejects symlinks
// in kodata/ that point outside the kodata root, preventing host files from being
// in kodata/ that point outside the allowed root, preventing host files from being
// packed into the container image.
func TestWalkRecursiveSymlinkTraversal(t *testing.T) {
// Build a temp kodata dir with a symlink that escapes it.
kodataDir := t.TempDir()
// Build a temp repo with a kodata dir containing a symlink that escapes it.
repoDir := t.TempDir()
kodataDir := filepath.Join(repoDir, "kodata")
Comment thread
imjasonh marked this conversation as resolved.
if err := os.Mkdir(kodataDir, 0o755); err != nil {
t.Fatal(err)
}
outsideDir := t.TempDir()

// Write a sensitive file outside kodata.
Expand All @@ -1732,11 +1736,11 @@ func TestWalkRecursiveSymlinkTraversal(t *testing.T) {
t.Fatal(err)
}

resolvedKodata, err := filepath.EvalSymlinks(kodataDir)
resolvedRepo, err := filepath.EvalSymlinks(repoDir)
if err != nil {
t.Fatal(err)
}
absKodataRoot, err := filepath.Abs(resolvedKodata)
absAllowedRoot, err := filepath.Abs(resolvedRepo)
if err != nil {
t.Fatal(err)
}
Expand All @@ -1747,37 +1751,41 @@ func TestWalkRecursiveSymlinkTraversal(t *testing.T) {
platform := &v1.Platform{OS: "linux", Architecture: "amd64"}
creationTime := v1.Time{}

err = walkRecursive(tw, kodataDir, "/var/run/ko", absKodataRoot, creationTime, platform)
err = walkRecursive(tw, kodataDir, "/var/run/ko", absAllowedRoot, creationTime, platform)
if err == nil {
t.Error("walkRecursive: expected error for symlink escaping kodata root, got nil")
} else if !strings.Contains(err.Error(), "outside the kodata root") {
t.Error("walkRecursive: expected error for symlink escaping allowed root, got nil")
} else if !strings.Contains(err.Error(), "outside the allowed root") {
t.Errorf("walkRecursive: unexpected error message: %v", err)
}
}

// TestWalkRecursiveSymlinkWithinKodata verifies that symlinks within kodata are
// still followed correctly after the traversal check is applied.
func TestWalkRecursiveSymlinkWithinKodata(t *testing.T) {
kodataDir := t.TempDir()
// TestWalkRecursiveSymlinkWithinRepo verifies that in-repo symlinks outside the
// kodata directory are still followed when the repository root is trusted.
func TestWalkRecursiveSymlinkWithinRepo(t *testing.T) {
repoDir := t.TempDir()
kodataDir := filepath.Join(repoDir, "kodata")
if err := os.Mkdir(kodataDir, 0o755); err != nil {
t.Fatal(err)
}

// Write a regular file and a symlink to it, both inside kodata.
target := filepath.Join(kodataDir, "target.txt")
// Write a regular file outside kodata but within the same repo and a symlink to it.
target := filepath.Join(repoDir, "LICENSE")
if err := os.WriteFile(target, []byte("hello"), 0644); err != nil {
t.Fatal(err)
}
link := filepath.Join(kodataDir, "link.txt")
if err := os.Symlink(target, link); err != nil {
if err := os.Symlink(filepath.Join("..", "LICENSE"), link); err != nil {
if runtime.GOOS == "windows" {
t.Skipf("skipping symlink within-kodata test on Windows: %v", err)
t.Skipf("skipping symlink within-repo test on Windows: %v", err)
}
t.Fatal(err)
}

resolvedKodata, err := filepath.EvalSymlinks(kodataDir)
resolvedRepo, err := filepath.EvalSymlinks(repoDir)
if err != nil {
t.Fatal(err)
}
absKodataRoot, err := filepath.Abs(resolvedKodata)
absAllowedRoot, err := filepath.Abs(resolvedRepo)
if err != nil {
t.Fatal(err)
}
Expand All @@ -1788,8 +1796,8 @@ func TestWalkRecursiveSymlinkWithinKodata(t *testing.T) {
platform := &v1.Platform{OS: "linux", Architecture: "amd64"}
creationTime := v1.Time{}

if err := walkRecursive(tw, kodataDir, "/var/run/ko", absKodataRoot, creationTime, platform); err != nil {
t.Fatalf("walkRecursive: unexpected error for in-kodata symlink: %v", err)
if err := walkRecursive(tw, kodataDir, "/var/run/ko", absAllowedRoot, creationTime, platform); err != nil {
t.Fatalf("walkRecursive: unexpected error for in-repo symlink: %v", err)
}
tw.Close()

Expand Down Expand Up @@ -1821,3 +1829,70 @@ func TestWalkRecursiveSymlinkWithinKodata(t *testing.T) {
t.Errorf("expected %q in tar archive, not found", wantPath)
}
}

func TestResolveKODataAllowedRoot(t *testing.T) {
t.Run("env override", func(t *testing.T) {
packageDir := t.TempDir()
allowedRoot := filepath.Join(packageDir, "shared")
if err := os.Mkdir(allowedRoot, 0o755); err != nil {
t.Fatal(err)
}
t.Setenv(koDataPathAllowedRootEnv, "shared")

got, err := resolveKODataAllowedRoot(context.Background(), packageDir, filepath.Join(packageDir, "kodata"))
if err != nil {
t.Fatalf("resolveKODataAllowedRoot() = %v", err)
}

want, err := filepath.Abs(allowedRoot)
if err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("resolveKODataAllowedRoot() = %q, want %q", got, want)
}
})

t.Run("git top level", func(t *testing.T) {
repoDir := t.TempDir()
gittesting.GitInit(t, repoDir)
packageDir := filepath.Join(repoDir, "cmd", "app")
if err := os.MkdirAll(packageDir, 0o755); err != nil {
t.Fatal(err)
}

got, err := resolveKODataAllowedRoot(context.Background(), packageDir, filepath.Join(packageDir, "kodata"))
if err != nil {
t.Fatalf("resolveKODataAllowedRoot() = %v", err)
}

want, err := filepath.Abs(repoDir)
if err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("resolveKODataAllowedRoot() = %q, want %q", got, want)
}
})

t.Run("kodata fallback", func(t *testing.T) {
packageDir := t.TempDir()
kodataDir := filepath.Join(packageDir, "kodata")
if err := os.Mkdir(kodataDir, 0o755); err != nil {
t.Fatal(err)
}

got, err := resolveKODataAllowedRoot(context.Background(), packageDir, kodataDir)
if err != nil {
t.Fatalf("resolveKODataAllowedRoot() = %v", err)
}

want, err := filepath.Abs(kodataDir)
if err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("resolveKODataAllowedRoot() = %q, want %q", got, want)
}
})
}
Loading