tarfs: keep mode bits and ownership from tar headers - #2455
Conversation
WriteHeader's TypeDir branch created the directory with
hdr.FileInfo().Mode().Perm(), which masks to 0o777 and so dropped
setuid/setgid/sticky, and it never applied hdr.Uid/hdr.Gid at all.
Every package-owned directory landed as root:root with its special
bits gone.
The loss is format-neutral, because both writers read the same node:
pkg/build/tarball.go builds output headers with tar.FileInfoHeader,
which takes the special bits from FileInfo.Mode() and Uid/Gid from
FileInfo.Sys(), and pkg/build/erofs.go reads that same Sys() header.
Wolfi's postfix is a live example. Its apk ships 15 directories under
/var/spool/postfix and /var/lib/postfix owned by uid 100, four of them
also gid 100 or 101; an `apko build` of it emitted all 15 as 0:0.
MkdirAll carries only permission bits, so the special bits need a
separate Chmod. Both the Chmod and the Chown are restricted to
directories this header actually created:
- Ancestors MkdirAll had to invent are not described by this header,
so they must not inherit its ownership.
- InitDB creates /tmp as 1777 before any package installs, and
packages ship a tmp directory header with no sticky bit
(wolfi-baselayout intends 1777 but the apk records 0777), so
applying dir modes unconditionally would take the sticky bit off
/tmp in every wolfi-based image.
Reported by mattmoor in chainguard-dev#2418.
The same gap the previous commit fixed for directories applies to everything writeHeader creates: the node was built from the tar header's mode but never its Uid/Gid, so every packaged file and symlink was serialized as root:root. For a setgid binary that is a privilege change, not just cosmetics. Wolfi's postfix ships /usr/bin/postdrop and /usr/bin/postqueue as mode 0o2755 gid 101, i.e. setgid to the unprivileged "postdrop" group. An `apko build` of it emitted mode 0o2755 gid 0 -- setgid to root. The golden fixtures move because of this. The apks under internal/cli/testdata/packages declare their files as uid 501 gid 20, the account on the machine that built them, and apko now reproduces that faithfully; /etc/os-release is the only entry whose metadata changes in either golden image. testdata/golden was rebuilt with the same options TestBuild passes (verified byte-identical to the committed copy when built against the unfixed code), and testdata/top_image with internal/cli/testdata/regenerate_golden_top_image.sh.
…2458) Fixes #2456 — both findings, in `pkg/apk/apk`, plus the one thing in `pkg/apk/fs` that kept the second fix from working end to end. ### 1. The streaming install path drops dir mode bits and all ownership `installAPKFiles` is the install path taken whenever the target filesystem is not an `apk.WriteHeaderer` — in tree `apko build-cpio`, out of tree melange's qemu runner. Two defects, the same ones #2455 fixes for `pkg/tarfs`: - `MkdirAll` was passed `header.FileInfo().Mode().Perm()`, which masks to 0o777, so setuid/setgid/sticky on a directory header were dropped. - Nothing applied `header.Uid`/`header.Gid`, so everything installed came out `0:0`. A setgid binary such as wolfi postfix's `/usr/bin/postdrop` (`0o2755`, gid 101) kept its setgid bit but ended up setgid to *root* instead of to the group the package asked for. So `Chmod` newly created directories with every non-type bit, and `Chown` both directories and files. The directory metadata is applied only when we created the directory — `InitDB` makes `/tmp` 1777 before any package installs, and packages ship a `tmp` header without the sticky bit. Regular files get a `Chmod` too, because `dirFS.OpenFile` strips the special bits for the on-disk write and Linux clears them again on the next write by an unprivileged process. Symlinks and hardlinks are left alone: `FullFS` has no `Lchown`, and `Chown` would follow the link and retarget its target's ownership. **The order is load-bearing** (caught in review by @mattmoor, commit 4): `chown(2)` on a regular file clears setuid/setgid — for root too, and even for a chown that changes nothing — so `Chown` has to come *before* the `Chmod` that restores those bits. The old order left an installed setgid binary at 0755 on disk while the in-memory overrides still said 02755, so the serialized image was right and the tree melange's qemu runner boots was wrong. ### 2. `InitDB`'s base-directory permission check could never match for /tmp It compared `stat.Mode().Perm()` against entries whose perms carry `fs.ModeSticky`, so the `/tmp` entry never matched and any call where `/tmp` already existed as 1777 failed with `base directory /tmp has incorrect permissions: 777`. Now it compares every non-type bit and reports both got and want. I kept the check as strict as it was, just correct. ### 3. `seedOverride` dropped the same bits (commits 2 and 3) `DirFS` mirrors the tree it wraps into its in-memory overrides, and those overrides are what all mode lookups resolve against. `seedOverride` seeded only `mode.Perm()`, so a sticky directory or setgid binary *already on disk* was reported — and written into the layer — without those bits. That is also what kept fix 2 from working for a `DirFS` over a tree that already had `/tmp` as 1777. Seeding those bits only works if the owner comes with them, which review caught (thanks @depthfirst-app): a node seeded setuid but left at the default uid 0 would serialize as setuid *root*, and `apk.New`'s fallback filesystem is `DirFS(ctx, "/")`, so the tree walked can be a whole host root. So the owner is taken from the same `Sys()` as the mode and applied with `Chown`; symlinks stay unowned (`memFS.getNode` resolves the final component, so a `Chown` would retarget the link's target, and `FullFS` has no `Lchown`); and when `Sys()` carries no owner, setuid/setgid are dropped rather than paired with uid 0. ### Testing Six new tests, each mutation-checked against the pre-fix code: - `TestInstallAPKFilesModesAndOwnership` — sticky and setgid dirs, a setgid file, gid 101, and a pre-existing 1777 `/tmp` whose mode and owner must survive a package's `tmp` header. - `TestInstallAPKFilesModesOnDisk` — the same over `apkfs.DirFS`, asserting the bits reach the real disk. One entry's header uid/gid are the test process's own, which is the one case where the disk `Chown` succeeds unprivileged and so reaches the kernel's clearing of setuid/setgid instead of an `EPERM` the filesystem swallows; that entry fails against the wrong call order, with no root needed. - `TestInstallAPKFilesMetadataOrder` — pins `Chown` before `Chmod` with a recording `FullFS` that wraps a real memFS and only logs which call reaches each path first, covering the directory path where no kernel behavior is observable. - `TestInitDBBaseDirectoryPerms` — sticky `/tmp` accepted, non-sticky rejected. - `TestSeedOverride_SpecialModeBits` — setuid/setgid/sticky on disk survive seeding, with the uid/gid asserted against the on-disk `*syscall.Stat_t`. - `TestSeedOverride_NoOwnerDropsSetidBits` — with no owner available, setuid and setgid are dropped instead of seeded against uid 0. `go test ./pkg/... ./internal/...` passes and `golangci-lint run ./pkg/...` is clean. No golden digests moved: the paths this touches are not the ones the `internal/cli` fixtures build through. ### Not in scope `pkg/cpio/layer.go` never sets `Record.UID`/`GID`, so `apko build-cpio` output still lands `0:0` no matter what the filesystem records. The metadata is now correct up to the cpio conversion; that last step wants its own change. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
stevebeattie
left a comment
There was a problem hiding this comment.
Thanks for this. Reviewed against your branch rebased onto current main (rebases clean, suite green), so this accounts for #2458 landing this morning. The golden delta re-derives exactly as described: all four regenerated layers differ from base by one line, etc/os-release 0/0 → 501/20, nothing else, and no directory entry changed anywhere.
Worth adding to the PR description
I chased that 501/20 and it's worth writing down, because the next person to look will wonder. It isn't one stray file — every file created by the melange build pipeline in the internal/cli fixtures is 501/20 (etc/os-release in both baselayout packages, both .crt files in custom-ca-certs-1), while everything melange emits itself (.PKGINFO, .melange.yaml, .SIGN.*, the SBOMs) and every directory is root/root. 501/20 is a macOS host user — uid 501, gid 20 (staff). The split is the tell: pretend-baselayout's pipeline does mkdir -p .../etc then cat > .../etc/os-release one line apart, so melange must be normalizing directory entries while taking regular files' headers from disk, where Docker Desktop's shared filesystem reports the host user. (The declared build user is uid 1000, so it isn't the container account either.)
Nothing wrong with this PR — you're faithfully propagating what the apk records, and the apk records something odd. But it does mean a melange-on-macOS local build now produces images carrying the developer's uid on files, where apko previously normalized them to root. Real packages are unaffected — I pulled wolfi-baselayout-20230201-r27.apk and all 54 entries are root/root — but it's a behaviour change for local dev workflows that's worth one line so it isn't a surprise later.
Highest priority: the created gate makes correctness depend on install order — and it's now in two places
Gating both Chmod and Chown on "we just created this" means the fix only applies when the owning package's header happens to touch the path first. Three orderings that occur in any real multi-package install lose metadata silently — no error, no log line:
WriteHeader(dir "srv/data", 0o755, 0:0) # package A
WriteHeader(dir "srv/data", 0o2770, 100:100) # package B, owns it
→ drwxr-xr-x 0:0 setgid bit AND ownership discarded
WriteHeader(dir "var/spool/postfix/pid", 0o755, 0:0)
WriteHeader(dir "var/spool/postfix", 0o2755, 100:101) # its own header
→ drwxr-xr-x 0:0
WriteHeader(dir "var/log/app", 0o755, 0:0)
WriteHeader(dir "var/log/app", 0o755, 100:101) # no special bits at all
→ drwxr-xr-x 0:0 the Chown gate alone
There's also a filesystem-vs-database divergence. lazilyInstallAPKFiles appends every header to files regardless of WriteHeader's return (install.go:341 — installed is only consulted for installedFiles), and AddInstalledPackage emits M:uid:gid:perm straight from the header (installed.go:76-113). So in the srv/data case the image ships drwxr-xr-x 0:0 while /lib/apk/db/installed claims M:100:100:2770, and runtime apk fix/verify sees a mismatch.
On the rationale in the comment — it's real, and I verified it against the actual package rather than assuming. wolfi-baselayout ships:
drwxrwxrwx root/root tmp
drwxrwxrwx root/root var/tmp
0777, no sticky bit. So that header genuinely would strip what InitDB set. But note the owner: root/root. The /tmp story justifies gating the Chmod; it says nothing about the Chown. InitDB never calls Chown anywhere — grep across pkg/apk/apk/implementation.go is empty, and Mkdir's node literal leaves uid/gid at 0 — so in tarfs, which starts empty, every pre-existing directory is 0:0 and applying the header's owner is either a no-op or a correction.
This is now a cross-path question, which is why I'd rather settle it than block on it. #2458 shipped the identical gate to main for installAPKFiles (if statErr != nil { Chown; Chmod }, MkdirAll(name, mode.Perm())), so the design is the convention in two places and it'd be odd to block the second instance of a pattern that just merged as the first. Worth settling before a third path copies it. And the gate has a genuinely stronger justification there than here: installAPKFiles can run against a DirFS over a pre-existing tree (apk.New's fallback is DirFS(ctx, "/")), where an existing directory may legitimately have a non-root owner — your own test pins tmp at 5:5 for that reason. tarfs has no such case.
Suggestion for tarfs: apply the header's mode and ownership unconditionally, but never clear a special bit already present.
existing, statErr := m.getNode(hdr.Name)
var present fs.FileMode
if statErr == nil {
present = existing.mode
} else if !errors.Is(statErr, fs.ErrNotExist) {
return false, fmt.Errorf("error checking directory %s: %w", hdr.Name, statErr)
}
mode := hdr.FileInfo().Mode()
if err := m.MkdirAll(hdr.Name, mode.Perm()); err != nil { ... }
if err := m.Chown(hdr.Name, hdr.Uid, hdr.Gid); err != nil { ... }
keep := present & (os.ModeSetuid | os.ModeSetgid | os.ModeSticky)
if err := m.Chmod(hdr.Name, (mode&^os.ModeType)|keep); err != nil { ... }I ran this. It fixes all three orderings above and the cross-package ancestor case below, subsumes the errors.Is issue, and leaves every golden digest untouched — internal/cli passes unchanged, so no regeneration. The only thing in the suite that changes is TestWriteHeaderDirExisting:291-292: /tmp keeps its sticky bit and 0777, but its uid follows the header, 0 → 100. I think that assertion encodes the gate's behaviour rather than the desired behaviour, and the real package above makes it doubly synthetic — nothing ships a non-root tmp header, so the real-world result is unchanged either way. Worth deciding deliberately rather than inheriting.
Two notes on that shape. The tradeoff is that a package can no longer remove a special bit from an existing directory — the same tradeoff the current gate makes, just far narrower, and worth stating in the comment. And I've put Chown before Chmod to match what you landed in #2458; in tarfs the order is immaterial (Chown touches only uid/gid, and Chmod re-applies the node's type bits at fs.go:654), but your own comment there argues the paths shouldn't disagree, and right now they do.
Tests. The created == false branch is only reached via tfs.Mkdir — the InitDB shape — never through WriteHeader, which is the branch's own gating function and the path real installs take. All three losing orderings pass the current suite untouched. The same gap exists in #2458, where TestInstallAPKFilesModesAndOwnership orders its fixture "dirs first, so they exist before the files under them" and its only pre-existing case is again Mkdir-created.
While you're in here: a directory header now walks the tree five times (getNode probe, Chmod, Chown, Chtimes, on top of MkdirAll), each redoing the same O(depth) walk with a mutex per component, where it used to walk twice. If MkdirAll returned (*node, created bool) you'd drop three walks and the probe.
Should fix before merge
created := statErr != nil is too broad (fs.go:122). getNode also returns "maximum symlink depth exceeded" (fs.go:238) and errors bubbled from resolving intermediate symlink targets; for those, created becomes true although nothing was created. Latent today because MkdirAll fails on the same inputs first, but the coupling is invisible and unpinned. errors.Is(statErr, fs.ErrNotExist) — the sketch above does this. Same one-word issue now exists at install.go.
hdr.Uid/hdr.Gid reach the image unchecked, and the two output formats disagree. The pinned go-erofs does e.ino.uid = uint32(uid) (mkfs.go:320), so uid -1 becomes 4294967295 and 1<<33 becomes 0; archive/tar instead promotes both to PAX records, and an extractor reading uid=-1 calls chown(-1) — "leave as the extracting user". Both reproduce. Pre-PR this couldn't happen because nodes were always 0:0, so it's a new input path. A 0 <= uid <= math.MaxUint32 check at the copy point keeps the formats in agreement. Out-of-range values also flip go-erofs's compact-inode selection (layout.go:54), so it isn't purely cosmetic.
Directory coverage has no real-apk fixture, and setuid-on-a-directory is asserted nowhere despite being in the PR description — the dir test covers 0o2755|0o1000 only. Every directory in all four fixture apks is root/root 0755, which is why the regenerated goldens exercise only the file/symlink half; that's also why the hand-built tar.Header literals never hit the ordering case, since a real apk lists parents before children. wolfi-baselayout would make a much better fixture for this — it ships tmp/var/tmp at 0777 and root at 0700, which is precisely the shape the dir branch needs to get right.
Adjacent and pre-existing — your call whether to fold in
writeHeader trusts S_IFMT bits in the header's Mode field (fs.go:476, 540). Not introduced here, but this PR adds uid:/gid: on those exact lines, and headerFileInfo.Mode() ORs Go mode-type bits in from Mode. A TypeReg header with Mode=0o120777 and a Linkname installs as a symlink to the attacker's target, bypassing the default: arm at fs.go:198. All six S_IFMT values are accepted, and the tar and erofs writers disagree on which are fatal:
header Mode |
node becomes | tar writer | erofs writer |
|---|---|---|---|
0o120777 + Linkname |
symlink | Typeflag 2, target from Linkname |
symlink |
0o020644 |
char device | Typeflag 3 |
Mknod S_IFCHR |
0o060644 |
block device | Typeflag 4 |
Readnod fails → build dies |
0o010644 |
fifo | Typeflag 6 |
Mknod S_IFIFO |
0o040755 |
dir, node.dir == false |
build dies, "unknown file mode" | — |
0o140644 |
socket | build dies, "sockets not supported" | Mknod S_IFSOCK |
Four silently produce the wrong node type while the database records a regular file; two are build-killing. It needs a package you already chose to install, so it isn't urgent — and the PR only amplifies it (a forged node now also carries attacker uid/gid rather than always 0:0). The streaming path is unaffected: it dispatches on Typeflag and its new Chmod correctly masks with &^ fs.ModeType.
The obvious fix doesn't work — copying &^ os.ModeType across from your dir branch strips ModeSymlink from real symlinks and TestTarFS fails with file is not a link, because writeHeader serves both TypeReg and TypeSymlink. The type bits have to come from Typeflag:
func nodeModeFromHeader(header *tar.Header) fs.FileMode {
mode := header.FileInfo().Mode() &^ os.ModeType
if header.Typeflag == tar.TypeSymlink {
mode |= os.ModeSymlink
}
return mode
}Verified: closes all six and keeps the suite green.
Same-checksum short circuit keeps the first package's metadata (fs.go:512, and :157 for symlinks). etc/conf shipped 0644 1:1 by one package and 0600 100:101 by another with identical content stays -rw-r--r-- 1:1 while the database records a:100:101:0600 — the same divergence, on the file half, newly observable now that ownership is load-bearing. This needs a decision rather than a patch: does the later package's metadata win, or is first-wins intentional? Either way, a comment and a test.
Follow-ups I'd file rather than ask you to fix here
- The symlink-to-dir early return above your new code is dead (
fs.go:112).getNoderesolves symlinks for every component including the last, soStatreturns the target'sFileInfoandModeSymlinkis never set —Lstattoo. ATypeDirheader namingusr/lib64 -> libfalls through, returnsinstalled=trueinstead offalse, andChtimesat:140retimes the targetusr/lib(reproduced: the stamp lands onusr/lib). #2458 restructured the equivalent atinstall.go:219but kept the sameStat-based test, so it's still dead there. Flagging it here only because your newcreatedprobe sits directly underneath and shares the same symlink-resolvinggetNode. - Invented ancestors that no header ever describes keep the leaf's mode. A lone
home/alice/.ssh0700 header produceshomeandhome/aliceasdrwx------; a 0777 leaf produces 0777. This matchesos.MkdirAll, and the cross-package variant is fixed by the change above, so it's the narrow residue — but for an image builder, 0755 for invented intermediates is probably more defensible than inheriting the leaf's. - Hardlink members take the target's uid/gid, not their own header's.
usr/bin/aat 5:6 thenTypeLink usr/bin/b -> adeclaring 7:8 gives both 5:6, while the database records 7:8 forb. Arguably correct for a genuine hardlink — one inode, one owner — but the assumption that the headers agree is now load-bearing and pinned nowhere. memFileInfoembeds*node, so tarfs'sfs.FileInfois a live view rather than a stat snapshot (fs.go:997-1009). Everyos-backed implementation is a snapshot, so a before/after check written against this API compares the mutated node with itself and reports "unchanged". This cost me a false negative on the retiming above on first pass. Worth a comment on the type at minimum;pkg/apk/fs/memfs.golooks the same.pkg/cpio/layer.gonever setsRecord.UID/GID, declared out of scope in #2458, soapko build-cpiooutput still lands0:0regardless of either change.
Nits
- The four new tests differ only in the header fed to
WriteHeaderand the expected uid/gid/mode — one table with named rows would make the missing ordering cases a one-line addition each. Also mixest.Fatal(err)withrequire.NoError, andrequire.Zeroat:291-292carries no identifying message whererequire.Zerofat:259-260does. dirReader(fs_test.go:178) re-declares whatapkfs.ReaderFSalready has — bothReadDirandReadlinkare on it, andinternal/cli/build.go:269passestarfs.New()straight in where aFullFSis wanted.
Reviewed with Claude Code assistance. Every behavioural claim above was reproduced against a45a6a4 rebased onto c7be9d8f with a table-driven probe harness; happy to share it as a starting point for the tests.
|
Two additions since the review above: a class of metadata I didn't cover, and some evidence from your own melange change that bears on the gate question. xattrs on symlink headersI only looked at mode and ownership. xattrs travel the same PAX path, and the A symlink's own SELinux label is lost and misapplied. Symlinks legitimately carry Then both writers exclude symlinks from xattr emission ( And the redirection launders metadata past the file-conflict check. A capability xattr is only honoured on a regular file, so that's where it has to land — and an attacking package can't ship the victim's path directly:
Suggested fix, and #2458 already set the precedent. From that PR's body:
Exactly right for ownership, and the xattr path wants the same treatment: skip the xattr loop for The general shape is that Two smaller notes while I'm here. The melange#2646 makes the gate case better than the comment here doesFrom that PR body:
That's a better justification than the comment at Probes for all of the above are in the same harness as the earlier findings; happy to share. The one thing I could not verify locally is the kernel's rule for |
Follow-up to a finding mattmoor made while reviewing #2418:
Confirmed, and it turned out to be broader than directories.
What was wrong
pkg/tarfs/fs.gonever carried tar-header metadata onto its nodes:MkdirAll(name, hdr.FileInfo().Mode().Perm())—.Perm()masks to 0o777, so setuid/setgid/sticky were dropped0:0The loss is format-neutral, as Matt said, because both writers read the same node:
pkg/build/tarball.gobuilds output headers withtar.FileInfoHeader, which takes special bits fromFileInfo.Mode()and Uid/Gid fromFileInfo.Sys(), andpkg/build/erofs.goreads that sameSys()header.Impact, measured
An
apko buildof a wolfi postfix image, diffed against what the apk actually declares:/var/spool/postfixand/var/lib/postfixare uid 100 in the apk (four of them also gid 100 or 101). The built image had all 15 as0:0./usr/bin/postdropand/usr/bin/postqueueare mode0o2755gid 101 — setgid to the unprivilegedpostdropgroup. apko emitted mode0o2755gid 0, i.e. setgid to root. That is a privilege change rather than cosmetics, which is why this PR does not stop at the directory half.After the fix all 17 entries match the apk, and nothing else in the image moves.
apko erofs lson an--format erofsbuild of the same config agrees.The change
Two commits, one per half:
tarfs: keep dir mode bits and ownership from tar headers—MkdirAllas before for the permission bits, then a separateChmodfor setuid/setgid/sticky, plus aChown.tarfs: keep file and symlink ownership from tar headers— setuid/gidfrom the header whenwriteHeaderbuilds the node.One deliberate subtlety in the first commit: the
ChmodandChownapply only to directories that header actually created.InitDBcreates/tmpas 1777 before any package installs, and packages ship atmpdirectory header with no sticky bit —wolfi-baselayoutrunschmod 1777, but the published apk records plain0777(checked at the raw tar-header bytes). Applying dir modes unconditionally would therefore take the sticky bit off/tmpin every wolfi-based image.MkdirAllhas to invent are not described by the header, so they must not inherit its ownership.Both cases are covered by tests. Each of the four new assertions was mutation-checked — back out either half of the fix, or the newly-created-only condition, and the tests fail.
Golden fixtures
The second commit moves the golden images, and the reason is worth stating: the apks under
internal/cli/testdata/packagesdeclare their files as uid 501 gid 20, the account on the machine that built them, and apko now reproduces that faithfully./etc/os-releaseis the only entry whose metadata changes in either golden image (verified by extracting and diffing the layer entries).testdata/goldenwas rebuilt with the same optionsTestBuildpasses, after first confirming that command reproduces the committed golden byte-identically when built against the unfixed code.testdata/top_imagewas rebuilt with the existinginternal/cli/testdata/regenerate_golden_top_image.sh.go test ./...andmake lintare clean.Deliberately not in this PR
installAPKFilesinpkg/apk/apk/install.gohas the identical.Perm()mask and no chown at all, for files as well as directories. In-tree the consumer isapko build-cpio, which runsBuildLayeragainstapkfs.DirFS; melange's package builds are not affected, since those usetarfs.New()and are covered by this PR.wolfi-baselayoutasks for 1777 on/tmpand/var/tmp; the published r29 apk records0777for both./tmpgets 1777 in images today only because apko'sbaseDirectoriessets it, which is why/var/tmpships as0777.InitDB, also in apk: streaming install path drops directory mode bits and all uid/gid #2456:stat.Mode().Perm() != e.permscompares a maskedPerm()against0o777|fs.ModeSticky, so it can never be equal for/tmp. If/tmpever pre-exists atInitDBtime, that errors with "incorrect permissions". I did not establish a reachable caller, so I left it alone.🤖 Generated with Claude Code