Skip to content
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
75 changes: 63 additions & 12 deletions pkg/build/gobuild.go
Original file line number Diff line number Diff line change
Expand Up @@ -763,10 +763,12 @@
// 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 directory tree within which
// symlinks are permitted to resolve; symlinks that resolve to a path outside of
// it are rejected to prevent arbitrary host files from being packed into the
// container image. It defaults to the kodata root but may be widened to the
// enclosing source tree (see resolveKodataAllowedRoot).
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 +800,17 @@
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 source tree 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
evilgensec marked this conversation as resolved.
Outdated

// Get info of the symlink target.
Expand All @@ -822,7 +824,7 @@
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 Down Expand Up @@ -873,7 +875,8 @@
// 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

Check failure on line 878 in pkg/build/gobuild.go

View workflow job for this annotation

GitHub Actions / lint

ineffectual assignment to absKodataRoot (ineffassign)
Comment thread
evilgensec marked this conversation as resolved.
Outdated
absAllowedRoot := root
if _, statErr := os.Stat(root); statErr == nil {
resolvedRoot, err := filepath.EvalSymlinks(root)
if err != nil {
Expand All @@ -883,8 +886,56 @@
if err != nil {
return nil, fmt.Errorf("filepath.Abs(%q): %w", resolvedRoot, err)
}
// Widen the boundary from the kodata directory to the enclosing source
// tree so that in-repo symlinks (a top-level LICENSE, shared config
// outside the command root, etc.) keep working, while symlinks that
// escape the project entirely are still rejected.
absAllowedRoot = resolveKodataAllowedRoot(absKodataRoot)
}
return buf, walkRecursive(tw, root, chroot, absKodataRoot, creationTime, platform)
return buf, walkRecursive(tw, root, chroot, absAllowedRoot, creationTime, platform)
}

// resolveKodataAllowedRoot returns the directory tree within which kodata
// symlinks are permitted to resolve. By default this is the kodata root
// itself, but it is widened to the enclosing source tree so that symlinks to
// files elsewhere in the project continue to work while still blocking symlinks
// that escape the project entirely.
//
// The widened root is taken from the KO_DATA_PATH_ALLOWED_ROOT environment
// variable when set, otherwise from the enclosing git worktree. The candidate
// is only used when it is an ancestor of (or equal to) the kodata root, so the
// boundary can never become narrower than the kodata directory.
func resolveKodataAllowedRoot(absKodataRoot string) string {
candidate := kodataAllowedRootCandidate(absKodataRoot)
if candidate == "" {
return absKodataRoot
}
abs, err := filepath.Abs(candidate)
if err != nil {
return absKodataRoot
}
// Canonicalize so the comparison matches absKodataRoot, which was resolved
// with EvalSymlinks (e.g. /var -> /private/var on macOS).
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
abs = resolved
}
if abs == absKodataRoot || strings.HasPrefix(absKodataRoot, abs+string(filepath.Separator)) {
return abs
}
Comment thread
evilgensec marked this conversation as resolved.
Outdated
Comment thread
evilgensec marked this conversation as resolved.
return absKodataRoot
}

// kodataAllowedRootCandidate returns the caller-supplied or git-derived
// candidate for the widened symlink boundary, or "" when none is available.
func kodataAllowedRootCandidate(absKodataRoot string) string {
if env := os.Getenv("KO_DATA_PATH_ALLOWED_ROOT"); env != "" {
return env
}
out, err := exec.Command("git", "-C", absKodataRoot, "rev-parse", "--show-toplevel").Output()
Comment thread
evilgensec marked this conversation as resolved.
Outdated
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}

func createTemplateData(ctx context.Context, buildCtx buildContext) (map[string]any, error) {
Expand Down
112 changes: 111 additions & 1 deletion pkg/build/gobuild_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1750,7 +1750,7 @@ func TestWalkRecursiveSymlinkTraversal(t *testing.T) {
err = walkRecursive(tw, kodataDir, "/var/run/ko", absKodataRoot, 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") {
} else if !strings.Contains(err.Error(), "outside the allowed root") {
t.Errorf("walkRecursive: unexpected error message: %v", err)
}
}
Expand Down Expand Up @@ -1821,3 +1821,113 @@ func TestWalkRecursiveSymlinkWithinKodata(t *testing.T) {
t.Errorf("expected %q in tar archive, not found", wantPath)
}
}

// TestWalkRecursiveSymlinkWithinAllowedRoot verifies that a symlink in kodata
// pointing outside the kodata directory but still inside the widened allowed
// root (e.g. a top-level LICENSE in the project) is followed correctly.
func TestWalkRecursiveSymlinkWithinAllowedRoot(t *testing.T) {
// repoDir is the widened allowed root; kodata lives beneath it.
repoDir := t.TempDir()
kodataDir := filepath.Join(repoDir, "cmd", "app", "kodata")
if err := os.MkdirAll(kodataDir, 0755); err != nil {
t.Fatal(err)
}

// A file inside the repo but outside kodata (the case evankanderson raised).
Comment thread
evilgensec marked this conversation as resolved.
licenseFile := filepath.Join(repoDir, "LICENSE")
if err := os.WriteFile(licenseFile, []byte("license"), 0644); err != nil {
t.Fatal(err)
}

// Symlink inside kodata pointing at the in-repo, out-of-kodata file.
link := filepath.Join(kodataDir, "LICENSE")
if err := os.Symlink(licenseFile, link); err != nil {
if runtime.GOOS == "windows" {
t.Skipf("skipping symlink within-allowed-root test on Windows: %v", err)
}
t.Fatal(err)
}

resolvedRepo, err := filepath.EvalSymlinks(repoDir)
if err != nil {
t.Fatal(err)
}
absAllowedRoot, err := filepath.Abs(resolvedRepo)
if err != nil {
t.Fatal(err)
}

var buf bytes.Buffer
tw := tar.NewWriter(&buf)
defer tw.Close()
platform := &v1.Platform{OS: "linux", Architecture: "amd64"}
creationTime := v1.Time{}

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()

wantPath := path.Join("/var/run/ko", "LICENSE")
found := false
tr := tar.NewReader(&buf)
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("tar.Reader.Next(): %v", err)
}
if hdr.Name != wantPath {
continue
}
found = true
body, err := io.ReadAll(tr)
if err != nil {
t.Fatalf("io.ReadAll: %v", err)
}
if got, want := string(body), "license"; got != want {
t.Errorf("LICENSE content = %q, want %q", got, want)
}
}
if !found {
t.Errorf("expected %q in tar archive, not found", wantPath)
}
}

// TestResolveKodataAllowedRoot verifies the boundary is widened to an ancestor
// candidate (env override) but never narrowed below the kodata root.
func TestResolveKodataAllowedRoot(t *testing.T) {
base := t.TempDir()
resolvedBase, err := filepath.EvalSymlinks(base)
if err != nil {
t.Fatal(err)
}
kodataRoot := filepath.Join(resolvedBase, "cmd", "app", "kodata")
if err := os.MkdirAll(kodataRoot, 0755); err != nil {
t.Fatal(err)
}

// An ancestor directory that does not contain kodata (non-ancestor case).
unrelated := t.TempDir()

tests := []struct {
name string
env string
want string
}{
{name: "no override falls back to kodata root", env: "", want: kodataRoot},
{name: "ancestor override widens boundary", env: resolvedBase, want: resolvedBase},
{name: "non-ancestor override ignored", env: unrelated, want: kodataRoot},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("KO_DATA_PATH_ALLOWED_ROOT", tc.env)
got := resolveKodataAllowedRoot(kodataRoot)
if got != tc.want {
t.Errorf("resolveKodataAllowedRoot(%q) = %q, want %q", kodataRoot, got, tc.want)
}
})
}
}
Loading