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
101 changes: 87 additions & 14 deletions pkg/build/gobuild.go
Original file line number Diff line number Diff line change
Expand Up @@ -763,10 +763,12 @@ 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 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,16 @@ 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 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)
if !withinRoot(absEvalPath, absAllowedRoot) {
return fmt.Errorf("kodata symlink %q resolves to %q which is outside the allowed root %q",
hostPath, evalPath, absAllowedRoot)
}

// Get info of the symlink target.
Expand All @@ -822,7 +823,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 Down Expand Up @@ -873,18 +874,90 @@ func (g *gobuild) tarKoData(ref reference, platform *v1.Platform) (*bytes.Buffer
// 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
absAllowedRoot := 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)
absKodataRoot, err := filepath.Abs(resolvedRoot)
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, 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 withinRoot(absKodataRoot, abs) {
return abs
}
Comment thread
evilgensec marked this conversation as resolved.
return absKodataRoot
}

// withinRoot reports whether p is root itself or lies inside the root
// directory. Using filepath.Rel keeps the comparison robust against trailing
// separators (a filesystem root such as "/" or a Windows drive root like
// `C:\`) and platform-specific separators.
func withinRoot(p, root string) bool {
rel, err := filepath.Rel(root, p)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
Comment thread
evilgensec marked this conversation as resolved.

// 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
}
return enclosingGitRoot(absKodataRoot)
}

// enclosingGitRoot walks up from dir looking for a .git entry and returns the
// worktree root, or "" if dir is not inside a git checkout. It reads the
// filesystem directly rather than shelling out to git.
func enclosingGitRoot(dir string) string {
for {
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
return buf, walkRecursive(tw, root, chroot, absKodataRoot, creationTime, platform)
}

func createTemplateData(ctx context.Context, buildCtx buildContext) (map[string]any, error) {
Expand Down
160 changes: 159 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,161 @@ 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)
}
})
}
}

// TestWithinRoot covers the containment check, including a root that already
// ends in a path separator (a filesystem root such as "/"), which must not
// produce a double-separator prefix that spuriously rejects a valid path.
func TestWithinRoot(t *testing.T) {
sep := string(filepath.Separator)
tests := []struct {
name string
p string
root string
want bool
}{
{name: "equal", p: filepath.Join(sep, "a", "b"), root: filepath.Join(sep, "a", "b"), want: true},
{name: "child", p: filepath.Join(sep, "a", "b", "c"), root: filepath.Join(sep, "a", "b"), want: true},
{name: "sibling rejected", p: filepath.Join(sep, "a", "bc"), root: filepath.Join(sep, "a", "b"), want: false},
{name: "escape rejected", p: filepath.Join(sep, "etc", "passwd"), root: filepath.Join(sep, "home", "user", "repo"), want: false},
{name: "root with trailing sep does not double up", p: filepath.Join(sep, "etc", "passwd"), root: sep, want: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := withinRoot(tc.p, tc.root); got != tc.want {
t.Errorf("withinRoot(%q, %q) = %v, want %v", tc.p, tc.root, got, tc.want)
}
})
}
}

// TestResolveKodataAllowedRootGitWorktree verifies that, with no env override,
// the allowed root is widened to the enclosing git worktree (detected by a .git
// entry, without shelling out to git).
func TestResolveKodataAllowedRootGitWorktree(t *testing.T) {
t.Setenv("KO_DATA_PATH_ALLOWED_ROOT", "")
base := t.TempDir()
resolvedBase, err := filepath.EvalSymlinks(base)
if err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(resolvedBase, ".git"), 0755); err != nil {
t.Fatal(err)
}
kodataRoot := filepath.Join(resolvedBase, "cmd", "app", "kodata")
if err := os.MkdirAll(kodataRoot, 0755); err != nil {
t.Fatal(err)
}
if got := resolveKodataAllowedRoot(kodataRoot); got != resolvedBase {
t.Errorf("resolveKodataAllowedRoot(%q) = %q, want git worktree root %q", kodataRoot, got, resolvedBase)
}
}
Loading