daemon: persist teardown fallbacks and reject unsafe import paths

Preserve cleanup after daemon restarts and harden OCI and tar imports
against filenames that debugfs cannot encode safely.

Mirror tap, loop, and dm teardown identity onto VM.Runtime, teach
cleanup and reconcile to fall back to those persisted fields when
handles.json is missing or corrupt, and clear the recovery state on
stop, error, and delete paths.

Reject debugfs-hostile entry names during flattening and in
ApplyOwnership itself, then add regression coverage for corrupt
handles.json recovery and unsafe import paths.

Verified with targeted go tests, make lint-go, make lint-shell, and
make build.
This commit is contained in:
Thales Maciel 2026-04-23 16:21:59 -03:00
parent 86a56fedb3
commit d743a8ba4b
No known key found for this signature in database
GPG key ID: 33112E6833C34679
15 changed files with 272 additions and 81 deletions

View file

@ -138,6 +138,9 @@ func applyEntry(tr *tar.Reader, hdr *tar.Header, dest string, meta *Metadata) er
if filepath.IsAbs(rel) || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return fmt.Errorf("unsafe path in layer: %q", hdr.Name)
}
if err := validateDebugFSPath(rel); err != nil {
return err
}
base := filepath.Base(rel)
parent := filepath.Dir(rel)

View file

@ -10,9 +10,9 @@
// and returns a v1.Image whose layer blobs are cached on disk under
// cacheDir/blobs/sha256/<hex> so re-pulls are local.
// - Flatten replays the layers in order into a staging directory,
// applies whiteouts, rejects unsafe paths/symlinks, and returns
// Metadata capturing the original tar-header uid/gid/mode for
// every entry.
// applies whiteouts, rejects unsafe paths/symlinks plus filenames
// that debugfs can't represent safely, and returns Metadata
// capturing the original tar-header uid/gid/mode for every entry.
// - BuildExt4 turns the staging directory into an ext4 file via
// `mkfs.ext4 -F -d` (no mount, no sudo). Root-owns the filesystem
// via `-E root_owner=0:0`.

View file

