Skip to content

fix(plugin): extract plugin archives through os.Root - #32774

Open
Thanatos9404 wants to merge 1 commit into
helm:mainfrom
Thanatos9404:harden/plugin-extract-os-root
Open

Thanatos9404 wants to merge 1 commit into
helm:mainfrom
Thanatos9404:harden/plugin-extract-os-root

Conversation

@Thanatos9404

Copy link
Copy Markdown

What this PR does / why we need it:

Plugin archives are extracted in two places: TarGzExtractor in internal/plugin/installer/extractor.go, used by the HTTP and local installers, and a second copy of the same tar loop in the OCI installer. Both build each output path with cleanJoin, which calls securejoin.SecureJoin, and then write with the os package.

That path is only right at the moment it is computed. The filepath-securejoin README describes the SecureJoin API as "fundamentally unsafe" when path components can change between the join and the use, and recommends newer APIs for new code. SecureJoin also deals with a symlink in the target directory that points outside it by rewriting it into a path inside the target, so extraction carries on and writes somewhere the archive did not name.

Plugin installs writing outside their directory have come up before: GHSA-qq3j-xp49-j73f (plugin archive directory traversal, 2020) and GHSA-vmx8-mqv2-9gmg (plugin install writing outside the plugins directory through the version field), as have similar chart cases (GHSA-hr2v-4r36-88hr, GHSA-557j-xg8c-q2mm). All of those are fixed. This PR does not fix a known vulnerability. It removes the path joining from plugin extraction, so that an archive entry cannot be written outside the target directory by construction.

The change:

  • One extractTar in extractor.go, built on os.Root, is used by TarGzExtractor.Extract and by the OCI installer. Every directory and file is created through the root. The OCI copy of the loop is removed, so each file is now closed as soon as it has been written instead of when the function returns.
  • cleanJoin becomes cleanPath. It keeps the existing checks for :, .. and absolute paths, with the same error messages, and returns the cleaned relative path instead of joining it.
  • Files are created with os.FileMode(header.Mode).Perm(). Root.OpenFile rejects anything beyond permission bits, and os.OpenFile already ignored the tar setuid, setgid, sticky and file type bits, so ordinary archives get exactly the modes they got before.

os.Root was added in Go 1.24 and Root.MkdirAll in Go 1.25; main and dev-v3 both declare go 1.26.0. #32773 does the same for chart expansion. Once both are merged nothing imports github.com/cyphar/filepath-securejoin any more, and I will add the go mod tidy change to whichever of the two is merged second (or send it separately).

Special notes for your reviewer:

For well formed plugin archives nothing changes: same files and layout, directories 0755, file permissions from the header minus umask, symlink and other unsupported entry types still rejected with unknown type, and the same errors for :, .. and absolute paths. The behaviour changes, all for unusual input, are:

  1. A symlink already in the target directory that leads outside it (a directory symlink with an absolute or relative target, or a file symlink) now makes extraction fail with an error ending in path escapes from parent. Before, SecureJoin rewrote it into a path inside the target and extraction continued there.
  2. Absolute symlinks are refused even when they point inside the target, because os.Root does not follow absolute links. Relative symlinks that stay inside the target are still followed.
  3. A header mode carrying Go's own setuid, setgid or sticky FileMode bits (1<<23, 1<<22, 1<<20) used to set those bits on the file through os.OpenFile; now only the permission bits are used. This overlaps with Sanitize attacker-controlled file modes in plugin extraction #32249.
  4. On Windows, an entry named like a reserved device such as con now fails with path escapes from parent instead of opening the device. (Names containing : were already rejected.)

Related work: #31387 also removes the OCI copy of extractTar; whichever lands first, I am happy to rebase. #32414, #32347 and #32398 fix the deferred Close in the OCI loop on their own.

Tests: internal/plugin/installer/extractor_test.go runs every case through both TarGzExtractor and the OCI entry point. One table test feeds archives that try to leave the target (.., an absolute path, a symlink entry followed by a write through it) and targets that already contain symlinks pointing outside (directory symlink with an absolute and a relative target, a directory entry through such a symlink, and a file symlink); every case must fail and leave the outside directory empty. Another checks that a well formed archive (a ./ entry, explicit and implied directories, a pax global header, and modes 0644, 0755, 0600, 04755, 02755, 01755, 0100755, 0100644) extracts with the same content, owner permission bits and no special bits. A third checks that a relative symlink inside the target is still followed. TestCleanJoin becomes TestCleanPath with the same cases.

On main, the 8 cases with an existing symlink pointing outside fail (4 per entry point): extraction succeeds and writes at the rewritten path. Every other new test passes on main and on this branch. I ran the package tests on Linux; they do not build on Windows on main either (syscall.Umask in http_installer_test.go).

If applicable:

  • this PR contains user facing changes (the docs needed label should be applied if so)
  • this PR contains unit tests
  • this PR has been tested for backwards compatibility

The HTTP and local installers (TarGzExtractor) and the OCI installer
each had their own copy of the tar extraction loop. Both built output
paths with cleanJoin, which calls securejoin.SecureJoin, and then wrote
through the os package. The joined path is only valid at the moment it
is computed: the filepath-securejoin documentation notes that
SecureJoin cannot protect against path components that change before
the path is used. A symlink in the target directory pointing outside
it was also rewritten into a path inside the target instead of being
refused.

Use a single extractTar for all three installers that opens the target
directory with os.Root and creates every directory and file through
it, so no entry can be written outside the target directory. The OCI
copy of the loop is removed, which also means each file is closed as
soon as it is written instead of when the function returns.

cleanJoin becomes cleanPath. It keeps the existing checks for ':',
'..' and absolute paths and returns a clean relative path instead of
joining it. Files are created with the permission bits of the header
mode, which is what os.OpenFile applied for ordinary modes.

Behaviour for well formed plugin archives is unchanged. Changes for
unusual input: a symlink in the target directory that leaves it, or
any absolute symlink, now makes extraction fail instead of being
rewritten; relative symlinks that stay inside are still followed.
Header modes carrying Go's setuid, setgid or sticky FileMode bits no
longer set them on the extracted file.

Signed-off-by: Yashvardhan Thanvi <[email protected]>
Copilot AI balanced review requested due to automatic review settings October 18, 2026 12:30
@pull-request-size pull-request-size Bot added the size/L Denotes a PR that changes 100-499 lines, ignoring generated files. label Oct 18, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot added the v4.x Issues and Pull Requests related to the major version v4 label Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L Denotes a PR that changes 100-499 lines, ignoring generated files. v4.x Issues and Pull Requests related to the major version v4

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants