Skip to content

fix(security): extract archives through an os.Root handle - #196

Merged
tas50 merged 2 commits into
mainfrom
fix/extract-contained-by-os-root
Sep 8, 2026
Merged

fix(security): extract archives through an os.Root handle#196
tas50 merged 2 commits into
mainfrom
fix/extract-contained-by-os-root

Conversation

@tas50

@tas50 tas50 commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Addresses the open go-path-traversal code scanning alerts (#10, #11, #12, #16, #21).

What the scanner found

Five alerts across the three tarball extractors:

Alert Location
#10, #11, #12 cli/cookbook/extract.go (cookbook install)
#16 cli/policyfile/extract.go (policyfile cookbook fetch)
#21 cli/policyfile/rubyeval/loader.go (ruby.wasm loader)

Each site already guarded containment with filepath.Rel, which CodeQL does not model as a sanitizer. So the immediate question was whether these are real.

What is actually wrong

The guards are correct for what they check, and the existing ../ tests still pass. But the check is lexical: it reasons about the entry name as a string. An entry like nginx/cache/evil.rb is provably inside the destination by every string comparison available.

The write happens against the filesystem. If the destination already holds a cache symlink pointing elsewhere, the create follows it and the entry lands outside the directory that was just validated. The same gap covers the TOCTOU case, where a symlink appears between the check and the write.

Is it exploitable today?

No. All three destinations are freshly created directories (os.MkdirTemp, or RemoveAll followed by MkdirAll), so there is no pre-existing symlink to follow, and all three extractors skip symlink and hardlink entries, so a hostile archive cannot plant one mid-stream.

That safety rests entirely on an unwritten invariant: every caller must pass a fresh directory. Nothing in the extractors enforces it, and nothing warns a future caller who reuses a directory. This change removes the invariant rather than documenting it.

The change

Open the destination as an os.Root and route every MkdirAll and OpenFile through that handle. Containment is enforced per path component at open time, so an escape fails at the syscall no matter what the name looked like.

The lexical check stays: it still produces the better error message, naming the offending archive entry, and it rejects bad input before any I/O.

Tests

Each extractor gets a test that plants a symlink in the destination and asserts the entry does not escape through it. All three fail before this change:

--- FAIL: TestExtractArchiveDoesNotFollowSymlinkOutOfDest
    archive entry escaped destDir through a pre-existing symlink
--- FAIL: TestExtractCookbookTarballDoesNotFollowSymlinkOutOfDest
    archive entry escaped dest through a pre-existing symlink
--- FAIL: TestExtractTarGzDoesNotFollowSymlinkOutOfDest
    archive entry escaped dest through a pre-existing symlink

go build ./..., go vet ./..., and gofmt are clean. cli/cookbook, cli/policyfile, and cli/policyfile/rubyeval all pass, including the full rubyeval suite that exercises real ruby.wasm extraction through the changed path.

Not covered here

Two other path traversal alerts on the security tab are not addressed, because neither is a defect:

  • Add cinc client create command #20 (ruby-path-traversal, critical) in cli/policyfile/rubyeval/generate_goldens.rb. A developer-only golden generator that is never compiled into the binary. The flagged File.read(policyfile_path) reads paths produced by Dir.glob over a fixed testdata directory, with no external input. Recommend dismissing as a false positive rather than contorting a dev script to satisfy the scanner.
  • fix(policyfile): keep a repository source path inside the clone #183 already covers a genuine path traversal in cli/policyfile/fetch.go (a git source's path escaping the clone) and is unrelated to these.

The three tarball extractors (cookbook install, policyfile cookbook fetch,
and the ruby.wasm loader) each checked containment with filepath.Rel before
writing an entry. That check is lexical: it reasons about the entry name as
a string, and a name like "nginx/cache/evil.rb" is provably inside the
destination by every string comparison we can make.

The write, though, happens against the filesystem. If the destination
already holds a "cache" symlink pointing elsewhere, the create follows it
and the entry lands outside the destination that was just validated. The
same gap covers the TOCTOU case, where the symlink appears between the
check and the write.

Open the destination as an os.Root and route every MkdirAll and OpenFile
through that handle. Containment is then enforced per path component when
the file is opened, so an escape fails at the syscall regardless of what
the name looked like. The lexical check stays, since it still produces the
better error message, naming the offending archive entry.

No caller can reach this today: all three destinations are freshly created
directories (os.MkdirTemp, or RemoveAll followed by MkdirAll), so there is
no pre-existing symlink to follow, and all three extractors already skip
symlink and hardlink entries so an archive cannot plant one mid-stream.
This removes the unwritten "callers must pass a fresh directory" invariant
that was holding that safety up, and clears the five go-path-traversal
code scanning alerts, which flag the filepath.Rel guard because CodeQL does
not model it as a sanitizer.

Each extractor gets a test that plants a symlink in the destination and
asserts the entry does not escape through it. All three fail before this
change.

Signed-off-by: Tim Smith <tim@mondoo.com>
Comment thread cli/policyfile/extract.go Fixed
The entry name is already relative by the time it is validated, so joining
it onto the destination and relativizing it straight back off was a round
trip that computed the same string it started with.

Check the relative path directly instead. Same rejection semantics, with
one difference that only tightens things: an absolute entry name is now
refused outright rather than being silently rewritten to sit under the
destination, which is what os.Root does with it anyway.

This also drops the filepath.Join call that the xgrep scanner matched on.
The suggested replacement in that alert, strings.HasPrefix against a
canonical base, is the weaker check: it accepts a sibling directory sharing
a prefix ("/tmp/foobar/evil" passes a "/tmp/foo" prefix test), so it is not
adopted here.

Signed-off-by: Tim Smith <tim@mondoo.com>
@tas50
tas50 merged commit 16ef5e0 into main Sep 8, 2026
6 checks passed
@tas50
tas50 deleted the fix/extract-contained-by-os-root branch September 8, 2026 17:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants