High severity7.5GHSA Advisory· Published May 9, 2026· Updated May 13, 2026
CVE-2026-42574
CVE-2026-42574
Description
apko allows users to build and publish OCI container images built from apk packages. From version 0.14.8 to before version 1.2.5, a crafted .apk could install a TypeSymlink tar entry whose target pointed outside the build root, and a subsequent directory-creation or file-write entry in the same or later archive could traverse that symlink to reach host paths the build user could write to. This issue has been patched in version 1.2.5.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
chainguard.dev/apkoGo | >= 0.14.8, < 1.2.5 | 1.2.5 |
Affected products
1- Range: >= 0.14.8, < 1.2.5
Patches
1f5a96e1299acfs: Scope all DirFS operations through os.Root (#2187)
5 files changed · +766 −255
pkg/apk/apk/path_traversal_test.go+251 −0 modified@@ -88,6 +88,257 @@ func TestPathTraversalHardlink(t *testing.T) { } } +// TestSymlinkEscape_FileThroughAbsoluteSymlink covers the classic symlink-escape +// shape: a malicious APK plants a symlink inside the image whose target is an +// absolute path pointing outside the rootfs, then a regular file whose tar +// header name traverses that symlink. The install must fail and the outside +// path must remain untouched. +func TestSymlinkEscape_FileThroughAbsoluteSymlink(t *testing.T) { + ctx := t.Context() + + sandbox := t.TempDir() + base := filepath.Join(sandbox, "base") + outsideDir := filepath.Join(sandbox, "outside") + outsideFile := filepath.Join(outsideDir, "pwned") + if err := os.MkdirAll(outsideDir, 0o755); err != nil { + t.Fatal(err) + } + + fsys := apkfs.DirFS(ctx, base, apkfs.WithCreateDir()) + if fsys == nil { + t.Fatalf("failed to create dirfs for base %s", base) + } + + a, err := New(ctx, WithFS(fsys)) + if err != nil { + t.Fatalf("apk.New: %v", err) + } + + r, err := makeSymlinkThenFileTar("evil", outsideDir, "evil/pwned", []byte("malicious")) + if err != nil { + t.Fatalf("makeSymlinkThenFileTar: %v", err) + } + + if _, err := a.installAPKFiles(ctx, r, &Package{}); err == nil { + t.Fatalf("expected installAPKFiles to fail, but it succeeded") + } + + if _, statErr := os.Stat(outsideFile); statErr == nil { + t.Fatalf("expected %s to not exist after fix", outsideFile) + } +} + +// TestSymlinkEscape_FileThroughRelativeSymlink is the ../outside variant. +func TestSymlinkEscape_FileThroughRelativeSymlink(t *testing.T) { + ctx := t.Context() + + sandbox := t.TempDir() + base := filepath.Join(sandbox, "base") + outsideDir := filepath.Join(sandbox, "outside") + outsideFile := filepath.Join(outsideDir, "pwned") + if err := os.MkdirAll(outsideDir, 0o755); err != nil { + t.Fatal(err) + } + + fsys := apkfs.DirFS(ctx, base, apkfs.WithCreateDir()) + if fsys == nil { + t.Fatalf("failed to create dirfs for base %s", base) + } + + a, err := New(ctx, WithFS(fsys)) + if err != nil { + t.Fatalf("apk.New: %v", err) + } + + r, err := makeSymlinkThenFileTar("evil", "../outside", "evil/pwned", []byte("malicious")) + if err != nil { + t.Fatalf("makeSymlinkThenFileTar: %v", err) + } + + if _, err := a.installAPKFiles(ctx, r, &Package{}); err == nil { + t.Fatalf("expected installAPKFiles to fail, but it succeeded") + } + + if _, statErr := os.Stat(outsideFile); statErr == nil { + t.Fatalf("expected %s to not exist after fix", outsideFile) + } +} + +// TestSymlinkEscape_MkdirAllThroughSymlink plants a symlink whose target is an +// outside directory, then a TypeDir entry traversing the symlink. MkdirAll +// must not merge new dirs into the outside location. +func TestSymlinkEscape_MkdirAllThroughSymlink(t *testing.T) { + ctx := t.Context() + + sandbox := t.TempDir() + base := filepath.Join(sandbox, "base") + outsideDir := filepath.Join(sandbox, "outside") + outsideSub := filepath.Join(outsideDir, "sub") + if err := os.MkdirAll(outsideDir, 0o755); err != nil { + t.Fatal(err) + } + + fsys := apkfs.DirFS(ctx, base, apkfs.WithCreateDir()) + if fsys == nil { + t.Fatalf("failed to create dirfs for base %s", base) + } + + a, err := New(ctx, WithFS(fsys)) + if err != nil { + t.Fatalf("apk.New: %v", err) + } + + r, err := makeSymlinkThenDirTar("evil", outsideDir, "evil/sub") + if err != nil { + t.Fatalf("makeSymlinkThenDirTar: %v", err) + } + + if _, err := a.installAPKFiles(ctx, r, &Package{}); err == nil { + t.Fatalf("expected installAPKFiles to fail, but it succeeded") + } + + if _, statErr := os.Stat(outsideSub); statErr == nil { + t.Fatalf("expected %s to not exist after fix", outsideSub) + } +} + +// TestSymlinkEscape_HardlinkThroughSymlink validates the hardlink path: the +// prior GHSA guarded target-side escapes, but the newname side could still be +// redirected through an attacker-planted symlink. +func TestSymlinkEscape_HardlinkThroughSymlink(t *testing.T) { + ctx := t.Context() + + sandbox := t.TempDir() + base := filepath.Join(sandbox, "base") + outsideDir := filepath.Join(sandbox, "outside") + outsideLinked := filepath.Join(outsideDir, "linked") + if err := os.MkdirAll(outsideDir, 0o755); err != nil { + t.Fatal(err) + } + + fsys := apkfs.DirFS(ctx, base, apkfs.WithCreateDir()) + if fsys == nil { + t.Fatalf("failed to create dirfs for base %s", base) + } + + a, err := New(ctx, WithFS(fsys)) + if err != nil { + t.Fatalf("apk.New: %v", err) + } + + r, err := makeHardlinkThroughSymlinkTar("legit", "evil", outsideDir, "evil/linked") + if err != nil { + t.Fatalf("makeHardlinkThroughSymlinkTar: %v", err) + } + + if _, err := a.installAPKFiles(ctx, r, &Package{}); err == nil { + t.Fatalf("expected installAPKFiles to fail, but it succeeded") + } + + if _, statErr := os.Lstat(outsideLinked); statErr == nil { + t.Fatalf("expected %s to not exist after fix", outsideLinked) + } +} + +func makeSymlinkThenFileTar(symlinkName, symlinkTarget, fileName string, content []byte) (*bytes.Reader, error) { + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + + if err := tw.WriteHeader(&tar.Header{ + Name: symlinkName, + Linkname: symlinkTarget, + Typeflag: tar.TypeSymlink, + Mode: 0o777, + }); err != nil { + return nil, err + } + + if err := tw.WriteHeader(&tar.Header{ + Name: fileName, + Typeflag: tar.TypeReg, + Mode: 0o644, + Size: int64(len(content)), + }); err != nil { + return nil, err + } + if _, err := tw.Write(content); err != nil { + return nil, err + } + + if err := tw.Close(); err != nil { + return nil, err + } + return bytes.NewReader(buf.Bytes()), nil +} + +func makeSymlinkThenDirTar(symlinkName, symlinkTarget, dirName string) (*bytes.Reader, error) { + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + + if err := tw.WriteHeader(&tar.Header{ + Name: symlinkName, + Linkname: symlinkTarget, + Typeflag: tar.TypeSymlink, + Mode: 0o777, + }); err != nil { + return nil, err + } + + if err := tw.WriteHeader(&tar.Header{ + Name: dirName, + Typeflag: tar.TypeDir, + Mode: 0o755, + }); err != nil { + return nil, err + } + + if err := tw.Close(); err != nil { + return nil, err + } + return bytes.NewReader(buf.Bytes()), nil +} + +func makeHardlinkThroughSymlinkTar(regularName, symlinkName, symlinkTarget, hardlinkName string) (*bytes.Reader, error) { + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + + content := []byte("legitimate") + if err := tw.WriteHeader(&tar.Header{ + Name: regularName, + Typeflag: tar.TypeReg, + Mode: 0o644, + Size: int64(len(content)), + }); err != nil { + return nil, err + } + if _, err := tw.Write(content); err != nil { + return nil, err + } + + if err := tw.WriteHeader(&tar.Header{ + Name: symlinkName, + Linkname: symlinkTarget, + Typeflag: tar.TypeSymlink, + Mode: 0o777, + }); err != nil { + return nil, err + } + + if err := tw.WriteHeader(&tar.Header{ + Name: hardlinkName, + Linkname: regularName, + Typeflag: tar.TypeLink, + Mode: 0o644, + }); err != nil { + return nil, err + } + + if err := tw.Close(); err != nil { + return nil, err + } + return bytes.NewReader(buf.Bytes()), nil +} + func makeTestTar(dirName, symlinkName, symlinkTarget string) (*bytes.Reader, error) { var buf bytes.Buffer tw := tar.NewWriter(&buf)
pkg/apk/fs/rwosfs.go+152 −165 modified@@ -16,10 +16,12 @@ package fs import ( "context" + "errors" "fmt" "io/fs" "os" "path/filepath" + "runtime" "sort" "strings" "sync" @@ -93,29 +95,33 @@ func DirFS(ctx context.Context, dir string, opts ...DirFSOption) FullFS { return nil } + root, err := os.OpenRoot(dir) + if err != nil { + log.Warn("error opening root", "error", err) + return nil + } + var caseSensitive bool if options.caseSensitiveSet { caseSensitive = options.caseSensitive } else { - // check if the underlying filesystem is case-sensitive - // we cannot just use it in TempDir() because these might be different filesystems - // find a file that does not exist + // Probe the underlying filesystem through the root so the probe is + // subject to the same sandboxing as every other write. We cannot reuse + // the caller's TempDir because it might be on a different filesystem. for i := 0; ; i++ { filename := fmt.Sprintf("test-dirfs-%d", i) - // filepath.Join below here is considered safe since we control filename - if _, err := os.Stat(filepath.Join(dir, filename)); err == nil { + if _, err := root.Stat(filename); err == nil { continue } - if err := os.WriteFile(filepath.Join(dir, filename), []byte("test"), 0o600); err != nil { + if err := root.WriteFile(filename, []byte("test"), 0o600); err != nil { caseSensitive = false // If this fails, let's just assume it's not case sensitive. break } - // see if it exists - if _, err := os.Stat(filepath.Join(dir, strings.ToUpper(filename))); err != nil { + if _, err := root.Stat(strings.ToUpper(filename)); err != nil { caseSensitive = true } // clean up our own messes - _ = os.Remove(filepath.Join(dir, filename)) + _ = root.Remove(filename) break } } @@ -126,13 +132,19 @@ func DirFS(ctx context.Context, dir string, opts ...DirFSOption) FullFS { } f := &dirFS{ base: dir, + root: root, overrides: m, caseMap: caseMap, } - // need to populate the overrides with appropriate info - root := os.DirFS(dir) - - _ = fs.WalkDir(root, ".", func(path string, d fs.DirEntry, err error) error { + // Safety net for the library-consumer case where the dirFS is stashed + // inside another type (e.g. apk.New's fallback) and never reachable for + // explicit Close. Stopped by Close when it runs deterministically. + f.cleanup = runtime.AddCleanup(f, func(r *os.Root) { _ = r.Close() }, root) + // Seed overrides by walking the backing tree through the root. This + // refuses to follow any pre-existing symlink whose target escapes base, + // and root.Readlink gives us consistent sandbox semantics with the rest + // of the dirFS API surface. + _ = fs.WalkDir(root.FS(), ".", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } @@ -151,11 +163,7 @@ func DirFS(ctx context.Context, dir string, opts ...DirFSOption) FullFS { err = f.overrides.Mkdir(path, fullPerm) case fs.ModeSymlink: var target string - target, err = f.sanitizePath(path) - if err != nil { - return err - } - target, err = os.Readlink(target) + target, err = root.Readlink(path) if err == nil { err = f.overrides.Symlink(target, path) } @@ -201,6 +209,19 @@ func DirFS(ctx context.Context, dir string, opts ...DirFSOption) FullFS { // else in memory. type dirFS struct { base string + // root is a capability-style handle scoped to base. All disk operations on + // caller-supplied paths go through it so path resolution can never escape + // base via symlinks, hard links, absolute path components, or "..". + root *os.Root + // cleanup releases the root FD on garbage collection as a safety net for + // callers that can't reach Close() (notably the apk.New fallback which + // stashes the FullFS inside *APK). Deterministic release via Close() is + // still preferred. + // + // TODO: revisit and replace with `Close() error` on the FullFS interface + // once we're willing to take the breaking change across in-tree + // implementers and library consumers. + cleanup runtime.Cleanup // overrides is a map of overrides for things that could not be kept on disk because of permission, // filesystem or operating system limitations. // It will include all directories, but no file contents. @@ -212,6 +233,19 @@ type dirFS struct { caseMapMutex sync.Mutex } +// Close releases the underlying *os.Root file descriptor. A GC cleanup is also +// registered as a safety net for consumers who cannot reach this method. +// Calling Close more than once is safe. +func (f *dirFS) Close() error { + if f.root == nil { + return nil + } + f.cleanup.Stop() + err := f.root.Close() + f.root = nil + return err +} + func (f *dirFS) Readlink(name string) (string, error) { // The underlying filesystem might not support symlinks, and it might be case-insensitive, so just // use the one in memory. @@ -232,42 +266,41 @@ func (f *dirFS) Open(name string) (fs.File, error) { } func (f *dirFS) open(name string) (*fileImpl, error) { - fullpath, err := f.sanitizePath(name) - if err != nil { - return nil, err - } + rel := f.relPath(name) baseName := filepath.Base(name) if f.caseSensitiveOnDisk(name) { - file, err := os.Open(fullpath) + file, err := f.root.Open(rel) if err == nil { return &fileImpl{ - file: file, - name: baseName, - fullpath: fullpath, + file: file, + name: baseName, + root: f.root, + rel: rel, }, nil } if !os.IsPermission(err) { return nil, err } // get the original permissions - fi, err := os.Stat(fullpath) + fi, err := f.root.Stat(rel) if err != nil { - return nil, fmt.Errorf("unable to stat file %s: %w", fullpath, err) + return nil, fmt.Errorf("unable to stat file %s: %w", name, err) } // Try to change permissions and open again. - if err := os.Chmod(fullpath, 0o600); err != nil { + if err := f.root.Chmod(rel, 0o600); err != nil { return nil, fmt.Errorf("unable to read file or change permissions: %s", name) } - file, err = os.Open(fullpath) + file, err = f.root.Open(rel) if err != nil { return nil, fmt.Errorf("unable to read file even after change permissions: %s", name) } perms := fi.Mode() return &fileImpl{ - file: file, - name: baseName, - fullpath: fullpath, - perms: &perms, + file: file, + name: baseName, + root: f.root, + rel: rel, + perms: &perms, }, nil } @@ -296,22 +329,14 @@ func (f *dirFS) OpenFile(name string, flag int, perm fs.FileMode) (File, error) // do we create it on disk? if f.createOnDisk(name) { _ = file.Close() - fullpath, err := f.sanitizePath(name) - if err != nil { - return nil, err - } - file, err = os.OpenFile(fullpath, flag, perm) + file, err = f.root.OpenFile(f.relPath(name), flag, perm) if err != nil { return nil, err } } } else { if f.caseSensitiveOnDisk(name) { - fullpath, sanErr := f.sanitizePath(name) - if sanErr != nil { - return nil, sanErr - } - file, err = os.OpenFile(fullpath, flag, perm) + file, err = f.root.OpenFile(f.relPath(name), flag, perm) } else { file, err = f.overrides.OpenFile(name, flag, perm) } @@ -336,11 +361,7 @@ func (f *dirFS) Stat(name string) (fs.FileInfo, error) { return nil, err } if f.caseSensitiveOnDisk(name) { - fullpath, err := f.sanitizePath(name) - if err != nil { - return nil, err - } - fi, err = os.Stat(fullpath) + fi, err = f.root.Stat(f.relPath(name)) if err != nil { return nil, err } @@ -371,11 +392,7 @@ func (f *dirFS) Create(name string) (File, error) { if f.createOnDisk(name) { // close the memory one _ = file.Close() - fullpath, err := f.sanitizePath(name) - if err != nil { - return nil, err - } - file, err = os.Create(fullpath) + file, err = f.root.Create(f.relPath(name)) if err != nil { return nil, err } @@ -389,11 +406,7 @@ func (f *dirFS) Remove(name string) error { return err } if f.removeOnDisk(name) { - fullpath, err := f.sanitizePath(name) - if err != nil { - return err - } - return os.Remove(fullpath) + return f.root.Remove(f.relPath(name)) } return nil } @@ -405,11 +418,14 @@ func (f *dirFS) ReadDir(name string) ([]fs.DirEntry, error) { err error ) if f.caseSensitiveOnDisk(name) { - fullpath, err := f.sanitizePath(name) + // *os.Root does not expose ReadDir; open the directory through it and + // read entries from the returned *os.File. + dir, err := f.root.Open(f.relPath(name)) if err != nil { return nil, err } - onDisk, err = os.ReadDir(fullpath) + onDisk, err = dir.ReadDir(-1) + _ = dir.Close() if err != nil { return nil, err } @@ -447,21 +463,13 @@ func (f *dirFS) ReadDir(name string) ([]fs.DirEntry, error) { } func (f *dirFS) ReadFile(name string) ([]byte, error) { if f.caseSensitiveOnDisk(name) { - fullpath, err := f.sanitizePath(name) - if err != nil { - return nil, err - } - return os.ReadFile(fullpath) + return f.root.ReadFile(f.relPath(name)) } return f.overrides.ReadFile(name) } func (f *dirFS) WriteFile(name string, b []byte, mode fs.FileMode) error { if f.createOnDisk(name) { - fullpath, err := f.sanitizePath(name) - if err != nil { - return err - } - if err := os.WriteFile(fullpath, b, mode); err != nil { + if err := f.root.WriteFile(f.relPath(name), b, mode); err != nil { return err } } @@ -473,51 +481,30 @@ func (f *dirFS) WriteFile(name string, b []byte, mode fs.FileMode) error { func (f *dirFS) Readnod(name string) (dev int, err error) { if f.caseSensitiveOnDisk(name) { - fullpath, err := f.sanitizePath(name) - if err != nil { - return 0, err - } - _, err = os.Stat(fullpath) - if err != nil { + if _, err := f.root.Stat(f.relPath(name)); err != nil { return 0, err } } return f.overrides.Readnod(name) } func (f *dirFS) Link(oldname, newname string) error { - fullpath, err := f.sanitizePath(oldname) - if err != nil { - return err - } - // for hardlink, we cannot take target as is, as it might be outside of the base. - // So we must sanitize it. It should point to a file that is within the filesystem. - target := filepath.Clean(fullpath) - if !strings.HasPrefix(target, f.base) { - return fmt.Errorf("hardlink target %s is outside of the filesystem", target) - } if f.createOnDisk(newname) { - fullpath, err := f.sanitizePath(newname) - if err != nil { - return err - } - if err := os.Link(target, fullpath); err != nil { + // *os.Root enforces that both endpoints resolve within base, including + // refusing to traverse any attacker-planted symlink in either path. + if err := f.root.Link(f.relPath(oldname), f.relPath(newname)); err != nil { return err } } return f.overrides.Link(oldname, newname) } func (f *dirFS) Symlink(oldname, newname string) error { - // For symlink, take target as is. - // If it is outside of the base, it will be resolved by Readlink. - // This enables proper symlink behaviour. + // The target (oldname) is stored verbatim, which preserves legitimate APK + // semantics (e.g., absolute paths within the image). *os.Root refuses to + // traverse the symlink at use time if it would escape the root. if f.createOnDisk(newname) { - fullpath, err := f.sanitizePath(newname) - if err != nil { - return err - } - if err := os.Symlink(oldname, fullpath); err != nil { + if err := f.root.Symlink(oldname, f.relPath(newname)); err != nil { return err } } @@ -528,11 +515,8 @@ func (f *dirFS) MkdirAll(name string, perm fs.FileMode) error { // just in case, because some underlying systems miss this fullPerm := os.ModeDir | perm if f.createOnDisk(name) { - fullpath, err := f.sanitizePath(name) - if err != nil { - return err - } - if err := os.MkdirAll(fullpath, fullPerm); err != nil { + // *os.Root rejects type bits in the mode; strip to permission bits. + if err := f.root.MkdirAll(f.relPath(name), fullPerm.Perm()); err != nil { return err } } @@ -543,72 +527,82 @@ func (f *dirFS) Mkdir(name string, perm fs.FileMode) error { // just in case, because some underlying systems miss this fullPerm := os.ModeDir | perm if f.createOnDisk(name) { - fullpath, err := f.sanitizePath(name) - if err != nil { - return err - } - if err := os.Mkdir(fullpath, fullPerm); err != nil { + // *os.Root rejects type bits in the mode; strip to permission bits. + if err := f.root.Mkdir(f.relPath(name), fullPerm.Perm()); err != nil { return err } } return f.overrides.Mkdir(name, fullPerm) } +// isUnsupportedByFS reports whether an error from a best-effort on-disk +// metadata call is one we expect to ignore: +// - ENOTSUP/EOPNOTSUPP/ENOSYS: the filesystem or platform can't perform the +// operation (e.g. a FUSE mount that ignores mode bits). +// - EPERM: the process lacks privilege (e.g. chown as non-root without +// CAP_CHOWN, chmod of a not-owned or immutable file). This is the +// dominant failure mode for unprivileged builds. +// +// In-memory overrides remain the authoritative source for mode/ownership, so +// the build still produces a correctly-attributed tar. Real errors (path +// escapes, I/O failures, missing files) still surface. +func isUnsupportedByFS(err error) bool { + return errors.Is(err, syscall.ENOTSUP) || + errors.Is(err, syscall.EOPNOTSUPP) || + errors.Is(err, syscall.ENOSYS) || + errors.Is(err, syscall.EPERM) +} + func (f *dirFS) Chmod(path string, perm fs.FileMode) error { if f.caseSensitiveOnDisk(path) { - fullpath, err := f.sanitizePath(path) - if err != nil { + if err := f.root.Chmod(f.relPath(path), perm); err != nil && !isUnsupportedByFS(err) { return err } - // ignore error, as we track it in memory anyways, and disk filesystem might not support it - _ = os.Chmod(fullpath, perm) } return f.overrides.Chmod(path, perm) } func (f *dirFS) Chown(path string, uid, gid int) error { if f.caseSensitiveOnDisk(path) { - fullpath, err := f.sanitizePath(path) - if err != nil { + if err := f.root.Chown(f.relPath(path), uid, gid); err != nil && !isUnsupportedByFS(err) { return err } - // ignore error, as we track it in memory anyways, and disk filesystem might not support it - _ = os.Chown(fullpath, uid, gid) } return f.overrides.Chown(path, uid, gid) } func (f *dirFS) Chtimes(path string, atime time.Time, mtime time.Time) error { - fullpath, err := f.sanitizePath(path) - if err != nil { - return err - } - if err := os.Chtimes(fullpath, atime, mtime); err != nil { + if err := f.root.Chtimes(f.relPath(path), atime, mtime); err != nil { return fmt.Errorf("unable to change times: %w", err) } return f.overrides.Chtimes(path, atime, mtime) } +// Mknod stores device metadata in the memFS overrides (the authoritative +// layer) and best-effort materializes the node on disk so other operations +// that consult disk first still find an entry. +// +// os.Root has no Mknod of its own; the disk side is platform-specific and +// implemented in rwosfs_mknod_{linux,other}.go via mknodOnDisk. func (f *dirFS) Mknod(name string, mode uint32, dev int) error { if f.caseSensitiveOnDisk(name) { - fullpath, err := f.sanitizePath(name) - if err != nil { + if err := f.mknodOnDisk(f.relPath(name), mode, dev); err != nil { return err } - // what if we could not create it? Just create a regular file there, and memory will override - if err := unix.Mknod(fullpath, mode, dev); err != nil { - fullpath, err = f.sanitizePath(name) - if err != nil { - return err - } - if err := os.WriteFile(fullpath, nil, 0); err != nil { - return err - } - } } return f.overrides.Mknod(name, mode, dev) } +// placeholderOnDisk creates an empty file through the root so that later +// Stat/Open calls that consult disk first find something. Device metadata is +// tracked in the in-memory overrides; this entry is a visibility stub only. +func (f *dirFS) placeholderOnDisk(rel string) error { + if err := f.root.WriteFile(rel, nil, 0); err != nil { + return fmt.Errorf("unable to create placeholder on disk: %w", err) + } + return nil +} + func (f *dirFS) SetXattr(path string, attr string, data []byte) error { // the underlying filesystem might or might not support xattrs // but we have info on every file in memory, so might as well store it there. @@ -629,33 +623,24 @@ func (f *dirFS) Sub(path string) (FullFS, error) { const pathSeparator = string(os.PathSeparator) -// sanitizePath joins base and p safely, preventing path traversal outside base. -// It allows ".." components in the path as long as the resolved path stays within base. -func (f *dirFS) sanitizePath(p string) (string, error) { - if f.base == "" { - return "", fmt.Errorf("empty base") - } - +// relPath normalizes a caller-supplied path into a clean, root-relative form +// suitable for use with *os.Root operations. +// +// It handles two things that *os.Root does not handle gracefully on its own: +// - empty / "/" / absolute-looking inputs, which *os.Root rejects as "path +// escapes from parent" — here we strip to a root-relative form. +// - intermediate ".." components, which *os.Root walks against the real +// filesystem (so "a/c/../b" fails if "c" doesn't exist). filepath.Clean +// collapses them lexically before we hand the path to the root. +// +// Null-byte paths and ".." components that escape the root are left to +// *os.Root to reject; both yield clear errors from the stdlib. +func (f *dirFS) relPath(p string) string { clean := strings.TrimSuffix(strings.TrimPrefix(p, pathSeparator), pathSeparator) if clean == "" { - return f.base, nil - } - - if strings.Contains(clean, "\x00") { - return "", fmt.Errorf("path contains null byte") - } - - // Resolve the path and check if it escapes base using filepath.Rel - resolved := filepath.Clean(filepath.Join(f.base, clean)) - rel, err := filepath.Rel(filepath.Clean(f.base), resolved) - if err != nil || strings.HasPrefix(rel, ".."+pathSeparator) || rel == ".." { - return "", fmt.Errorf("%s: %s", "content filepath is tainted", p) - } - - if os.IsPathSeparator(f.base[len(f.base)-1]) { - return f.base + clean, nil + return "." } - return f.base + pathSeparator + clean, nil + return filepath.Clean(clean) } func (f *dirFS) caseSensitiveOnDisk(p string) bool { @@ -711,18 +696,20 @@ func (f *dirFS) removeOnDisk(p string) (removeOnDisk bool) { type file File type fileImpl struct { file - name string - fullpath string - perms *os.FileMode + name string + // root and rel are populated when the underlying file lives on disk so + // permission restoration in Close() goes through the sandboxed root. + root *os.Root + rel string + perms *os.FileMode } func (f fileImpl) Close() error { if err := f.file.Close(); err != nil { return err } - if f.perms != nil { - // f.name is the basename of the path, use the f.file.name here - return os.Chmod(f.fullpath, *f.perms) + if f.perms != nil && f.root != nil { + return f.root.Chmod(f.rel, *f.perms) } return nil }
pkg/apk/fs/rwosfs_mknod_linux.go+40 −0 added@@ -0,0 +1,40 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package fs + +import ( + "path" + + "golang.org/x/sys/unix" +) + +// mknodOnDisk issues mknodat against a root-constrained parent directory FD +// so the syscall cannot be tricked into traversing a symlink out of the +// sandbox. If the underlying filesystem refuses the mode (e.g. tmpfs +// rejecting certain device types), a zero-byte placeholder is written +// through the root instead. +func (f *dirFS) mknodOnDisk(rel string, mode uint32, dev int) error { + parent, err := f.root.Open(path.Dir(rel)) + if err != nil { + return err + } + defer parent.Close() + if err := unix.Mknodat(int(parent.Fd()), path.Base(rel), mode, dev); err == nil { + return nil + } + return f.placeholderOnDisk(rel) +}
pkg/apk/fs/rwosfs_mknod_other.go+24 −0 added@@ -0,0 +1,24 @@ +// Copyright 2026 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !linux + +package fs + +// mknodOnDisk has no sandboxed implementation off Linux (no Root.Mknod, no +// unix.Mknodat). The device metadata lives in the memFS overrides; this +// writes a placeholder file through the root so Stat/Open resolve. +func (f *dirFS) mknodOnDisk(rel string, _ uint32, _ int) error { + return f.placeholderOnDisk(rel) +}
pkg/apk/fs/rwosfs_test.go+299 −90 modified@@ -18,6 +18,7 @@ import ( "io/fs" "os" "path/filepath" + "syscall" "testing" "github.com/stretchr/testify/require" @@ -455,100 +456,308 @@ func TestScriptsTarPattern(t *testing.T) { require.Equal(t, combinedData, finalData, "ReadFile should return combined data, not zeros") } -func TestSanitizePath(t *testing.T) { +// TestSymlinkEscape_WriteFile_AbsoluteTarget verifies that a symlink planted +// inside the dirFS whose target is an absolute path outside the base cannot be +// used to land a write outside the base. +func TestSymlinkEscape_WriteFile_AbsoluteTarget(t *testing.T) { + sandbox := t.TempDir() + base := filepath.Join(sandbox, "base") + outside := filepath.Join(sandbox, "outside") + require.NoError(t, os.MkdirAll(outside, 0o755)) + require.NoError(t, os.MkdirAll(base, 0o755)) + + fsys := DirFS(t.Context(), base) + require.NotNil(t, fsys) + + require.NoError(t, fsys.Symlink(outside, "evil")) + + err := fsys.WriteFile("evil/pwned", []byte("bad"), 0o644) + require.Error(t, err, "write through escape symlink must fail") + + _, statErr := os.Stat(filepath.Join(outside, "pwned")) + require.True(t, os.IsNotExist(statErr), "outside file must not exist") +} + +// TestSymlinkEscape_WriteFile_RelativeTarget covers the "../outside" target +// variant separately: the lexical path inside dirFS is innocuous but the +// symlink resolves to a location outside the root. +func TestSymlinkEscape_WriteFile_RelativeTarget(t *testing.T) { + sandbox := t.TempDir() + base := filepath.Join(sandbox, "base") + outside := filepath.Join(sandbox, "outside") + require.NoError(t, os.MkdirAll(outside, 0o755)) + require.NoError(t, os.MkdirAll(base, 0o755)) + + fsys := DirFS(t.Context(), base) + require.NotNil(t, fsys) + + require.NoError(t, fsys.Symlink("../outside", "evil")) + + err := fsys.WriteFile("evil/pwned", []byte("bad"), 0o644) + require.Error(t, err, "write through relative-escape symlink must fail") + + _, statErr := os.Stat(filepath.Join(outside, "pwned")) + require.True(t, os.IsNotExist(statErr), "outside file must not exist") +} + +// TestSymlinkEscape_OpenFileCreate mirrors the WriteFile case for the +// OpenFile+O_CREATE code path, which takes a different branch in dirFS. +func TestSymlinkEscape_OpenFileCreate(t *testing.T) { + sandbox := t.TempDir() + base := filepath.Join(sandbox, "base") + outside := filepath.Join(sandbox, "outside") + require.NoError(t, os.MkdirAll(outside, 0o755)) + require.NoError(t, os.MkdirAll(base, 0o755)) + + fsys := DirFS(t.Context(), base) + require.NotNil(t, fsys) + + require.NoError(t, fsys.Symlink(outside, "evil")) + + fh, err := fsys.OpenFile("evil/pwned", os.O_CREATE|os.O_WRONLY, 0o644) + if err == nil { + _ = fh.Close() + } + require.Error(t, err, "OpenFile+O_CREATE through escape symlink must fail") + + _, statErr := os.Stat(filepath.Join(outside, "pwned")) + require.True(t, os.IsNotExist(statErr), "outside file must not exist") +} + +// TestSymlinkEscape_MkdirAll ensures MkdirAll cannot merge into an +// attacker-planted symlinked directory that points outside the root. +func TestSymlinkEscape_MkdirAll(t *testing.T) { + sandbox := t.TempDir() + base := filepath.Join(sandbox, "base") + outside := filepath.Join(sandbox, "outside") + require.NoError(t, os.MkdirAll(outside, 0o755)) + require.NoError(t, os.MkdirAll(base, 0o755)) + + fsys := DirFS(t.Context(), base) + require.NotNil(t, fsys) + + require.NoError(t, fsys.Symlink(outside, "evil")) + + err := fsys.MkdirAll("evil/sub", 0o755) + require.Error(t, err, "MkdirAll through escape symlink must fail") + + _, statErr := os.Stat(filepath.Join(outside, "sub")) + require.True(t, os.IsNotExist(statErr), "outside dir must not exist") +} + +// TestSymlinkEscape_Link rejects hardlink newnames whose path resolution goes +// through an attacker-planted symlink pointing out of the root. +func TestSymlinkEscape_Link(t *testing.T) { + sandbox := t.TempDir() + base := filepath.Join(sandbox, "base") + outside := filepath.Join(sandbox, "outside") + require.NoError(t, os.MkdirAll(outside, 0o755)) + require.NoError(t, os.MkdirAll(base, 0o755)) + + fsys := DirFS(t.Context(), base) + require.NotNil(t, fsys) + + require.NoError(t, fsys.WriteFile("legit", []byte("content"), 0o644)) + require.NoError(t, fsys.Symlink(outside, "evil")) + + err := fsys.Link("legit", "evil/linked") + require.Error(t, err, "Link through escape symlink must fail") + + _, statErr := os.Lstat(filepath.Join(outside, "linked")) + require.True(t, os.IsNotExist(statErr), "outside hardlink must not exist") +} + +// TestSymlinkEscape_ReadFile ensures a symlink planted inside dirFS cannot be +// used to read a file outside the root (read-side exfiltration). +func TestSymlinkEscape_ReadFile(t *testing.T) { + sandbox := t.TempDir() + base := filepath.Join(sandbox, "base") + outside := filepath.Join(sandbox, "outside") + secret := filepath.Join(outside, "secret") + require.NoError(t, os.MkdirAll(outside, 0o755)) + require.NoError(t, os.WriteFile(secret, []byte("top-secret"), 0o600)) + require.NoError(t, os.MkdirAll(base, 0o755)) + + fsys := DirFS(t.Context(), base) + require.NotNil(t, fsys) + + require.NoError(t, fsys.Symlink(secret, "evil")) + + _, err := fsys.ReadFile("evil") + require.Error(t, err, "ReadFile through escape symlink must fail") +} + +// TestSymlinkEscape_Stat ensures Stat refuses to resolve an attacker-planted +// symlink that points outside the root. +func TestSymlinkEscape_Stat(t *testing.T) { + sandbox := t.TempDir() + base := filepath.Join(sandbox, "base") + outside := filepath.Join(sandbox, "outside") + secret := filepath.Join(outside, "secret") + require.NoError(t, os.MkdirAll(outside, 0o755)) + require.NoError(t, os.WriteFile(secret, []byte("s"), 0o600)) + require.NoError(t, os.MkdirAll(base, 0o755)) + + fsys := DirFS(t.Context(), base) + require.NotNil(t, fsys) + + require.NoError(t, fsys.Symlink(secret, "evil")) + + _, err := fsys.Stat("evil") + require.Error(t, err, "Stat through escape symlink must fail") +} + +// TestSymlinkEscape_Chmod verifies Chmod cannot change permissions on files +// outside the root via an attacker-planted symlink. The escape returns an +// error (not a filesystem-compat errno) so it surfaces to the caller. +func TestSymlinkEscape_Chmod(t *testing.T) { + sandbox := t.TempDir() + base := filepath.Join(sandbox, "base") + outside := filepath.Join(sandbox, "outside") + target := filepath.Join(outside, "target") + require.NoError(t, os.MkdirAll(outside, 0o755)) + require.NoError(t, os.WriteFile(target, []byte("x"), 0o600)) + require.NoError(t, os.MkdirAll(base, 0o755)) + + fsys := DirFS(t.Context(), base) + require.NotNil(t, fsys) + + require.NoError(t, fsys.Symlink(target, "evil")) + + require.Error(t, fsys.Chmod("evil", 0o777), "Chmod through escape symlink must fail") + + fi, err := os.Stat(target) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o600), fi.Mode().Perm(), "outside file perms must be unchanged") +} + +// TestSymlinkEscape_Chown verifies Chown cannot change ownership on files +// outside the root via an attacker-planted symlink. Mirrors the Chmod case: +// path-escape errors surface, compat errnos (e.g. EPERM as non-root) don't. +func TestSymlinkEscape_Chown(t *testing.T) { + sandbox := t.TempDir() + base := filepath.Join(sandbox, "base") + outside := filepath.Join(sandbox, "outside") + target := filepath.Join(outside, "target") + require.NoError(t, os.MkdirAll(outside, 0o755)) + require.NoError(t, os.WriteFile(target, []byte("x"), 0o600)) + require.NoError(t, os.MkdirAll(base, 0o755)) + + fsys := DirFS(t.Context(), base) + require.NotNil(t, fsys) + + require.NoError(t, fsys.Symlink(target, "evil")) + + require.Error(t, fsys.Chown("evil", os.Getuid(), os.Getgid()), "Chown through escape symlink must fail") +} + +// TestSymlinkEscape_PreExistingOnDisk plants an escape symlink on disk before +// DirFS is constructed. The constructor walker must not create a usable +// escape; writes through the link must still be refused. +func TestSymlinkEscape_PreExistingOnDisk(t *testing.T) { + sandbox := t.TempDir() + base := filepath.Join(sandbox, "base") + outside := filepath.Join(sandbox, "outside") + require.NoError(t, os.MkdirAll(outside, 0o755)) + require.NoError(t, os.MkdirAll(base, 0o755)) + require.NoError(t, os.Symlink(outside, filepath.Join(base, "evil"))) + + fsys := DirFS(t.Context(), base) + require.NotNil(t, fsys) + + err := fsys.WriteFile("evil/pwned", []byte("bad"), 0o644) + require.Error(t, err, "write through pre-existing escape symlink must fail") + + _, statErr := os.Stat(filepath.Join(outside, "pwned")) + require.True(t, os.IsNotExist(statErr), "outside file must not exist") +} + +// TestSymlinkEscape_Mknod_ParentPath exercises the parent-FD leg of +// mknodOnDisk: a symlink component in the path to the node must be refused at +// root.Open time, before mknodat is ever issued. +func TestSymlinkEscape_Mknod_ParentPath(t *testing.T) { + sandbox := t.TempDir() + base := filepath.Join(sandbox, "base") + outside := filepath.Join(sandbox, "outside") + require.NoError(t, os.MkdirAll(outside, 0o755)) + require.NoError(t, os.MkdirAll(base, 0o755)) + + fsys := DirFS(t.Context(), base) + require.NotNil(t, fsys) + + require.NoError(t, fsys.Symlink(outside, "evil")) + + err := fsys.Mknod("evil/foo", syscall.S_IFCHR|0o644, 0) + require.Error(t, err, "Mknod through escape symlink parent must fail") + + _, statErr := os.Lstat(filepath.Join(outside, "foo")) + require.True(t, os.IsNotExist(statErr), "outside node must not exist") +} + +// TestSymlinkEscape_Mknod_Basename exercises the placeholder fallback: when +// mknodat refuses (EEXIST on an existing symlink, or EPERM without CAP_MKNOD), +// mknodOnDisk falls back to root.OpenFile, which must refuse to follow a +// basename symlink that escapes the root. +func TestSymlinkEscape_Mknod_Basename(t *testing.T) { + sandbox := t.TempDir() + base := filepath.Join(sandbox, "base") + outside := filepath.Join(sandbox, "outside") + target := filepath.Join(outside, "pwned") + require.NoError(t, os.MkdirAll(outside, 0o755)) + require.NoError(t, os.MkdirAll(base, 0o755)) + + fsys := DirFS(t.Context(), base) + require.NotNil(t, fsys) + + require.NoError(t, fsys.Symlink(target, "evil")) + + err := fsys.Mknod("evil", syscall.S_IFCHR|0o644, 0) + require.Error(t, err, "Mknod on escape-symlink basename must fail") + + _, statErr := os.Lstat(target) + require.True(t, os.IsNotExist(statErr), "outside node must not be created via placeholder") +} + +// TestDirFSClose ensures the type-assertable Close() releases the root FD. +func TestDirFSClose(t *testing.T) { + dir := t.TempDir() + fsys := DirFS(t.Context(), dir) + require.NotNil(t, fsys) + + closer, ok := fsys.(interface{ Close() error }) + require.True(t, ok, "dirFS should expose Close()") + require.NoError(t, closer.Close()) + // Idempotent. + require.NoError(t, closer.Close()) +} + +func TestRelPath(t *testing.T) { + // relPath is a normalizer; it does not police escapes — *os.Root is the + // authority on that. The cases that *used* to error in relPath now pass + // through normalized, and the rejection happens at the root.* call site. for _, tt := range []struct { - name string - base string - path string - want string - wantErr bool + name string + path string + want string }{ - { - name: "empty base", - base: "", - wantErr: true, - }, - { - name: "empty path returns base", - base: "/tmp/1", - want: "/tmp/1", - }, - { - name: "root path returns base", - base: "/tmp/1", - path: "/", - want: "/tmp/1", - }, - { - name: "dot path returns base (plus dot)", - base: "/tmp/1", - path: ".", - want: "/tmp/1/.", - }, - { - name: "base has valid traversal", - base: "../", - path: ".", - want: "../.", - }, - { - name: "base has valid traversal (no trailing slash)", - base: "..", - path: ".", - want: "../.", - }, - { - name: "invalid path with traversal", - base: "/tmp/1", - path: "../", - wantErr: true, - }, - { - name: "path with traversal even within base is valid", - base: "/tmp/1", - path: "a/b/c/../../b", - want: "/tmp/1/a/b/c/../../b", - }, - { - name: "path with trailing dotdot stays within base", - base: "/tmp/1", - path: "a/b/c/..", - want: "/tmp/1/a/b/c/..", - }, - { - name: "path starting with dotdot but resolving within base", - base: "/tmp/1", - path: "../1/a", - want: "/tmp/1/../1/a", - }, - { - name: "path with multiple dotdot stays within base", - base: "/tmp/1", - path: "a/b/../x/../z", - want: "/tmp/1/a/b/../x/../z", - }, - { - name: "relative base with path that escapes", - base: "../", - path: "../..", - wantErr: true, - }, - { - name: "path with null byte", - base: "/tmp/1", - path: "test\x00file", - wantErr: true, - }, + {name: "empty path", path: "", want: "."}, + {name: "root path", path: "/", want: "."}, + {name: "dot path", path: ".", want: "."}, + {name: "simple path", path: "a/b", want: "a/b"}, + {name: "absolute path becomes relative", path: "/a/b", want: "a/b"}, + {name: "dotdot middle resolves in place", path: "a/b/c/../../b", want: "a/b"}, + {name: "trailing dotdot resolves in place", path: "a/b/c/..", want: "a/b"}, + {name: "multiple dotdot staying within root", path: "a/b/../x/../z", want: "a/z"}, + + // These used to error; now they normalize and os.Root rejects at use time. + {name: "leading dotdot normalized", path: "../", want: ".."}, + {name: "nested escape normalized", path: "a/../..", want: ".."}, + {name: "leading dotdot with path kept", path: "../1/a", want: "../1/a"}, + {name: "null byte preserved for os.Root to reject", path: "test\x00file", want: "test\x00file"}, } { t.Run(tt.name, func(t *testing.T) { - f := &dirFS{base: tt.base} - got, err := f.sanitizePath(tt.path) - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - require.Equal(t, tt.want, got) + f := &dirFS{} + require.Equal(t, tt.want, f.relPath(tt.path)) }) } }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-qq3r-w4hj-gjp6ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-42574ghsaADVISORY
- github.com/chainguard-dev/apko/commit/f5a96e1299ac81c7ea9441705ec467688086f442nvdWEB
- github.com/chainguard-dev/apko/pull/2187nvdWEB
- github.com/chainguard-dev/apko/releases/tag/v1.2.5nvdWEB
- github.com/chainguard-dev/apko/security/advisories/GHSA-qq3r-w4hj-gjp6nvdWEB
News mentions
0No linked articles in our index yet.