@ -254,6 +254,30 @@ func TestFlattenRejectsPathTraversal(t *testing.T) {
}
}
func TestFlattenRejectsDebugFSHostilePath(t *testing.T) {
img, err := mutate.AppendLayers(empty.Image,
makeLayer(t, []tarMember{
{name: `etc/bad"name`, body: []byte("bad")},
}),
)
if err != nil {
t.Fatalf("AppendLayers: %v", err)
}
pulled := PulledImage{
Reference: "test/debugfs-hostile",
Digest: "sha256:test",
Platform: "linux/amd64",
Image: img,
}
_, err = Flatten(context.Background(), pulled, t.TempDir())
if !errors.Is(err, errUnsafeDebugFSPath) {
t.Fatalf("Flatten hostile path: err=%v, want %v", err, errUnsafeDebugFSPath)
}
if !strings.Contains(err.Error(), `etc/bad\"name`) {
t.Fatalf("Flatten hostile path: err=%v, want offending path", err)
}
}
func TestFlattenAcceptsAbsoluteSymlink(t *testing.T) {
// Container layers regularly contain absolute symlinks like
// /usr/bin/mawk — they're interpreted relative to the rootfs at
@ -303,6 +327,19 @@ func TestFlattenRejectsRelativeSymlinkEscape(t *testing.T) {
}
}
func TestFlattenTarRejectsDebugFSHostilePath(t *testing.T) {
tarData := buildTar(t, []tarMember{
{name: "etc/bad\tname", body: []byte("bad")},
})
_, err := FlattenTar(context.Background(), bytes.NewReader(tarData), t.TempDir())
if !errors.Is(err, errUnsafeDebugFSPath) {
t.Fatalf("FlattenTar hostile path: err=%v, want %v", err, errUnsafeDebugFSPath)
}
if !strings.Contains(err.Error(), `etc/bad\tname`) {
t.Fatalf("FlattenTar hostile path: err=%v, want offending path", err)
}
}
func TestBuildExt4ProducesValidImage(t *testing.T) {
if _, err := exec.LookPath("mkfs.ext4"); err != nil {
t.Skip("mkfs.ext4 not available; skipping")
@ -412,13 +449,30 @@ func TestApplyOwnershipRewritesUidGidMode(t *testing.T) {
}
}
func TestApplyOwnershipRejectsUnsafeMetadataPath(t *testing.T) {
meta := Metadata{Entries: map[string]FileMeta{
"bad\nname": {Uid: 0, Gid: 0, Mode: 0o644, Type: tar.TypeReg},
}}
err := ApplyOwnership(context.Background(), system.NewRunner(), filepath.Join(t.TempDir(), "rootfs.ext4"), meta)
if !errors.Is(err, errUnsafeDebugFSPath) {
t.Fatalf("ApplyOwnership hostile path: err=%v, want %v", err, errUnsafeDebugFSPath)
}
if !strings.Contains(err.Error(), `bad\nname`) {
t.Fatalf("ApplyOwnership hostile path: err=%v, want offending path", err)
}
}
func TestBuildOwnershipScriptDeterministic(t *testing.T) {
meta := Metadata{Entries: map[string]FileMeta{
"b": {Uid: 0, Gid: 0, Mode: 0o755, Type: tar.TypeReg},
"a": {Uid: 0, Gid: 0, Mode: 0o755, Type: tar.TypeReg},
"a/x": {Uid: 0, Gid: 0, Mode: 0o644, Type: tar.TypeReg},
}}
got := buildOwnershipScript(meta).String()
gotBuf, err := buildOwnershipScript(meta)
if err != nil {
t.Fatalf("buildOwnershipScript: %v", err)
}
got := gotBuf.String()
// sorted: a, a/x, b
want := "set_inode_field /a uid 0\nset_inode_field /a gid 0\nset_inode_field /a mode 0100755\n" +
"set_inode_field /a/x uid 0\nset_inode_field /a/x gid 0\nset_inode_field /a/x mode 0100644\n" +

View file

@ -4,8 +4,10 @@ import (
"archive/tar"
"bytes"
"context"
"errors"
"fmt"
"sort"
"strings"
"banger/internal/system"
)
@ -24,7 +26,10 @@ func ApplyOwnership(ctx context.Context, runner system.CommandRunner, ext4File s
if len(meta.Entries) == 0 {
return nil
}
script := buildOwnershipScript(meta)
script, err := buildOwnershipScript(meta)
if err != nil {
return err
}
if script.Len() == 0 {
return nil
}
@ -43,7 +48,7 @@ func ApplyOwnership(ctx context.Context, runner system.CommandRunner, ext4File s
// Paths are prefixed with "/" so debugfs resolves them from the ext4
// root. Entries are sorted for deterministic output (helps testing and
// makes debugfs's internal caching slightly more cache-friendly).
func buildOwnershipScript(meta Metadata) *bytes.Buffer {
func buildOwnershipScript(meta Metadata) (*bytes.Buffer, error) {
var buf bytes.Buffer
paths := make([]string, 0, len(meta.Entries))
for p := range meta.Entries {
@ -56,12 +61,15 @@ func buildOwnershipScript(meta Metadata) *bytes.Buffer {
if mode == 0 {
continue // hardlinks or unsupported types (skip)
}
if err := validateDebugFSPath(p); err != nil {
return nil, err
}
escaped := escapeDebugfsPath(p)
fmt.Fprintf(&buf, "set_inode_field %s uid %d\n", escaped, m.Uid)
fmt.Fprintf(&buf, "set_inode_field %s gid %d\n", escaped, m.Gid)
fmt.Fprintf(&buf, "set_inode_field %s mode 0%o\n", escaped, mode)
}
return &buf
return &buf, nil
}
// debugfsMode composes the full i_mode word (file-type bits +
@ -87,27 +95,29 @@ func debugfsMode(typ byte, hdrMode int64) uint32 {
}
}
// escapeDebugfsPath prepends "/" and wraps in double quotes if the path
// contains whitespace or special characters. debugfs' quoting is
// minimal; for safety we reject backslashes/quotes in paths entirely.
func escapeDebugfsPath(rel string) string {
abs := "/" + rel
// Container images don't normally use quoting-hostile chars; if they
// do, fall back to the raw path and hope debugfs copes (it usually
// does for spaces when quoted).
needsQuote := false
for _, c := range abs {
switch c {
case ' ', '\t':
needsQuote = true
case '"', '\\', '\n':
// Deliberately unhandled; debugfs may fail on these.
// Returning the raw string gives us a visible error
// instead of a silently-corrupted script.
return abs
var errUnsafeDebugFSPath = errors.New("unsafe path for debugfs ownership script")
func validateDebugFSPath(rel string) error {
for i := 0; i < len(rel); i++ {
switch c := rel[i]; {
case c == '"':
return fmt.Errorf("%w: %q contains '\"'", errUnsafeDebugFSPath, rel)
case c == '\\':
return fmt.Errorf("%w: %q contains '\\\\'", errUnsafeDebugFSPath, rel)
case c < 0x20 || c == 0x7f:
return fmt.Errorf("%w: %q contains control byte 0x%02x", errUnsafeDebugFSPath, rel, c)
}
}
if needsQuote {
return nil
}
// escapeDebugfsPath prepends "/" and wraps in double quotes if the path
// contains spaces. validateDebugFSPath rejects debugfs-hostile bytes
// before this runs, so the only quoting we need is the simple
// whitespace case debugfs already handles.
func escapeDebugfsPath(rel string) string {
abs := "/" + rel
if strings.ContainsRune(abs, ' ') {
return `"` + abs + `"`
}
return abs