pax_global_header00006660000000000000000000000064147013777440014530gustar00rootroot0000000000000052 comment=fd16ade2aea75ef1ad2b9e8144316f1931bd2df3 filepath-securejoin-0.3.4/000077500000000000000000000000001470137774400154745ustar00rootroot00000000000000filepath-securejoin-0.3.4/.github/000077500000000000000000000000001470137774400170345ustar00rootroot00000000000000filepath-securejoin-0.3.4/.github/dependabot.yml000066400000000000000000000006771470137774400216760ustar00rootroot00000000000000# Please see the documentation for all configuration options: # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: # Dependencies in go.mod. - package-ecosystem: "gomod" directory: "/" schedule: interval: "daily" # Dependencies in .github/workflows/*.yml. - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" filepath-securejoin-0.3.4/.github/workflows/000077500000000000000000000000001470137774400210715ustar00rootroot00000000000000filepath-securejoin-0.3.4/.github/workflows/ci.yml000066400000000000000000000075451470137774400222220ustar00rootroot00000000000000name: ci on: push: tags: - "v*" branches: - main pull_request: schedule: - cron: "30 10 * * 0" jobs: test-windows: strategy: fail-fast: false matrix: go-version: - "1.21" - "1.22" - "^1" runs-on: windows-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} - name: mkdir gocoverdir run: | # mktemp --tmpdir -d gocoverdir.XXXXXXXX function New-TemporaryDirectory { param ( [string] $Prefix ) $parent = [System.IO.Path]::GetTempPath() do { [string] $guid = [System.Guid]::NewGuid() $item = New-Item -Path "$parent" -Name "$Prefix.$guid" -ItemType "directory" -ErrorAction SilentlyContinue } while (-not "$item") return $item.FullName } $GOCOVERDIR = (New-TemporaryDirectory -Prefix "gocoverdir") echo "GOCOVERDIR=$GOCOVERDIR" >>"$env:GITHUB_ENV" - name: unit tests run: go test -v -cover '-test.gocoverdir' "$env:GOCOVERDIR" ./... - name: upload coverage uses: actions/upload-artifact@v4 with: name: coverage-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }} path: ${{ env.GOCOVERDIR }} test-unix: strategy: fail-fast: false matrix: os: - ubuntu-latest - macos-latest go-version: - "1.21" - "1.22" - "^1" runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} - name: mkdir gocoverdir run: | GOCOVERDIR="$(mktemp --tmpdir -d gocoverdir.XXXXXXXX)" echo "GOCOVERDIR=$GOCOVERDIR" >>"$GITHUB_ENV" - name: go test run: go test -v -cover -test.gocoverdir="$GOCOVERDIR" ./... - name: sudo go test run: sudo go test -v -cover -test.gocoverdir="$GOCOVERDIR" ./... - name: upload coverage uses: actions/upload-artifact@v4 with: name: coverage-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }} path: ${{ env.GOCOVERDIR }} coverage: runs-on: ubuntu-latest needs: - test-windows - test-unix steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: "^1" - name: download all coverage uses: actions/download-artifact@v4 with: path: coverage - name: generate coverage list run: | find coverage/ GOCOVERDIRS="$(printf '%s,' coverage/* | sed 's|,$||')" echo "GOCOVERDIRS=$GOCOVERDIRS" >>"$GITHUB_ENV" FULLCOVERAGE_FILE="$(mktemp --tmpdir fullcoverage.XXXXXXXX)" echo "FULLCOVERAGE_FILE=$FULLCOVERAGE_FILE" >>"$GITHUB_ENV" - name: compute coverage run: go tool covdata percent -i "$GOCOVERDIRS" - name: compute func coverage run: go tool covdata func -i "$GOCOVERDIRS" | sort -k 3gr - name: merge coverage run: | go tool covdata textfmt -i "$GOCOVERDIRS" -o "$FULLCOVERAGE_FILE" go tool cover -html="$FULLCOVERAGE_FILE" -o "$FULLCOVERAGE_FILE.html" - name: upload merged coverage uses: actions/upload-artifact@v4 with: name: fullcoverage-${{ github.job }} path: ${{ env.FULLCOVERAGE_FILE }} - name: upload coverage html uses: actions/upload-artifact@v4 with: name: fullcoverage-${{ github.job }}.html path: ${{ env.FULLCOVERAGE_FILE }}.html codespell: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: pip install codespell==v2.3.0 - run: codespell filepath-securejoin-0.3.4/CHANGELOG.md000066400000000000000000000200031470137774400173000ustar00rootroot00000000000000# Changelog # All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ## ## [0.3.4] - 2024-10-09 ## ### Fixed ### - Previously, some testing mocks we had resulted in us doing `import "testing"` in non-`_test.go` code, which made some downstreams like Kubernetes unhappy. This has been fixed. (#32) ## [0.3.3] - 2024-09-30 ## ### Fixed ### - The mode and owner verification logic in `MkdirAll` has been removed. This was originally intended to protect against some theoretical attacks but upon further consideration these protections don't actually buy us anything and they were causing spurious errors with more complicated filesystem setups. - The "is the created directory empty" logic in `MkdirAll` has also been removed. This was not causing us issues yet, but some pseudofilesystems (such as `cgroup`) create non-empty directories and so this logic would've been wrong for such cases. ## [0.3.2] - 2024-09-13 ## ### Changed ### - Passing the `S_ISUID` or `S_ISGID` modes to `MkdirAllInRoot` will now return an explicit error saying that those bits are ignored by `mkdirat(2)`. In the past a different error was returned, but since the silent ignoring behaviour is codified in the man pages a more explicit error seems apt. While silently ignoring these bits would be the most compatible option, it could lead to users thinking their code sets these bits when it doesn't. Programs that need to deal with compatibility can mask the bits themselves. (#23, #25) ### Fixed ### - If a directory has `S_ISGID` set, then all child directories will have `S_ISGID` set when created and a different gid will be used for any inode created under the directory. Previously, the "expected owner and mode" validation in `securejoin.MkdirAll` did not correctly handle this. We now correctly handle this case. (#24, #25) ## [0.3.1] - 2024-07-23 ## ### Changed ### - By allowing `Open(at)InRoot` to opt-out of the extra work done by `MkdirAll` to do the necessary "partial lookups", `Open(at)InRoot` now does less work for both implementations (resulting in a many-fold decrease in the number of operations for `openat2`, and a modest improvement for non-`openat2`) and is far more guaranteed to match the correct `openat2(RESOLVE_IN_ROOT)` behaviour. - We now use `readlinkat(fd, "")` where possible. For `Open(at)InRoot` this effectively just means that we no longer risk getting spurious errors during rename races. However, for our hardened procfs handler, this in theory should prevent mount attacks from tricking us when doing magic-link readlinks (even when using the unsafe host `/proc` handle). Unfortunately `Reopen` is still potentially vulnerable to those kinds of somewhat-esoteric attacks. Technically this [will only work on post-2.6.39 kernels][linux-readlinkat-emptypath] but it seems incredibly unlikely anyone is using `filepath-securejoin` on a pre-2011 kernel. ### Fixed ### - Several improvements were made to the errors returned by `Open(at)InRoot` and `MkdirAll` when dealing with invalid paths under the emulated (ie. non-`openat2`) implementation. Previously, some paths would return the wrong error (`ENOENT` when the last component was a non-directory), and other paths would be returned as though they were acceptable (trailing-slash components after a non-directory would be ignored by `Open(at)InRoot`). These changes were done to match `openat2`'s behaviour and purely is a consistency fix (most users are going to be using `openat2` anyway). [linux-readlinkat-emptypath]: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=65cfc6722361570bfe255698d9cd4dccaf47570d ## [0.3.0] - 2024-07-11 ## ### Added ### - A new set of `*os.File`-based APIs have been added. These are adapted from [libpathrs][] and we strongly suggest using them if possible (as they provide far more protection against attacks than `SecureJoin`): - `Open(at)InRoot` resolves a path inside a rootfs and returns an `*os.File` handle to the path. Note that the handle returned is an `O_PATH` handle, which cannot be used for reading or writing (as well as some other operations -- [see open(2) for more details][open.2]) - `Reopen` takes an `O_PATH` file handle and safely re-opens it to upgrade it to a regular handle. This can also be used with non-`O_PATH` handles, but `O_PATH` is the most obvious application. - `MkdirAll` is an implementation of `os.MkdirAll` that is safe to use to create a directory tree within a rootfs. As these are new APIs, they may change in the future. However, they should be safe to start migrating to as we have extensive tests ensuring they behave correctly and are safe against various races and other attacks. [libpathrs]: https://github.com/openSUSE/libpathrs [open.2]: https://www.man7.org/linux/man-pages/man2/open.2.html ## [0.2.5] - 2024-05-03 ## ### Changed ### - Some minor changes were made to how lexical components (like `..` and `.`) are handled during path generation in `SecureJoin`. There is no behaviour change as a result of this fix (the resulting paths are the same). ### Fixed ### - The error returned when we hit a symlink loop now references the correct path. (#10) ## [0.2.4] - 2023-09-06 ## ### Security ### - This release fixes a potential security issue in filepath-securejoin when used on Windows ([GHSA-6xv5-86q9-7xr8][], which could be used to generate paths outside of the provided rootfs in certain cases), as well as improving the overall behaviour of filepath-securejoin when dealing with Windows paths that contain volume names. Thanks to Paulo Gomes for discovering and fixing these issues. ### Fixed ### - Switch to GitHub Actions for CI so we can test on Windows as well as Linux and MacOS. [GHSA-6xv5-86q9-7xr8]: https://github.com/advisories/GHSA-6xv5-86q9-7xr8 ## [0.2.3] - 2021-06-04 ## ### Changed ### - Switch to Go 1.13-style `%w` error wrapping, letting us drop the dependency on `github.com/pkg/errors`. ## [0.2.2] - 2018-09-05 ## ### Changed ### - Use `syscall.ELOOP` as the base error for symlink loops, rather than our own (internal) error. This allows callers to more easily use `errors.Is` to check for this case. ## [0.2.1] - 2018-09-05 ## ### Fixed ### - Use our own `IsNotExist` implementation, which lets us handle `ENOTDIR` properly within `SecureJoin`. ## [0.2.0] - 2017-07-19 ## We now have 100% test coverage! ### Added ### - Add a `SecureJoinVFS` API that can be used for mocking (as we do in our new tests) or for implementing custom handling of lookup operations (such as for rootless containers, where work is necessary to access directories with weird modes because we don't have `CAP_DAC_READ_SEARCH` or `CAP_DAC_OVERRIDE`). ## 0.1.0 - 2017-07-19 This is our first release of `github.com/cyphar/filepath-securejoin`, containing a full implementation with a coverage of 93.5% (the only missing cases are the error cases, which are hard to mocktest at the moment). [Unreleased]: https://github.com/cyphar/filepath-securejoin/compare/v0.3.4...HEAD [0.3.3]: https://github.com/cyphar/filepath-securejoin/compare/v0.3.3...v0.3.4 [0.3.3]: https://github.com/cyphar/filepath-securejoin/compare/v0.3.2...v0.3.3 [0.3.2]: https://github.com/cyphar/filepath-securejoin/compare/v0.3.1...v0.3.2 [0.3.1]: https://github.com/cyphar/filepath-securejoin/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/cyphar/filepath-securejoin/compare/v0.2.5...v0.3.0 [0.2.5]: https://github.com/cyphar/filepath-securejoin/compare/v0.2.4...v0.2.5 [0.2.4]: https://github.com/cyphar/filepath-securejoin/compare/v0.2.3...v0.2.4 [0.2.3]: https://github.com/cyphar/filepath-securejoin/compare/v0.2.2...v0.2.3 [0.2.2]: https://github.com/cyphar/filepath-securejoin/compare/v0.2.1...v0.2.2 [0.2.1]: https://github.com/cyphar/filepath-securejoin/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/cyphar/filepath-securejoin/compare/v0.1.0...v0.2.0 filepath-securejoin-0.3.4/LICENSE000066400000000000000000000030141470137774400164770ustar00rootroot00000000000000Copyright (C) 2014-2015 Docker Inc & Go Authors. All rights reserved. Copyright (C) 2017-2024 SUSE LLC. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. filepath-securejoin-0.3.4/README.md000066400000000000000000000161171470137774400167610ustar00rootroot00000000000000## `filepath-securejoin` ## [![Go Documentation](https://pkg.go.dev/badge/github.com/cyphar/filepath-securejoin.svg)](https://pkg.go.dev/github.com/cyphar/filepath-securejoin) [![Build Status](https://github.com/cyphar/filepath-securejoin/actions/workflows/ci.yml/badge.svg)](https://github.com/cyphar/filepath-securejoin/actions/workflows/ci.yml) ### Old API ### This library was originally just an implementation of `SecureJoin` which was [intended to be included in the Go standard library][go#20126] as a safer `filepath.Join` that would restrict the path lookup to be inside a root directory. The implementation was based on code that existed in several container runtimes. Unfortunately, this API is **fundamentally unsafe** against attackers that can modify path components after `SecureJoin` returns and before the caller uses the path, allowing for some fairly trivial TOCTOU attacks. `SecureJoin` (and `SecureJoinVFS`) are still provided by this library to support legacy users, but new users are strongly suggested to avoid using `SecureJoin` and instead use the [new api](#new-api) or switch to [libpathrs][libpathrs]. With the above limitations in mind, this library guarantees the following: * If no error is set, the resulting string **must** be a child path of `root` and will not contain any symlink path components (they will all be expanded). * When expanding symlinks, all symlink path components **must** be resolved relative to the provided root. In particular, this can be considered a userspace implementation of how `chroot(2)` operates on file paths. Note that these symlinks will **not** be expanded lexically (`filepath.Clean` is not called on the input before processing). * Non-existent path components are unaffected by `SecureJoin` (similar to `filepath.EvalSymlinks`'s semantics). * The returned path will always be `filepath.Clean`ed and thus not contain any `..` components. A (trivial) implementation of this function on GNU/Linux systems could be done with the following (note that this requires root privileges and is far more opaque than the implementation in this library, and also requires that `readlink` is inside the `root` path and is trustworthy): ```go package securejoin import ( "os/exec" "path/filepath" ) func SecureJoin(root, unsafePath string) (string, error) { unsafePath = string(filepath.Separator) + unsafePath cmd := exec.Command("chroot", root, "readlink", "--canonicalize-missing", "--no-newline", unsafePath) output, err := cmd.CombinedOutput() if err != nil { return "", err } expanded := string(output) return filepath.Join(root, expanded), nil } ``` [libpathrs]: https://github.com/openSUSE/libpathrs [go#20126]: https://github.com/golang/go/issues/20126 ### New API ### While we recommend users switch to [libpathrs][libpathrs] as soon as it has a stable release, some methods implemented by libpathrs have been ported to this library to ease the transition. These APIs are only supported on Linux. These APIs are implemented such that `filepath-securejoin` will opportunistically use certain newer kernel APIs that make these operations far more secure. In particular: * All of the lookup operations will use [`openat2`][openat2.2] on new enough kernels (Linux 5.6 or later) to restrict lookups through magic-links and bind-mounts (for certain operations) and to make use of `RESOLVE_IN_ROOT` to efficiently resolve symlinks within a rootfs. * The APIs provide hardening against a malicious `/proc` mount to either detect or avoid being tricked by a `/proc` that is not legitimate. This is done using [`openat2`][openat2.2] for all users, and privileged users will also be further protected by using [`fsopen`][fsopen.2] and [`open_tree`][open_tree.2] (Linux 5.2 or later). [openat2.2]: https://www.man7.org/linux/man-pages/man2/openat2.2.html [fsopen.2]: https://github.com/brauner/man-pages-md/blob/main/fsopen.md [open_tree.2]: https://github.com/brauner/man-pages-md/blob/main/open_tree.md #### `OpenInRoot` #### ```go func OpenInRoot(root, unsafePath string) (*os.File, error) func OpenatInRoot(root *os.File, unsafePath string) (*os.File, error) func Reopen(handle *os.File, flags int) (*os.File, error) ``` `OpenInRoot` is a much safer version of ```go path, err := securejoin.SecureJoin(root, unsafePath) file, err := os.OpenFile(path, unix.O_PATH|unix.O_CLOEXEC) ``` that protects against various race attacks that could lead to serious security issues, depending on the application. Note that the returned `*os.File` is an `O_PATH` file descriptor, which is quite restricted. Callers will probably need to use `Reopen` to get a more usable handle (this split is done to provide useful features like PTY spawning and to avoid users accidentally opening bad inodes that could cause a DoS). Callers need to be careful in how they use the returned `*os.File`. Usually it is only safe to operate on the handle directly, and it is very easy to create a security issue. [libpathrs][libpathrs] provides far more helpers to make using these handles safer -- there is currently no plan to port them to `filepath-securejoin`. `OpenatInRoot` is like `OpenInRoot` except that the root is provided using an `*os.File`. This allows you to ensure that multiple `OpenatInRoot` (or `MkdirAllHandle`) calls are operating on the same rootfs. > **NOTE**: Unlike `SecureJoin`, `OpenInRoot` will error out as soon as it hits > a dangling symlink or non-existent path. This is in contrast to `SecureJoin` > which treated non-existent components as though they were real directories, > and would allow for partial resolution of dangling symlinks. These behaviours > are at odds with how Linux treats non-existent paths and dangling symlinks, > and so these are no longer allowed. #### `MkdirAll` #### ```go func MkdirAll(root, unsafePath string, mode int) error func MkdirAllHandle(root *os.File, unsafePath string, mode int) (*os.File, error) ``` `MkdirAll` is a much safer version of ```go path, err := securejoin.SecureJoin(root, unsafePath) err = os.MkdirAll(path, mode) ``` that protects against the same kinds of races that `OpenInRoot` protects against. `MkdirAllHandle` is like `MkdirAll` except that the root is provided using an `*os.File` (the reason for this is the same as with `OpenatInRoot`) and an `*os.File` of the final created directory is returned (this directory is guaranteed to be effectively identical to the directory created by `MkdirAllHandle`, which is not possible to ensure by just using `OpenatInRoot` after `MkdirAll`). > **NOTE**: Unlike `SecureJoin`, `MkdirAll` will error out as soon as it hits > a dangling symlink or non-existent path. This is in contrast to `SecureJoin` > which treated non-existent components as though they were real directories, > and would allow for partial resolution of dangling symlinks. These behaviours > are at odds with how Linux treats non-existent paths and dangling symlinks, > and so these are no longer allowed. This means that `MkdirAll` will not > create non-existent directories referenced by a dangling symlink. ### License ### The license of this project is the same as Go, which is a BSD 3-clause license available in the `LICENSE` file. filepath-securejoin-0.3.4/VERSION000066400000000000000000000000061470137774400165400ustar00rootroot000000000000000.3.4 filepath-securejoin-0.3.4/doc.go000066400000000000000000000043001470137774400165650ustar00rootroot00000000000000// Copyright (C) 2014-2015 Docker Inc & Go Authors. All rights reserved. // Copyright (C) 2017-2024 SUSE LLC. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. // Package securejoin implements a set of helpers to make it easier to write Go // code that is safe against symlink-related escape attacks. The primary idea // is to let you resolve a path within a rootfs directory as if the rootfs was // a chroot. // // securejoin has two APIs, a "legacy" API and a "modern" API. // // The legacy API is [SecureJoin] and [SecureJoinVFS]. These methods are // **not** safe against race conditions where an attacker changes the // filesystem after (or during) the [SecureJoin] operation. // // The new API is made up of [OpenInRoot] and [MkdirAll] (and derived // functions). These are safe against racing attackers and have several other // protections that are not provided by the legacy API. There are many more // operations that most programs expect to be able to do safely, but we do not // provide explicit support for them because we want to encourage users to // switch to [libpathrs](https://github.com/openSUSE/libpathrs) which is a // cross-language next-generation library that is entirely designed around // operating on paths safely. // // securejoin has been used by several container runtimes (Docker, runc, // Kubernetes, etc) for quite a few years as a de-facto standard for operating // on container filesystem paths "safely". However, most users still use the // legacy API which is unsafe against various attacks (there is a fairly long // history of CVEs in dependent as a result). Users should switch to the modern // API as soon as possible (or even better, switch to libpathrs). // // This project was initially intended to be included in the Go standard // library, but [it was rejected](https://go.dev/issue/20126). There is now a // [new Go proposal](https://go.dev/issue/67002) for a safe path resolution API // that shares some of the goals of filepath-securejoin. However, that design // is intended to work like `openat2(RESOLVE_BENEATH)` which does not fit the // usecase of container runtimes and most system tools. package securejoin filepath-securejoin-0.3.4/go.mod000066400000000000000000000004241470137774400166020ustar00rootroot00000000000000module github.com/cyphar/filepath-securejoin go 1.21 require ( github.com/stretchr/testify v1.9.0 golang.org/x/sys v0.21.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) filepath-securejoin-0.3.4/go.sum000066400000000000000000000020121470137774400166220ustar00rootroot00000000000000github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= filepath-securejoin-0.3.4/join.go000066400000000000000000000111401470137774400167570ustar00rootroot00000000000000// Copyright (C) 2014-2015 Docker Inc & Go Authors. All rights reserved. // Copyright (C) 2017-2024 SUSE LLC. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package securejoin import ( "errors" "os" "path/filepath" "strings" "syscall" ) const maxSymlinkLimit = 255 // IsNotExist tells you if err is an error that implies that either the path // accessed does not exist (or path components don't exist). This is // effectively a more broad version of [os.IsNotExist]. func IsNotExist(err error) bool { // Check that it's not actually an ENOTDIR, which in some cases is a more // convoluted case of ENOENT (usually involving weird paths). return errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) || errors.Is(err, syscall.ENOENT) } // SecureJoinVFS joins the two given path components (similar to [filepath.Join]) except // that the returned path is guaranteed to be scoped inside the provided root // path (when evaluated). Any symbolic links in the path are evaluated with the // given root treated as the root of the filesystem, similar to a chroot. The // filesystem state is evaluated through the given [VFS] interface (if nil, the // standard [os].* family of functions are used). // // Note that the guarantees provided by this function only apply if the path // components in the returned string are not modified (in other words are not // replaced with symlinks on the filesystem) after this function has returned. // Such a symlink race is necessarily out-of-scope of SecureJoinVFS. // // NOTE: Due to the above limitation, Linux users are strongly encouraged to // use [OpenInRoot] instead, which does safely protect against these kinds of // attacks. There is no way to solve this problem with SecureJoinVFS because // the API is fundamentally wrong (you cannot return a "safe" path string and // guarantee it won't be modified afterwards). // // Volume names in unsafePath are always discarded, regardless if they are // provided via direct input or when evaluating symlinks. Therefore: // // "C:\Temp" + "D:\path\to\file.txt" results in "C:\Temp\path\to\file.txt" func SecureJoinVFS(root, unsafePath string, vfs VFS) (string, error) { // Use the os.* VFS implementation if none was specified. if vfs == nil { vfs = osVFS{} } unsafePath = filepath.FromSlash(unsafePath) var ( currentPath string remainingPath = unsafePath linksWalked int ) for remainingPath != "" { if v := filepath.VolumeName(remainingPath); v != "" { remainingPath = remainingPath[len(v):] } // Get the next path component. var part string if i := strings.IndexRune(remainingPath, filepath.Separator); i == -1 { part, remainingPath = remainingPath, "" } else { part, remainingPath = remainingPath[:i], remainingPath[i+1:] } // Apply the component lexically to the path we are building. // currentPath does not contain any symlinks, and we are lexically // dealing with a single component, so it's okay to do a filepath.Clean // here. nextPath := filepath.Join(string(filepath.Separator), currentPath, part) if nextPath == string(filepath.Separator) { currentPath = "" continue } fullPath := root + string(filepath.Separator) + nextPath // Figure out whether the path is a symlink. fi, err := vfs.Lstat(fullPath) if err != nil && !IsNotExist(err) { return "", err } // Treat non-existent path components the same as non-symlinks (we // can't do any better here). if IsNotExist(err) || fi.Mode()&os.ModeSymlink == 0 { currentPath = nextPath continue } // It's a symlink, so get its contents and expand it by prepending it // to the yet-unparsed path. linksWalked++ if linksWalked > maxSymlinkLimit { return "", &os.PathError{Op: "SecureJoin", Path: root + string(filepath.Separator) + unsafePath, Err: syscall.ELOOP} } dest, err := vfs.Readlink(fullPath) if err != nil { return "", err } remainingPath = dest + string(filepath.Separator) + remainingPath // Absolute symlinks reset any work we've already done. if filepath.IsAbs(dest) { currentPath = "" } } // There should be no lexical components like ".." left in the path here, // but for safety clean up the path before joining it to the root. finalPath := filepath.Join(string(filepath.Separator), currentPath) return filepath.Join(root, finalPath), nil } // SecureJoin is a wrapper around [SecureJoinVFS] that just uses the [os].* library // of functions as the [VFS]. If in doubt, use this function over [SecureJoinVFS]. func SecureJoin(root, unsafePath string) (string, error) { return SecureJoinVFS(root, unsafePath, nil) } filepath-securejoin-0.3.4/join_test.go000066400000000000000000000310661470137774400200270ustar00rootroot00000000000000// Copyright (C) 2017-2024 SUSE LLC. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package securejoin import ( "errors" "io/ioutil" "os" "path/filepath" "runtime" "syscall" "testing" ) // TODO: These tests won't work on plan9 because it doesn't have symlinks, and // also we use '/' here explicitly which probably won't work on Windows. func symlink(t *testing.T, oldname, newname string) { if err := os.Symlink(oldname, newname); err != nil { t.Fatal(err) } } type input struct { root, unsafe string expected string } // Test basic handling of symlink expansion. func TestSymlink(t *testing.T) { dir, err := ioutil.TempDir("", "TestSymlink") if err != nil { t.Fatal(err) } dir, err = filepath.EvalSymlinks(dir) if err != nil { t.Fatal(err) } defer os.RemoveAll(dir) symlink(t, "somepath", filepath.Join(dir, "etc")) symlink(t, "../../../../../../../../../../../../../etc", filepath.Join(dir, "etclink")) symlink(t, "/../../../../../../../../../../../../../etc/passwd", filepath.Join(dir, "passwd")) rootOrVol := string(filepath.Separator) if vol := filepath.VolumeName(dir); vol != "" { rootOrVol = vol + rootOrVol } tc := []input{ // Make sure that expansion with a root of '/' proceeds in the expected fashion. {rootOrVol, filepath.Join(dir, "passwd"), filepath.Join(rootOrVol, "etc", "passwd")}, {rootOrVol, filepath.Join(dir, "etclink"), filepath.Join(rootOrVol, "etc")}, {rootOrVol, filepath.Join(dir, "etc"), filepath.Join(dir, "somepath")}, // Now test scoped expansion. {dir, "passwd", filepath.Join(dir, "somepath", "passwd")}, {dir, "etclink", filepath.Join(dir, "somepath")}, {dir, "etc", filepath.Join(dir, "somepath")}, {dir, "etc/test", filepath.Join(dir, "somepath", "test")}, {dir, "etc/test/..", filepath.Join(dir, "somepath")}, } for _, test := range tc { got, err := SecureJoin(test.root, test.unsafe) if err != nil { t.Errorf("securejoin(%q, %q): unexpected error: %v", test.root, test.unsafe, err) continue } // This is only for OS X, where /etc is a symlink to /private/etc. In // principle, SecureJoin(/, pth) is the same as EvalSymlinks(pth) in // the case where the path exists. if test.root == "/" { if expected, err := filepath.EvalSymlinks(test.expected); err == nil { test.expected = expected } } if got != test.expected { t.Errorf("securejoin(%q, %q): expected %q, got %q", test.root, test.unsafe, test.expected, got) continue } } } // In a path without symlinks, SecureJoin is equivalent to Clean+Join. func TestNoSymlink(t *testing.T) { dir, err := ioutil.TempDir("", "TestNoSymlink") if err != nil { t.Fatal(err) } dir, err = filepath.EvalSymlinks(dir) if err != nil { t.Fatal(err) } defer os.RemoveAll(dir) tc := []input{ {dir, "somepath", filepath.Join(dir, "somepath")}, {dir, "even/more/path", filepath.Join(dir, "even", "more", "path")}, {dir, "/this/is/a/path", filepath.Join(dir, "this", "is", "a", "path")}, {dir, "also/a/../path/././/with/some/./.././junk", filepath.Join(dir, "also", "path", "with", "junk")}, {dir, "yetanother/../path/././/with/some/./.././junk../../../../../../../../../../../../etc/passwd", filepath.Join(dir, "etc", "passwd")}, {dir, "/../../../../../../../../../../../../../../../../etc/passwd", filepath.Join(dir, "etc", "passwd")}, {dir, "../../../../../../../../../../../../../../../../somedir", filepath.Join(dir, "somedir")}, {dir, "../../../../../../../../../../../../../../../../", filepath.Join(dir)}, {dir, "./../../.././././../../../../../../../../../../../../../../../../etc passwd", filepath.Join(dir, "etc passwd")}, } if runtime.GOOS == "windows" { tc = append(tc, []input{ {dir, "d:\\etc\\test", filepath.Join(dir, "etc", "test")}, }...) } for _, test := range tc { got, err := SecureJoin(test.root, test.unsafe) if err != nil { t.Errorf("securejoin(%q, %q): unexpected error: %v", test.root, test.unsafe, err) } if got != test.expected { t.Errorf("securejoin(%q, %q): expected %q, got %q", test.root, test.unsafe, test.expected, got) } } } // Make sure that .. is **not** expanded lexically. func TestNonLexical(t *testing.T) { dir, err := ioutil.TempDir("", "TestNonLexical") if err != nil { t.Fatal(err) } dir, err = filepath.EvalSymlinks(dir) if err != nil { t.Fatal(err) } defer os.RemoveAll(dir) os.MkdirAll(filepath.Join(dir, "subdir"), 0755) os.MkdirAll(filepath.Join(dir, "cousinparent", "cousin"), 0755) symlink(t, "../cousinparent/cousin", filepath.Join(dir, "subdir", "link")) symlink(t, "/../cousinparent/cousin", filepath.Join(dir, "subdir", "link2")) symlink(t, "/../../../../../../../../../../../../../../../../cousinparent/cousin", filepath.Join(dir, "subdir", "link3")) for _, test := range []input{ {dir, "subdir", filepath.Join(dir, "subdir")}, {dir, "subdir/link/test", filepath.Join(dir, "cousinparent", "cousin", "test")}, {dir, "subdir/link2/test", filepath.Join(dir, "cousinparent", "cousin", "test")}, {dir, "subdir/link3/test", filepath.Join(dir, "cousinparent", "cousin", "test")}, {dir, "subdir/../test", filepath.Join(dir, "test")}, // This is the divergence from a simple filepath.Clean implementation. {dir, "subdir/link/../test", filepath.Join(dir, "cousinparent", "test")}, {dir, "subdir/link2/../test", filepath.Join(dir, "cousinparent", "test")}, {dir, "subdir/link3/../test", filepath.Join(dir, "cousinparent", "test")}, } { got, err := SecureJoin(test.root, test.unsafe) if err != nil { t.Errorf("securejoin(%q, %q): unexpected error: %v", test.root, test.unsafe, err) continue } if got != test.expected { t.Errorf("securejoin(%q, %q): expected %q, got %q", test.root, test.unsafe, test.expected, got) continue } } } // Make sure that symlink loops result in errors. func TestSymlinkLoop(t *testing.T) { dir, err := ioutil.TempDir("", "TestSymlinkLoop") if err != nil { t.Fatal(err) } dir, err = filepath.EvalSymlinks(dir) if err != nil { t.Fatal(err) } defer os.RemoveAll(dir) os.MkdirAll(filepath.Join(dir, "subdir"), 0755) symlink(t, "../../../../../../../../../../../../../../../../path", filepath.Join(dir, "subdir", "link")) symlink(t, "/subdir/link", filepath.Join(dir, "path")) symlink(t, "/../../../../../../../../../../../../../../../../self", filepath.Join(dir, "self")) for _, test := range []struct { root, unsafe string }{ {dir, "subdir/link"}, {dir, "path"}, {dir, "../../path"}, {dir, "subdir/link/../.."}, {dir, "../../../../../../../../../../../../../../../../subdir/link/../../../../../../../../../../../../../../../.."}, {dir, "self"}, {dir, "self/.."}, {dir, "/../../../../../../../../../../../../../../../../self/.."}, {dir, "/self/././.."}, } { got, err := SecureJoin(test.root, test.unsafe) if !errors.Is(err, syscall.ELOOP) { t.Errorf("securejoin(%q, %q): expected ELOOP, got %q & %v", test.root, test.unsafe, got, err) continue } } } // Make sure that ENOTDIR is correctly handled. func TestEnotdir(t *testing.T) { dir, err := ioutil.TempDir("", "TestEnotdir") if err != nil { t.Fatal(err) } dir, err = filepath.EvalSymlinks(dir) if err != nil { t.Fatal(err) } defer os.RemoveAll(dir) os.MkdirAll(filepath.Join(dir, "subdir"), 0755) ioutil.WriteFile(filepath.Join(dir, "notdir"), []byte("I am not a directory!"), 0755) symlink(t, "/../../../notdir/somechild", filepath.Join(dir, "subdir", "link")) for _, test := range []struct { root, unsafe string }{ {dir, "subdir/link"}, {dir, "notdir"}, {dir, "notdir/child"}, } { _, err := SecureJoin(test.root, test.unsafe) if err != nil { t.Errorf("securejoin(%q, %q): unexpected error: %v", test.root, test.unsafe, err) continue } } } // Some silly tests to make sure that all error types are correctly handled. func TestIsNotExist(t *testing.T) { for _, test := range []struct { err error expected bool }{ {&os.PathError{Op: "test1", Err: syscall.ENOENT}, true}, {&os.LinkError{Op: "test1", Err: syscall.ENOENT}, true}, {&os.SyscallError{Syscall: "test1", Err: syscall.ENOENT}, true}, {&os.PathError{Op: "test2", Err: syscall.ENOTDIR}, true}, {&os.LinkError{Op: "test2", Err: syscall.ENOTDIR}, true}, {&os.SyscallError{Syscall: "test2", Err: syscall.ENOTDIR}, true}, {&os.PathError{Op: "test3", Err: syscall.EACCES}, false}, {&os.LinkError{Op: "test3", Err: syscall.EACCES}, false}, {&os.SyscallError{Syscall: "test3", Err: syscall.EACCES}, false}, {errors.New("not a proper error"), false}, } { got := IsNotExist(test.err) if got != test.expected { t.Errorf("IsNotExist(%#v): expected %v, got %v", test.err, test.expected, got) } } } type mockVFS struct { lstat func(path string) (os.FileInfo, error) readlink func(path string) (string, error) } func (m mockVFS) Lstat(path string) (os.FileInfo, error) { return m.lstat(path) } func (m mockVFS) Readlink(path string) (string, error) { return m.readlink(path) } // Make sure that SecureJoinVFS actually does use the given VFS interface. func TestSecureJoinVFS(t *testing.T) { dir, err := ioutil.TempDir("", "TestNonLexical") if err != nil { t.Fatal(err) } dir, err = filepath.EvalSymlinks(dir) if err != nil { t.Fatal(err) } defer os.RemoveAll(dir) os.MkdirAll(filepath.Join(dir, "subdir"), 0755) os.MkdirAll(filepath.Join(dir, "cousinparent", "cousin"), 0755) symlink(t, "../cousinparent/cousin", filepath.Join(dir, "subdir", "link")) symlink(t, "/../cousinparent/cousin", filepath.Join(dir, "subdir", "link2")) symlink(t, "/../../../../../../../../../../../../../../../../cousinparent/cousin", filepath.Join(dir, "subdir", "link3")) for _, test := range []input{ {dir, "subdir", filepath.Join(dir, "subdir")}, {dir, "subdir/link/test", filepath.Join(dir, "cousinparent", "cousin", "test")}, {dir, "subdir/link2/test", filepath.Join(dir, "cousinparent", "cousin", "test")}, {dir, "subdir/link3/test", filepath.Join(dir, "cousinparent", "cousin", "test")}, {dir, "subdir/../test", filepath.Join(dir, "test")}, // This is the divergence from a simple filepath.Clean implementation. {dir, "subdir/link/../test", filepath.Join(dir, "cousinparent", "test")}, {dir, "subdir/link2/../test", filepath.Join(dir, "cousinparent", "test")}, {dir, "subdir/link3/../test", filepath.Join(dir, "cousinparent", "test")}, } { var nLstat, nReadlink int mock := mockVFS{ lstat: func(path string) (os.FileInfo, error) { nLstat++; return os.Lstat(path) }, readlink: func(path string) (string, error) { nReadlink++; return os.Readlink(path) }, } got, err := SecureJoinVFS(test.root, test.unsafe, mock) if err != nil { t.Errorf("securejoin(%q, %q): unexpected error: %v", test.root, test.unsafe, err) continue } if got != test.expected { t.Errorf("securejoin(%q, %q): expected %q, got %q", test.root, test.unsafe, test.expected, got) continue } if nLstat == 0 && nReadlink == 0 { t.Errorf("securejoin(%q, %q): expected to use either lstat or readlink, neither were used", test.root, test.unsafe) } } } // Make sure that SecureJoinVFS actually does use the given VFS interface, and // that errors are correctly propagated. func TestSecureJoinVFSErrors(t *testing.T) { var ( lstatErr = errors.New("lstat error") readlinkErr = errors.New("readlink err") ) // Set up directory. dir, err := ioutil.TempDir("", "TestSecureJoinVFSErrors") if err != nil { t.Fatal(err) } dir, err = filepath.EvalSymlinks(dir) if err != nil { t.Fatal(err) } defer os.RemoveAll(dir) // Make a link. symlink(t, "../../../../../../../../../../../../../../../../path", filepath.Join(dir, "link")) // Define some fake mock functions. lstatFailFn := func(path string) (os.FileInfo, error) { return nil, lstatErr } readlinkFailFn := func(path string) (string, error) { return "", readlinkErr } // Make sure that the set of {lstat, readlink} failures do propagate. for idx, test := range []struct { vfs VFS expected []error }{ { expected: []error{nil}, vfs: mockVFS{ lstat: os.Lstat, readlink: os.Readlink, }, }, { expected: []error{lstatErr}, vfs: mockVFS{ lstat: lstatFailFn, readlink: os.Readlink, }, }, { expected: []error{readlinkErr}, vfs: mockVFS{ lstat: os.Lstat, readlink: readlinkFailFn, }, }, { expected: []error{lstatErr, readlinkErr}, vfs: mockVFS{ lstat: lstatFailFn, readlink: readlinkFailFn, }, }, } { _, err := SecureJoinVFS(dir, "link", test.vfs) success := false for _, exp := range test.expected { if err == exp { success = true } } if !success { t.Errorf("SecureJoinVFS.mock%d: expected to get lstatError, got %v", idx, err) } } } filepath-securejoin-0.3.4/lookup_linux.go000066400000000000000000000307721470137774400205640ustar00rootroot00000000000000//go:build linux // Copyright (C) 2024 SUSE LLC. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package securejoin import ( "errors" "fmt" "os" "path" "path/filepath" "slices" "strings" "golang.org/x/sys/unix" ) type symlinkStackEntry struct { // (dir, remainingPath) is what we would've returned if the link didn't // exist. This matches what openat2(RESOLVE_IN_ROOT) would return in // this case. dir *os.File remainingPath string // linkUnwalked is the remaining path components from the original // Readlink which we have yet to walk. When this slice is empty, we // drop the link from the stack. linkUnwalked []string } func (se symlinkStackEntry) String() string { return fmt.Sprintf("<%s>/%s [->%s]", se.dir.Name(), se.remainingPath, strings.Join(se.linkUnwalked, "/")) } func (se symlinkStackEntry) Close() { _ = se.dir.Close() } type symlinkStack []*symlinkStackEntry func (s *symlinkStack) IsEmpty() bool { return s == nil || len(*s) == 0 } func (s *symlinkStack) Close() { if s != nil { for _, link := range *s { link.Close() } // TODO: Switch to clear once we switch to Go 1.21. *s = nil } } var ( errEmptyStack = errors.New("[internal] stack is empty") errBrokenSymlinkStack = errors.New("[internal error] broken symlink stack") ) func (s *symlinkStack) popPart(part string) error { if s == nil || s.IsEmpty() { // If there is nothing in the symlink stack, then the part was from the // real path provided by the user, and this is a no-op. return errEmptyStack } if part == "." { // "." components are no-ops -- we drop them when doing SwapLink. return nil } tailEntry := (*s)[len(*s)-1] // Double-check that we are popping the component we expect. if len(tailEntry.linkUnwalked) == 0 { return fmt.Errorf("%w: trying to pop component %q of empty stack entry %s", errBrokenSymlinkStack, part, tailEntry) } headPart := tailEntry.linkUnwalked[0] if headPart != part { return fmt.Errorf("%w: trying to pop component %q but the last stack entry is %s (%q)", errBrokenSymlinkStack, part, tailEntry, headPart) } // Drop the component, but keep the entry around in case we are dealing // with a "tail-chained" symlink. tailEntry.linkUnwalked = tailEntry.linkUnwalked[1:] return nil } func (s *symlinkStack) PopPart(part string) error { if err := s.popPart(part); err != nil { if errors.Is(err, errEmptyStack) { // Skip empty stacks. err = nil } return err } // Clean up any of the trailing stack entries that are empty. for lastGood := len(*s) - 1; lastGood >= 0; lastGood-- { entry := (*s)[lastGood] if len(entry.linkUnwalked) > 0 { break } entry.Close() (*s) = (*s)[:lastGood] } return nil } func (s *symlinkStack) push(dir *os.File, remainingPath, linkTarget string) error { if s == nil { return nil } // Split the link target and clean up any "" parts. linkTargetParts := slices.DeleteFunc( strings.Split(linkTarget, "/"), func(part string) bool { return part == "" || part == "." }) // Copy the directory so the caller doesn't close our copy. dirCopy, err := dupFile(dir) if err != nil { return err } // Add to the stack. *s = append(*s, &symlinkStackEntry{ dir: dirCopy, remainingPath: remainingPath, linkUnwalked: linkTargetParts, }) return nil } func (s *symlinkStack) SwapLink(linkPart string, dir *os.File, remainingPath, linkTarget string) error { // If we are currently inside a symlink resolution, remove the symlink // component from the last symlink entry, but don't remove the entry even // if it's empty. If we are a "tail-chained" symlink (a trailing symlink we // hit during a symlink resolution) we need to keep the old symlink until // we finish the resolution. if err := s.popPart(linkPart); err != nil { if !errors.Is(err, errEmptyStack) { return err } // Push the component regardless of whether the stack was empty. } return s.push(dir, remainingPath, linkTarget) } func (s *symlinkStack) PopTopSymlink() (*os.File, string, bool) { if s == nil || s.IsEmpty() { return nil, "", false } tailEntry := (*s)[0] *s = (*s)[1:] return tailEntry.dir, tailEntry.remainingPath, true } // partialLookupInRoot tries to lookup as much of the request path as possible // within the provided root (a-la RESOLVE_IN_ROOT) and opens the final existing // component of the requested path, returning a file handle to the final // existing component and a string containing the remaining path components. func partialLookupInRoot(root *os.File, unsafePath string) (*os.File, string, error) { return lookupInRoot(root, unsafePath, true) } func completeLookupInRoot(root *os.File, unsafePath string) (*os.File, error) { handle, remainingPath, err := lookupInRoot(root, unsafePath, false) if remainingPath != "" && err == nil { // should never happen err = fmt.Errorf("[bug] non-empty remaining path when doing a non-partial lookup: %q", remainingPath) } // lookupInRoot(partial=false) will always close the handle if an error is // returned, so no need to double-check here. return handle, err } func lookupInRoot(root *os.File, unsafePath string, partial bool) (Handle *os.File, _ string, _ error) { unsafePath = filepath.ToSlash(unsafePath) // noop // This is very similar to SecureJoin, except that we operate on the // components using file descriptors. We then return the last component we // managed open, along with the remaining path components not opened. // Try to use openat2 if possible. if hasOpenat2() { return lookupOpenat2(root, unsafePath, partial) } // Get the "actual" root path from /proc/self/fd. This is necessary if the // root is some magic-link like /proc/$pid/root, in which case we want to // make sure when we do checkProcSelfFdPath that we are using the correct // root path. logicalRootPath, err := procSelfFdReadlink(root) if err != nil { return nil, "", fmt.Errorf("get real root path: %w", err) } currentDir, err := dupFile(root) if err != nil { return nil, "", fmt.Errorf("clone root fd: %w", err) } defer func() { // If a handle is not returned, close the internal handle. if Handle == nil { _ = currentDir.Close() } }() // symlinkStack is used to emulate how openat2(RESOLVE_IN_ROOT) treats // dangling symlinks. If we hit a non-existent path while resolving a // symlink, we need to return the (dir, remainingPath) that we had when we // hit the symlink (treating the symlink as though it were a regular file). // The set of (dir, remainingPath) sets is stored within the symlinkStack // and we add and remove parts when we hit symlink and non-symlink // components respectively. We need a stack because of recursive symlinks // (symlinks that contain symlink components in their target). // // Note that the stack is ONLY used for book-keeping. All of the actual // path walking logic is still based on currentPath/remainingPath and // currentDir (as in SecureJoin). var symStack *symlinkStack if partial { symStack = new(symlinkStack) defer symStack.Close() } var ( linksWalked int currentPath string remainingPath = unsafePath ) for remainingPath != "" { // Save the current remaining path so if the part is not real we can // return the path including the component. oldRemainingPath := remainingPath // Get the next path component. var part string if i := strings.IndexByte(remainingPath, '/'); i == -1 { part, remainingPath = remainingPath, "" } else { part, remainingPath = remainingPath[:i], remainingPath[i+1:] } // If we hit an empty component, we need to treat it as though it is // "." so that trailing "/" and "//" components on a non-directory // correctly return the right error code. if part == "" { part = "." } // Apply the component lexically to the path we are building. // currentPath does not contain any symlinks, and we are lexically // dealing with a single component, so it's okay to do a filepath.Clean // here. nextPath := path.Join("/", currentPath, part) // If we logically hit the root, just clone the root rather than // opening the part and doing all of the other checks. if nextPath == "/" { if err := symStack.PopPart(part); err != nil { return nil, "", fmt.Errorf("walking into root with part %q failed: %w", part, err) } // Jump to root. rootClone, err := dupFile(root) if err != nil { return nil, "", fmt.Errorf("clone root fd: %w", err) } _ = currentDir.Close() currentDir = rootClone currentPath = nextPath continue } // Try to open the next component. nextDir, err := openatFile(currentDir, part, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) switch { case err == nil: st, err := nextDir.Stat() if err != nil { _ = nextDir.Close() return nil, "", fmt.Errorf("stat component %q: %w", part, err) } switch st.Mode() & os.ModeType { case os.ModeSymlink: // readlinkat implies AT_EMPTY_PATH since Linux 2.6.39. See // Linux commit 65cfc6722361 ("readlinkat(), fchownat() and // fstatat() with empty relative pathnames"). linkDest, err := readlinkatFile(nextDir, "") // We don't need the handle anymore. _ = nextDir.Close() if err != nil { return nil, "", err } linksWalked++ if linksWalked > maxSymlinkLimit { return nil, "", &os.PathError{Op: "securejoin.lookupInRoot", Path: logicalRootPath + "/" + unsafePath, Err: unix.ELOOP} } // Swap out the symlink's component for the link entry itself. if err := symStack.SwapLink(part, currentDir, oldRemainingPath, linkDest); err != nil { return nil, "", fmt.Errorf("walking into symlink %q failed: push symlink: %w", part, err) } // Update our logical remaining path. remainingPath = linkDest + "/" + remainingPath // Absolute symlinks reset any work we've already done. if path.IsAbs(linkDest) { // Jump to root. rootClone, err := dupFile(root) if err != nil { return nil, "", fmt.Errorf("clone root fd: %w", err) } _ = currentDir.Close() currentDir = rootClone currentPath = "/" } default: // If we are dealing with a directory, simply walk into it. _ = currentDir.Close() currentDir = nextDir currentPath = nextPath // The part was real, so drop it from the symlink stack. if err := symStack.PopPart(part); err != nil { return nil, "", fmt.Errorf("walking into directory %q failed: %w", part, err) } // If we are operating on a .., make sure we haven't escaped. // We only have to check for ".." here because walking down // into a regular component component cannot cause you to // escape. This mirrors the logic in RESOLVE_IN_ROOT, except we // have to check every ".." rather than only checking after a // rename or mount on the system. if part == ".." { // Make sure the root hasn't moved. if err := checkProcSelfFdPath(logicalRootPath, root); err != nil { return nil, "", fmt.Errorf("root path moved during lookup: %w", err) } // Make sure the path is what we expect. fullPath := logicalRootPath + nextPath if err := checkProcSelfFdPath(fullPath, currentDir); err != nil { return nil, "", fmt.Errorf("walking into %q had unexpected result: %w", part, err) } } } default: if !partial { return nil, "", err } // If there are any remaining components in the symlink stack, we // are still within a symlink resolution and thus we hit a dangling // symlink. So pretend that the first symlink in the stack we hit // was an ENOENT (to match openat2). if oldDir, remainingPath, ok := symStack.PopTopSymlink(); ok { _ = currentDir.Close() return oldDir, remainingPath, err } // We have hit a final component that doesn't exist, so we have our // partial open result. Note that we have to use the OLD remaining // path, since the lookup failed. return currentDir, oldRemainingPath, err } } // If the unsafePath had a trailing slash, we need to make sure we try to // do a relative "." open so that we will correctly return an error when // the final component is a non-directory (to match openat2). In the // context of openat2, a trailing slash and a trailing "/." are completely // equivalent. if strings.HasSuffix(unsafePath, "/") { nextDir, err := openatFile(currentDir, ".", unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) if err != nil { if !partial { _ = currentDir.Close() currentDir = nil } return currentDir, "", err } _ = currentDir.Close() currentDir = nextDir } // All of the components existed! return currentDir, "", nil } filepath-securejoin-0.3.4/lookup_linux_test.go000066400000000000000000001323001470137774400216110ustar00rootroot00000000000000//go:build linux // Copyright (C) 2024 SUSE LLC. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package securejoin import ( "errors" "fmt" "os" "path/filepath" "slices" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sys/unix" ) type partialLookupFunc func(root *os.File, unsafePath string) (*os.File, string, error) type lookupResult struct { handlePath, remainingPath string err error fileType uint32 } func checkPartialLookup(t *testing.T, partialLookupFn partialLookupFunc, rootDir *os.File, unsafePath string, expected lookupResult) { handle, remainingPath, err := partialLookupFn(rootDir, unsafePath) if handle != nil { defer handle.Close() } if expected.err != nil { if assert.Error(t, err) { assert.ErrorIs(t, err, expected.err) } if expected.handlePath == "" { if handle != nil { t.Errorf("unexpected handle %q", handle.Name()) } return } } else { if expected.remainingPath != "" { t.Errorf("we expect a remaining path, but no error? %q", expected.remainingPath) } assert.NoError(t, err) } assert.NotNil(t, handle, "expected to get a handle") // Check the remainingPath. assert.Equal(t, expected.remainingPath, remainingPath, "remaining path") // Check the handle path. gotPath, err := procSelfFdReadlink(handle) require.NoError(t, err, "get real path of returned handle") assert.Equal(t, expected.handlePath, gotPath, "real handle path") // Make sure the handle matches the readlink path. assert.Equal(t, gotPath, handle.Name(), "handle.Name() matching real handle path") // Check the handle type. unixStat, err := fstat(handle) require.NoError(t, err, "fstat handle") assert.Equal(t, expected.fileType, unixStat.Mode&unix.S_IFMT, "handle S_IFMT type") } func testPartialLookup(t *testing.T, partialLookupFn partialLookupFunc) { tree := []string{ "dir a", "dir b/c/d/e/f", "file b/c/file", "symlink e /b/c/d/e", "symlink b-file b/c/file", // Dangling symlinks. "symlink a-fake1 a/fake", "symlink a-fake2 a/fake/foo/bar/..", "symlink a-fake3 a/fake/../../b", "dir c", "symlink c/a-fake1 a/fake", "symlink c/a-fake2 a/fake/foo/bar/..", "symlink c/a-fake3 a/fake/../../b", // Test non-lexical symlinks. "dir target", "dir link1", "symlink link1/target_abs /target", "symlink link1/target_rel ../target", "dir link2", "symlink link2/link1_abs /link1", "symlink link2/link1_rel ../link1", "dir link3", "symlink link3/target_abs /link2/link1_rel/target_rel", "symlink link3/target_rel ../link2/link1_rel/target_rel", "symlink link3/deep_dangling1 ../link2/link1_rel/target_rel/nonexist", "symlink link3/deep_dangling2 ../link2/link1_rel/target_rel/nonexist", // Deep dangling symlinks (with single components). "dir dangling", "symlink dangling/a b/c", "dir dangling/b", "symlink dangling/b/c ../c", "symlink dangling/c d/e", "dir dangling/d", "symlink dangling/d/e ../e", "symlink dangling/e f/../g", "dir dangling/f", "symlink dangling/g h/i/j/nonexistent", "dir dangling/h/i/j", // Deep dangling symlink using a non-dir component. "dir dangling-file", "symlink dangling-file/a b/c", "dir dangling-file/b", "symlink dangling-file/b/c ../c", "symlink dangling-file/c d/e", "dir dangling-file/d", "symlink dangling-file/d/e ../e", "symlink dangling-file/e f/../g", "dir dangling-file/f", "symlink dangling-file/g h/i/j/file/foo", "dir dangling-file/h/i/j", "file dangling-file/h/i/j/file", // Some "bad" inodes that a regular user can create. "fifo b/fifo", "sock b/sock", // Symlink loops. "dir loop", "symlink loop/basic-loop1 basic-loop1", "symlink loop/basic-loop2 /loop/basic-loop2", "symlink loop/basic-loop3 ../loop/basic-loop3", "dir loop/a", "symlink loop/a/link ../b/link", "dir loop/b", "symlink loop/b/link /loop/c/link", "dir loop/c", "symlink loop/c/link /loop/d/link", "symlink loop/d e", "dir loop/e", "symlink loop/e/link ../a/link", "symlink loop/link a/link", } root := createTree(t, tree...) rootDir, err := os.OpenFile(root, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) require.NoError(t, err) defer rootDir.Close() for name, test := range map[string]struct { unsafePath string expected lookupResult }{ // Complete lookups. "complete-dir1": {"a", lookupResult{handlePath: "/a", remainingPath: "", fileType: unix.S_IFDIR}}, "complete-dir2": {"b/c/d/e/f", lookupResult{handlePath: "/b/c/d/e/f", remainingPath: "", fileType: unix.S_IFDIR}}, "complete-dir3": {"b///././c////.//d/./././///e////.//./f//././././", lookupResult{handlePath: "/b/c/d/e/f", remainingPath: "", fileType: unix.S_IFDIR}}, "complete-fifo": {"b/fifo", lookupResult{handlePath: "/b/fifo", remainingPath: "", fileType: unix.S_IFIFO}}, "complete-sock": {"b/sock", lookupResult{handlePath: "/b/sock", remainingPath: "", fileType: unix.S_IFSOCK}}, // Partial lookups. "partial-dir-basic": {"a/b/c/d/e/f/g/h", lookupResult{handlePath: "/a", remainingPath: "b/c/d/e/f/g/h", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "partial-dir-dotdot": {"a/foo/../bar/baz", lookupResult{handlePath: "/a", remainingPath: "foo/../bar/baz", fileType: unix.S_IFDIR, err: unix.ENOENT}}, // Complete lookups of non-lexical symlinks. "nonlexical-basic-complete1": {"target", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-basic-complete2": {"target/", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-basic-complete3": {"target//", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-basic-partial": {"target/foo", lookupResult{handlePath: "/target", remainingPath: "foo", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "nonlexical-basic-partial-dotdot": {"target/../target/foo/bar/../baz", lookupResult{handlePath: "/target", remainingPath: "foo/bar/../baz", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "nonlexical-level1-abs-complete1": {"link1/target_abs", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level1-abs-complete2": {"link1/target_abs/", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level1-abs-complete3": {"link1/target_abs//", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level1-abs-partial": {"link1/target_abs/foo", lookupResult{handlePath: "/target", remainingPath: "foo", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "nonlexical-level1-abs-partial-dotdot": {"link1/target_abs/../target/foo/bar/../baz", lookupResult{handlePath: "/target", remainingPath: "foo/bar/../baz", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "nonlexical-level1-rel-complete1": {"link1/target_rel", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level1-rel-complete2": {"link1/target_rel/", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level1-rel-complete3": {"link1/target_rel//", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level1-rel-partial": {"link1/target_rel/foo", lookupResult{handlePath: "/target", remainingPath: "foo", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "nonlexical-level1-rel-partial-dotdot": {"link1/target_rel/../target/foo/bar/../baz", lookupResult{handlePath: "/target", remainingPath: "foo/bar/../baz", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "nonlexical-level2-abs-abs-complete1": {"link2/link1_abs/target_abs", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level2-abs-abs-complete2": {"link2/link1_abs/target_abs/", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level2-abs-abs-complete3": {"link2/link1_abs/target_abs//", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level2-abs-abs-partial": {"link2/link1_abs/target_abs/foo", lookupResult{handlePath: "/target", remainingPath: "foo", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "nonlexical-level2-abs-abs-partial-dotdot": {"link2/link1_abs/target_abs/../target/foo/bar/../baz", lookupResult{handlePath: "/target", remainingPath: "foo/bar/../baz", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "nonlexical-level2-abs-rel-complete1": {"link2/link1_abs/target_rel", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level2-abs-rel-complete2": {"link2/link1_abs/target_rel/", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level2-abs-rel-complete3": {"link2/link1_abs/target_rel//", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level2-abs-rel-partial": {"link2/link1_abs/target_rel/foo", lookupResult{handlePath: "/target", remainingPath: "foo", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "nonlexical-level2-abs-rel-partial-dotdot": {"link2/link1_abs/target_rel/../target/foo/bar/../baz", lookupResult{handlePath: "/target", remainingPath: "foo/bar/../baz", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "nonlexical-level2-abs-open-complete1": {"link2/link1_abs/../target", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level2-abs-open-complete2": {"link2/link1_abs/../target/", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level2-abs-open-complete3": {"link2/link1_abs/../target//", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level2-abs-open-partial": {"link2/link1_abs/../target/foo", lookupResult{handlePath: "/target", remainingPath: "foo", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "nonlexical-level2-abs-open-partial-dotdot": {"link2/link1_abs/../target/../target/foo/bar/../baz", lookupResult{handlePath: "/target", remainingPath: "foo/bar/../baz", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "nonlexical-level2-rel-abs-complete1": {"link2/link1_rel/target_abs", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level2-rel-abs-complete2": {"link2/link1_rel/target_abs/", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level2-rel-abs-complete3": {"link2/link1_rel/target_abs//", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level2-rel-abs-partial": {"link2/link1_rel/target_abs/foo", lookupResult{handlePath: "/target", remainingPath: "foo", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "nonlexical-level2-rel-abs-partial-dotdot": {"link2/link1_rel/target_abs/../target/foo/bar/../baz", lookupResult{handlePath: "/target", remainingPath: "foo/bar/../baz", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "nonlexical-level2-rel-rel-complete1": {"link2/link1_rel/target_rel", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level2-rel-rel-complete2": {"link2/link1_rel/target_rel/", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level2-rel-rel-complete3": {"link2/link1_rel/target_rel//", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level2-rel-rel-partial": {"link2/link1_rel/target_rel/foo", lookupResult{handlePath: "/target", remainingPath: "foo", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "nonlexical-level2-rel-rel-partial-dotdot": {"link2/link1_rel/target_rel/../target/foo/bar/../baz", lookupResult{handlePath: "/target", remainingPath: "foo/bar/../baz", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "nonlexical-level2-rel-open-complete1": {"link2/link1_rel/../target", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level2-rel-open-complete2": {"link2/link1_rel/../target/", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level2-rel-open-complete3": {"link2/link1_rel/../target//", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level2-rel-open-partial": {"link2/link1_rel/../target/foo", lookupResult{handlePath: "/target", remainingPath: "foo", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "nonlexical-level2-rel-open-partial-dotdot": {"link2/link1_rel/../target/../target/foo/bar/../baz", lookupResult{handlePath: "/target", remainingPath: "foo/bar/../baz", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "nonlexical-level3-abs-complete1": {"link3/target_abs", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level3-abs-complete2": {"link3/target_abs/", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level3-abs-complete3": {"link3/target_abs//", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level3-abs-partial": {"link3/target_abs/foo", lookupResult{handlePath: "/target", remainingPath: "foo", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "nonlexical-level3-abs-partial-dotdot": {"link3/target_abs/../target/foo/bar/../baz", lookupResult{handlePath: "/target", remainingPath: "foo/bar/../baz", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "nonlexical-level3-rel-complete1": {"link3/target_rel", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level3-rel-complete2": {"link3/target_rel/", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level3-rel-complete3": {"link3/target_rel//", lookupResult{handlePath: "/target", remainingPath: "", fileType: unix.S_IFDIR}}, "nonlexical-level3-rel-partial": {"link3/target_rel/foo", lookupResult{handlePath: "/target", remainingPath: "foo", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "nonlexical-level3-rel-partial-dotdot": {"link3/target_rel/../target/foo/bar/../baz", lookupResult{handlePath: "/target", remainingPath: "foo/bar/../baz", fileType: unix.S_IFDIR, err: unix.ENOENT}}, // Partial lookups due to hitting a non-directory. "partial-nondir-slash1": {"b/c/file/", lookupResult{handlePath: "/b/c/file", remainingPath: "", fileType: unix.S_IFREG, err: unix.ENOTDIR}}, "partial-nondir-slash2": {"b/c/file//", lookupResult{handlePath: "/b/c/file", remainingPath: "/", fileType: unix.S_IFREG, err: unix.ENOTDIR}}, "partial-nondir-dot": {"b/c/file/.", lookupResult{handlePath: "/b/c/file", remainingPath: ".", fileType: unix.S_IFREG, err: unix.ENOTDIR}}, "partial-nondir-dotdot1": {"b/c/file/..", lookupResult{handlePath: "/b/c/file", remainingPath: "..", fileType: unix.S_IFREG, err: unix.ENOTDIR}}, "partial-nondir-dotdot2": {"b/c/file/../foo/bar", lookupResult{handlePath: "/b/c/file", remainingPath: "../foo/bar", fileType: unix.S_IFREG, err: unix.ENOTDIR}}, "partial-nondir-symlink-slash1": {"b-file/", lookupResult{handlePath: "/b/c/file", remainingPath: "", fileType: unix.S_IFREG, err: unix.ENOTDIR}}, "partial-nondir-symlink-slash2": {"b-file//", lookupResult{handlePath: "/b/c/file", remainingPath: "/", fileType: unix.S_IFREG, err: unix.ENOTDIR}}, "partial-nondir-symlink-dot": {"b-file/.", lookupResult{handlePath: "/b/c/file", remainingPath: ".", fileType: unix.S_IFREG, err: unix.ENOTDIR}}, "partial-nondir-symlink-dotdot1": {"b-file/..", lookupResult{handlePath: "/b/c/file", remainingPath: "..", fileType: unix.S_IFREG, err: unix.ENOTDIR}}, "partial-nondir-symlink-dotdot2": {"b-file/../foo/bar", lookupResult{handlePath: "/b/c/file", remainingPath: "../foo/bar", fileType: unix.S_IFREG, err: unix.ENOTDIR}}, "partial-fifo-slash1": {"b/fifo/", lookupResult{handlePath: "/b/fifo", remainingPath: "", fileType: unix.S_IFIFO, err: unix.ENOTDIR}}, "partial-fifo-slash2": {"b/fifo//", lookupResult{handlePath: "/b/fifo", remainingPath: "/", fileType: unix.S_IFIFO, err: unix.ENOTDIR}}, "partial-fifo-dot": {"b/fifo/.", lookupResult{handlePath: "/b/fifo", remainingPath: ".", fileType: unix.S_IFIFO, err: unix.ENOTDIR}}, "partial-fifo-dotdot1": {"b/fifo/..", lookupResult{handlePath: "/b/fifo", remainingPath: "..", fileType: unix.S_IFIFO, err: unix.ENOTDIR}}, "partial-fifo-dotdot2": {"b/fifo/../foo/bar", lookupResult{handlePath: "/b/fifo", remainingPath: "../foo/bar", fileType: unix.S_IFIFO, err: unix.ENOTDIR}}, "partial-sock-slash1": {"b/sock/", lookupResult{handlePath: "/b/sock", remainingPath: "", fileType: unix.S_IFSOCK, err: unix.ENOTDIR}}, "partial-sock-slash2": {"b/sock//", lookupResult{handlePath: "/b/sock", remainingPath: "/", fileType: unix.S_IFSOCK, err: unix.ENOTDIR}}, "partial-sock-dot": {"b/sock/.", lookupResult{handlePath: "/b/sock", remainingPath: ".", fileType: unix.S_IFSOCK, err: unix.ENOTDIR}}, "partial-sock-dotdot1": {"b/sock/..", lookupResult{handlePath: "/b/sock", remainingPath: "..", fileType: unix.S_IFSOCK, err: unix.ENOTDIR}}, "partial-sock-dotdot2": {"b/sock/../foo/bar", lookupResult{handlePath: "/b/sock", remainingPath: "../foo/bar", fileType: unix.S_IFSOCK, err: unix.ENOTDIR}}, // Dangling symlinks are treated as though they are non-existent. "dangling1-inroot-trailing": {"a-fake1", lookupResult{handlePath: "/", remainingPath: "a-fake1", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "dangling1-inroot-partial": {"a-fake1/foo", lookupResult{handlePath: "/", remainingPath: "a-fake1/foo", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "dangling1-inroot-partial-dotdot": {"a-fake1/../bar/baz", lookupResult{handlePath: "/", remainingPath: "a-fake1/../bar/baz", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "dangling1-sub-trailing": {"c/a-fake1", lookupResult{handlePath: "/c", remainingPath: "a-fake1", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "dangling1-sub-partial": {"c/a-fake1/foo", lookupResult{handlePath: "/c", remainingPath: "a-fake1/foo", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "dangling1-sub-partial-dotdot": {"c/a-fake1/../bar/baz", lookupResult{handlePath: "/c", remainingPath: "a-fake1/../bar/baz", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "dangling2-inroot-trailing": {"a-fake2", lookupResult{handlePath: "/", remainingPath: "a-fake2", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "dangling2-inroot-partial": {"a-fake2/foo", lookupResult{handlePath: "/", remainingPath: "a-fake2/foo", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "dangling2-inroot-partial-dotdot": {"a-fake2/../bar/baz", lookupResult{handlePath: "/", remainingPath: "a-fake2/../bar/baz", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "dangling2-sub-trailing": {"c/a-fake2", lookupResult{handlePath: "/c", remainingPath: "a-fake2", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "dangling2-sub-partial": {"c/a-fake2/foo", lookupResult{handlePath: "/c", remainingPath: "a-fake2/foo", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "dangling2-sub-partial-dotdot": {"c/a-fake2/../bar/baz", lookupResult{handlePath: "/c", remainingPath: "a-fake2/../bar/baz", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "dangling3-inroot-trailing": {"a-fake3", lookupResult{handlePath: "/", remainingPath: "a-fake3", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "dangling3-inroot-partial": {"a-fake3/foo", lookupResult{handlePath: "/", remainingPath: "a-fake3/foo", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "dangling3-inroot-partial-dotdot": {"a-fake3/../bar/baz", lookupResult{handlePath: "/", remainingPath: "a-fake3/../bar/baz", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "dangling3-sub-trailing": {"c/a-fake3", lookupResult{handlePath: "/c", remainingPath: "a-fake3", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "dangling3-sub-partial": {"c/a-fake3/foo", lookupResult{handlePath: "/c", remainingPath: "a-fake3/foo", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "dangling3-sub-partial-dotdot": {"c/a-fake3/../bar/baz", lookupResult{handlePath: "/c", remainingPath: "a-fake3/../bar/baz", fileType: unix.S_IFDIR, err: unix.ENOENT}}, // Tricky dangling symlinks. "dangling-tricky1-trailing": {"link3/deep_dangling1", lookupResult{handlePath: "/link3", remainingPath: "deep_dangling1", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "dangling-tricky1-partial": {"link3/deep_dangling1/foo", lookupResult{handlePath: "/link3", remainingPath: "deep_dangling1/foo", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "dangling-tricky1-partial-dotdot": {"link3/deep_dangling1/..", lookupResult{handlePath: "/link3", remainingPath: "deep_dangling1/..", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "dangling-tricky2-trailing": {"link3/deep_dangling2", lookupResult{handlePath: "/link3", remainingPath: "deep_dangling2", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "dangling-tricky2-partial": {"link3/deep_dangling2/foo", lookupResult{handlePath: "/link3", remainingPath: "deep_dangling2/foo", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "dangling-tricky2-partial-dotdot": {"link3/deep_dangling2/..", lookupResult{handlePath: "/link3", remainingPath: "deep_dangling2/..", fileType: unix.S_IFDIR, err: unix.ENOENT}}, // Really deep dangling links. "deep-dangling1": {"dangling/a", lookupResult{handlePath: "/dangling", remainingPath: "a", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "deep-dangling2": {"dangling/b/c", lookupResult{handlePath: "/dangling/b", remainingPath: "c", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "deep-dangling3": {"dangling/c", lookupResult{handlePath: "/dangling", remainingPath: "c", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "deep-dangling4": {"dangling/d/e", lookupResult{handlePath: "/dangling/d", remainingPath: "e", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "deep-dangling5": {"dangling/e", lookupResult{handlePath: "/dangling", remainingPath: "e", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "deep-dangling6": {"dangling/g", lookupResult{handlePath: "/dangling", remainingPath: "g", fileType: unix.S_IFDIR, err: unix.ENOENT}}, "deep-dangling-fileasdir1": {"dangling-file/a", lookupResult{handlePath: "/dangling-file", remainingPath: "a", fileType: unix.S_IFDIR, err: unix.ENOTDIR}}, "deep-dangling-fileasdir2": {"dangling-file/b/c", lookupResult{handlePath: "/dangling-file/b", remainingPath: "c", fileType: unix.S_IFDIR, err: unix.ENOTDIR}}, "deep-dangling-fileasdir3": {"dangling-file/c", lookupResult{handlePath: "/dangling-file", remainingPath: "c", fileType: unix.S_IFDIR, err: unix.ENOTDIR}}, "deep-dangling-fileasdir4": {"dangling-file/d/e", lookupResult{handlePath: "/dangling-file/d", remainingPath: "e", fileType: unix.S_IFDIR, err: unix.ENOTDIR}}, "deep-dangling-fileasdir5": {"dangling-file/e", lookupResult{handlePath: "/dangling-file", remainingPath: "e", fileType: unix.S_IFDIR, err: unix.ENOTDIR}}, "deep-dangling-fileasdir6": {"dangling-file/g", lookupResult{handlePath: "/dangling-file", remainingPath: "g", fileType: unix.S_IFDIR, err: unix.ENOTDIR}}, // Symlink loops. "loop": {"loop/link", lookupResult{err: unix.ELOOP}}, "loop-basic1": {"loop/basic-loop1", lookupResult{err: unix.ELOOP}}, "loop-basic2": {"loop/basic-loop2", lookupResult{err: unix.ELOOP}}, "loop-basic3": {"loop/basic-loop3", lookupResult{err: unix.ELOOP}}, } { test := test // copy iterator // Update the handlePath to be inside our root. if test.expected.handlePath != "" { test.expected.handlePath = filepath.Join(root, test.expected.handlePath) } t.Run(name, func(t *testing.T) { checkPartialLookup(t, partialLookupFn, rootDir, test.unsafePath, test.expected) }) } } func TestPartialLookupInRoot(t *testing.T) { withWithoutOpenat2(t, true, func(t *testing.T) { testPartialLookup(t, partialLookupInRoot) }) } func TestPartialOpenat2(t *testing.T) { testPartialLookup(t, partialLookupOpenat2) } func TestPartialLookupInRoot_BadInode(t *testing.T) { requireRoot(t) // mknod withWithoutOpenat2(t, true, func(t *testing.T) { partialLookupFn := partialLookupInRoot tree := []string{ // Make sure we don't open "bad" inodes. "dir foo", "char foo/whiteout 0 0", "block foo/whiteout-blk 0 0", } root := createTree(t, tree...) rootDir, err := os.OpenFile(root, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) require.NoError(t, err) defer rootDir.Close() for name, test := range map[string]struct { unsafePath string expected lookupResult }{ // Complete lookups. "char-trailing": {"foo/whiteout", lookupResult{handlePath: "/foo/whiteout", remainingPath: "", fileType: unix.S_IFCHR}}, "blk-trailing": {"foo/whiteout-blk", lookupResult{handlePath: "/foo/whiteout-blk", remainingPath: "", fileType: unix.S_IFBLK}}, // Partial lookups due to hitting a non-directory. "char-dot": {"foo/whiteout/.", lookupResult{handlePath: "/foo/whiteout", remainingPath: ".", fileType: unix.S_IFCHR, err: unix.ENOTDIR}}, "char-dotdot1": {"foo/whiteout/..", lookupResult{handlePath: "/foo/whiteout", remainingPath: "..", fileType: unix.S_IFCHR, err: unix.ENOTDIR}}, "char-dotdot2": {"foo/whiteout/../foo/bar", lookupResult{handlePath: "/foo/whiteout", remainingPath: "../foo/bar", fileType: unix.S_IFCHR, err: unix.ENOTDIR}}, "blk-dot": {"foo/whiteout-blk/.", lookupResult{handlePath: "/foo/whiteout-blk", remainingPath: ".", fileType: unix.S_IFBLK, err: unix.ENOTDIR}}, "blk-dotdot1": {"foo/whiteout-blk/..", lookupResult{handlePath: "/foo/whiteout-blk", remainingPath: "..", fileType: unix.S_IFBLK, err: unix.ENOTDIR}}, "blk-dotdot2": {"foo/whiteout-blk/../foo/bar", lookupResult{handlePath: "/foo/whiteout-blk", remainingPath: "../foo/bar", fileType: unix.S_IFBLK, err: unix.ENOTDIR}}, } { test := test // copy iterator // Update the handlePath to be inside our root. if test.expected.handlePath != "" { test.expected.handlePath = filepath.Join(root, test.expected.handlePath) } t.Run(name, func(t *testing.T) { checkPartialLookup(t, partialLookupFn, rootDir, test.unsafePath, test.expected) }) } }) } type racingLookupMeta struct { pauseCh chan struct{} passOkCount, passErrCount, skipCount, failCount, badErrCount int // test state counts badNameCount, fixRemainingPathCount int // workaround counts skipErrCounts map[error]int } func newRacingLookupMeta(pauseCh chan struct{}) *racingLookupMeta { return &racingLookupMeta{ pauseCh: pauseCh, skipErrCounts: map[error]int{}, } } func (m *racingLookupMeta) checkPartialLookup(t *testing.T, rootDir *os.File, unsafePath string, skipErrs []error, allowedResults []lookupResult) { // Similar to checkPartialLookup, but with extra logic for // handling the lookup stopping partly through the lookup. handle, remainingPath, err := partialLookupInRoot(rootDir, unsafePath) var ( handleName string realPath string unixStat unix.Stat_t ) if handle != nil { handleName = handle.Name() // Get the "proper" name from procSelfFdReadlink. m.pauseCh <- struct{}{} realPath, err = procSelfFdReadlink(handle) <-m.pauseCh require.NoError(t, err, "get real path of returned handle") unixStat, err = fstat(handle) require.NoError(t, err, "stat handle") _ = handle.Close() } else if err != nil { for _, skipErr := range skipErrs { if errors.Is(err, skipErr) { m.skipErrCounts[skipErr]++ m.skipCount++ return } } for _, allowed := range allowedResults { if allowed.err != nil && errors.Is(err, allowed.err) { m.passErrCount++ return } } // If we didn't hit any of the allowed errors, it's an // unexpected error. assert.NoError(t, err) m.badErrCount++ return } if realPath != handleName { // It's possible for handle.Name() to be wrong because while it was // correct when it was set, it might not match if the path was swapped // afterwards (for both openat2 and partialLookupInRoot). m.badNameCount++ } // It's possible for lookups with ".." components to decide to cut off the // lookup partially through the resolution when dealing with a swapping // attack, so for the purposes of validating our tests we clean up the // remainingPath so that it has all of the ".." components removed (but // include this in our statistics). fullLogicalPath := filepath.Join(realPath, remainingPath) newRemainingPath, err := filepath.Rel(realPath, fullLogicalPath) require.NoErrorf(t, err, "clean remaining path %s", remainingPath) if remainingPath != newRemainingPath { m.fixRemainingPathCount++ } remainingPath = newRemainingPath gotResult := lookupResult{ handlePath: realPath, remainingPath: remainingPath, fileType: unixStat.Mode & unix.S_IFMT, } counter := &m.passOkCount if !assert.Contains(t, allowedResults, gotResult) { counter = &m.failCount } (*counter)++ } func TestPartialLookup_RacingRename(t *testing.T) { if !hasRenameExchange() { t.Skip("test requires RENAME_EXCHANGE support") } withWithoutOpenat2(t, false, func(t *testing.T) { tree := []string{ "dir a/b/c/d", "symlink b-link ../b/../b/../b/../b/../b/../b/../b/../b/../b/../b/../b/../b/../b", "symlink c-link ../../b/c/../../b/c/../../b/c/../../b/c/../../b/c/../../b/c/../../b/c/../../b/c/../../b/c", "file file", "symlink bad-link /foobar", } var ( handlePath = "/a/b/c/d" remainingPath = "e" defaultExpected []lookupResult ) // The lookup could stop at any component other than /a, so allow all // of them. for handlePath != "/" { defaultExpected = append(defaultExpected, lookupResult{ handlePath: handlePath, remainingPath: remainingPath, fileType: unix.S_IFDIR, }) handlePath, remainingPath = filepath.Dir(handlePath), filepath.Join(filepath.Base(handlePath), remainingPath) } for name, test := range map[string]struct { subPathA, subPathB string unsafePath string skipErrs []error allowedResults []lookupResult }{ // Swap a symlink in and out. "swap-dir-link1-basic": {"a/b", "b-link", "a/b/c/d/e", nil, slices.Clone(defaultExpected)}, "swap-dir-link2-basic": {"a/b/c", "c-link", "a/b/c/d/e", nil, slices.Clone(defaultExpected)}, "swap-dir-link1-dotdot1": {"a/b", "b-link", "a/b/../b/../b/../b/../b/../b/../b/c/d/../d/../d/../d/../d/../d/e", nil, slices.Clone(defaultExpected)}, "swap-dir-link1-dotdot2": {"a/b", "b-link", "a/b/c/../c/../c/../c/../c/../c/../c/d/../d/../d/../d/../d/../d/e", nil, slices.Clone(defaultExpected)}, "swap-dir-link2-dotdot": {"a/b/c", "c-link", "a/b/c/../c/../c/../c/../c/../c/../c/d/../d/../d/../d/../d/../d/e", nil, slices.Clone(defaultExpected)}, // TODO: Swap a directory. // Swap a non-directory. "swap-dir-file-basic": {"a/b", "file", "a/b/c/d/e", []error{unix.ENOTDIR, unix.ENOENT}, append( // We could hit one of the final paths. slices.Clone(defaultExpected), // We could hit the file and stop resolving. lookupResult{handlePath: "/file", remainingPath: "c/d/e", fileType: unix.S_IFREG}, )}, "swap-dir-file-dotdot": {"a/b", "file", "a/b/c/../c/../c/../c/../c/../c/../c/d/../d/../d/../d/../d/../d/e", []error{unix.ENOTDIR, unix.ENOENT}, append( // We could hit one of the final paths. slices.Clone(defaultExpected), // We could hit the file and stop resolving. lookupResult{handlePath: "/file", remainingPath: "c/d/e", fileType: unix.S_IFREG}, )}, // Swap a dangling symlink. "swap-dir-danglinglink-basic": {"a/b", "bad-link", "a/b/c/d/e", []error{unix.ENOENT}, slices.Clone(defaultExpected)}, "swap-dir-danglinglink-dotdot": {"a/b", "bad-link", "a/b/c/../c/../c/../c/../c/../c/../c/d/../d/../d/../d/../d/../d/e", []error{unix.ENOENT}, slices.Clone(defaultExpected)}, // Swap the root. "swap-root-basic": {".", "../outsideroot", "a/b/c/d/e", nil, slices.Clone(defaultExpected)}, "swap-root-dotdot": {".", "../outsideroot", "a/b/../../a/b/../../a/b/../../a/b/../../a/b/../../a/b/../../a/b/../../a/b/c/d/e", nil, slices.Clone(defaultExpected)}, "swap-root-dotdot-extra": {".", "../outsideroot", "a/" + strings.Repeat("b/c/d/../../../", 10) + "b/c/d/e", nil, slices.Clone(defaultExpected)}, // Swap one of our walking paths outside the root. "swap-dir-outsideroot-basic": {"a/b", "../outsideroot", "a/b/c/d/e", nil, append( // We could hit the expected path. slices.Clone(defaultExpected), // We could also land in the "outsideroot" path. This is okay // because there was a moment when this directory was inside // the root, and the attacker moved it outside the root. If we // were to go into "..", the lookup would've failed (and we // would get an error here if that wasn't the case). lookupResult{handlePath: "../outsideroot", remainingPath: "c/d/e", fileType: unix.S_IFDIR}, lookupResult{handlePath: "../outsideroot/c", remainingPath: "d/e", fileType: unix.S_IFDIR}, lookupResult{handlePath: "../outsideroot/c/d", remainingPath: "e", fileType: unix.S_IFDIR}, )}, "swap-dir-outsideroot-dotdot": {"a/b", "../outsideroot", "a/b/../../a/b/../../a/b/../../a/b/../../a/b/../../a/b/../../a/b/../../a/b/c/d/e", nil, append( // We could hit the expected path. slices.Clone(defaultExpected), // We could also land in the "outsideroot" path. This is okay // because there was a moment when this directory was inside // the root, and the attacker moved it outside the root. // // Neither openat2 nor partialLookupInRoot will allow us to // walk into ".." in this case (escaping the root), and we // would catch that if it did happen. lookupResult{handlePath: "../outsideroot", remainingPath: "c/d/e", fileType: unix.S_IFDIR}, lookupResult{handlePath: "../outsideroot/c", remainingPath: "d/e", fileType: unix.S_IFDIR}, lookupResult{handlePath: "../outsideroot/c/d", remainingPath: "e", fileType: unix.S_IFDIR}, )}, } { test := test // copy iterator test.skipErrs = append(test.skipErrs, errPossibleAttack, errPossibleBreakout) t.Run(name, func(t *testing.T) { root := createTree(t, tree...) // Update the handlePath to be inside our root. for idx := range test.allowedResults { test.allowedResults[idx].handlePath = filepath.Join(root, test.allowedResults[idx].handlePath) } // Create an "outsideroot" path as a sibling to our root, for // swapping. err := os.MkdirAll(filepath.Join(root, "../outsideroot"), 0o755) require.NoError(t, err) rootDir, err := os.OpenFile(root, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) require.NoError(t, err) defer rootDir.Close() // If the swapping subpaths are "." we need to use an absolute // path because renaming "." isn't allowed. for _, subPath := range []*string{&test.subPathA, &test.subPathB} { if filepath.Join(root, *subPath) == root { *subPath = root } } // Run a goroutine that spams a rename in the root. pauseCh := make(chan struct{}) exitCh := make(chan struct{}) defer close(exitCh) go doRenameExchangeLoop(pauseCh, exitCh, rootDir, test.subPathA, test.subPathB) // Do several runs to try to catch bugs. const testRuns = 50000 m := newRacingLookupMeta(pauseCh) for i := 0; i < testRuns; i++ { m.checkPartialLookup(t, rootDir, test.unsafePath, test.skipErrs, test.allowedResults) } pct := func(count int) string { return fmt.Sprintf("%d(%.3f%%)", count, 100.0*float64(count)/float64(testRuns)) } // Output some stats. t.Logf("after %d runs: passOk=%s passErr=%s skip=%s fail=%s (+badErr=%s)", // runs and breakdown of path-related (pass, fail) as well as skipped runs testRuns, pct(m.passOkCount), pct(m.passErrCount), pct(m.skipCount), pct(m.failCount), // failures due to incorrect errors (rather than bad paths) pct(m.badErrCount)) t.Logf(" badHandleName=%s fixRemainingPath=%s", // stats for how many test runs had to have some "workarounds" pct(m.badNameCount), pct(m.fixRemainingPathCount)) if len(m.skipErrCounts) > 0 { t.Logf(" skipErr breakdown:") for err, count := range m.skipErrCounts { t.Logf(" %3.d: %v", count, err) } } }) } }) } type ssOperation interface { String() string Do(*testing.T, *symlinkStack) error } type ssOpPop struct{ part string } func (op ssOpPop) Do(t *testing.T, s *symlinkStack) error { return s.PopPart(op.part) } func (op ssOpPop) String() string { return fmt.Sprintf("PopPart(%q)", op.part) } type ssOpSwapLink struct { part, dirName, expectedPath, linkTarget string } func fakeFile(name string) (*os.File, error) { fd, err := unix.Open(".", unix.O_PATH|unix.O_CLOEXEC, 0) if err != nil { return nil, &os.PathError{Op: "open", Path: ".", Err: err} } return os.NewFile(uintptr(fd), name), nil } func (op ssOpSwapLink) Do(t *testing.T, s *symlinkStack) error { f, err := fakeFile(op.dirName) require.NoErrorf(t, err, "make fake file with %q name", op.dirName) return s.SwapLink(op.part, f, op.expectedPath, op.linkTarget) } func (op ssOpSwapLink) String() string { return fmt.Sprintf("SwapLink(%q, <%s>, %q, %q)", op.part, op.dirName, op.expectedPath, op.linkTarget) } type ssOp struct { op ssOperation expectedErr error } func (t ssOp) String() string { return fmt.Sprintf("%s = %v", t.op, t.expectedErr) } func dumpStack(t *testing.T, ss symlinkStack) { for i, sym := range ss { t.Logf("ss[%d] %s", i, sym) } } func testSymlinkStack(t *testing.T, ops ...ssOp) symlinkStack { var ss symlinkStack for _, op := range ops { err := op.op.Do(t, &ss) if !assert.ErrorIsf(t, err, op.expectedErr, "%s", op) { dumpStack(t, ss) ss.Close() t.FailNow() } } return ss } func TestSymlinkStackBasic(t *testing.T) { ss := testSymlinkStack(t, ssOp{op: ssOpSwapLink{"foo", "A", "", "bar/baz"}}, ssOp{op: ssOpSwapLink{"bar", "B", "baz", "abcd"}}, ssOp{op: ssOpPop{"abcd"}}, ssOp{op: ssOpSwapLink{"baz", "C", "", "taillink"}}, ssOp{op: ssOpPop{"taillink"}}, ssOp{op: ssOpPop{"anotherbit"}}, ) defer ss.Close() if !assert.True(t, ss.IsEmpty()) { dumpStack(t, ss) t.FailNow() } } func TestSymlinkStackBadPop(t *testing.T) { ss := testSymlinkStack(t, ssOp{op: ssOpSwapLink{"foo", "A", "", "bar/baz"}}, ssOp{op: ssOpSwapLink{"bar", "B", "baz", "abcd"}}, ssOp{op: ssOpSwapLink{"bad", "C", "", "abcd"}, expectedErr: errBrokenSymlinkStack}, ssOp{op: ssOpPop{"abcd"}}, ssOp{op: ssOpSwapLink{"baz", "C", "", "abcd"}}, ssOp{op: ssOpSwapLink{"abcd", "D", "", ""}}, // TODO: This is technically an invalid thing to push. ssOp{op: ssOpSwapLink{"another", "E", "", ""}, expectedErr: errBrokenSymlinkStack}, ) defer ss.Close() } type expectedStackEntry struct { expectedDirName string expectedUnwalked []string } func testStackContents(t *testing.T, msg string, ss symlinkStack, expected ...expectedStackEntry) { if len(expected) > 0 { require.Equalf(t, len(ss), len(expected), "%s: stack should be the expected length", msg) require.Falsef(t, ss.IsEmpty(), "%s: stack IsEmpty should be false", msg) } else { require.Emptyf(t, len(ss), "%s: stack should be empty", msg) require.Truef(t, ss.IsEmpty(), "%s: stack IsEmpty should be true", msg) } for idx, entry := range expected { assert.Equalf(t, ss[idx].dir.Name(), entry.expectedDirName, "%s: stack entry %d name mismatch", msg, idx) if len(entry.expectedUnwalked) > 0 { assert.Equalf(t, ss[idx].linkUnwalked, entry.expectedUnwalked, "%s: stack entry %d unwalked link entries mismatch", msg, idx) } else { assert.Emptyf(t, ss[idx].linkUnwalked, "%s: stack entry %d unwalked link entries", msg, idx) } } // Fail the test immediately so we can get the current stack in the test output. if t.Failed() { t.FailNow() } } func TestSymlinkStackBasicTailChain(t *testing.T) { ss := testSymlinkStack(t, ssOp{op: ssOpSwapLink{"foo", "A", "", "tailA"}}, ssOp{op: ssOpSwapLink{"tailA", "B", "", "tailB"}}, ssOp{op: ssOpSwapLink{"tailB", "C", "", "tailC"}}, ssOp{op: ssOpSwapLink{"tailC", "D", "", "tailD"}}, ssOp{op: ssOpSwapLink{"tailD", "E", "", "foo/taillink"}}, ) defer func() { if t.Failed() { dumpStack(t, ss) } }() defer ss.Close() // Basic expected contents. testStackContents(t, "initial state", ss, // The top 4 entries should have no unwalked links. expectedStackEntry{"A", nil}, expectedStackEntry{"B", nil}, expectedStackEntry{"C", nil}, expectedStackEntry{"D", nil}, // And the final entry should just be foo/taillink. expectedStackEntry{"E", []string{"foo", "taillink"}}, ) // Popping "foo" should keep the tail-chain. require.NoError(t, ss.PopPart("foo"), "pop foo") testStackContents(t, "pop tail-chain end", ss, // The top 4 entries should have no unwalked links. expectedStackEntry{"A", nil}, expectedStackEntry{"B", nil}, expectedStackEntry{"C", nil}, expectedStackEntry{"D", nil}, // And the final entry should just be foo/taillink. expectedStackEntry{"E", []string{"taillink"}}, ) // Dropping taillink should empty the stack. require.NoError(t, ss.PopPart("taillink"), "pop taillink") testStackContents(t, "pop last element in tail-chain", ss) assert.True(t, ss.IsEmpty(), "pop last element in tail-chain should empty chain") } func TestSymlinkStackTailChain(t *testing.T) { ss := testSymlinkStack(t, ssOp{op: ssOpSwapLink{"foo", "A", "", "tailA/subdir1"}}, // First tail-chain. ssOp{op: ssOpSwapLink{"tailA", "B", "", "tailB"}}, ssOp{op: ssOpSwapLink{"tailB", "C", "", "tailC"}}, ssOp{op: ssOpSwapLink{"tailC", "D", "", "tailD"}}, ssOp{op: ssOpSwapLink{"tailD", "E", "", "taillink1/subdir2"}}, // Second tail-chain. ssOp{op: ssOpSwapLink{"taillink1", "F", "", "tailE"}}, ssOp{op: ssOpSwapLink{"tailE", "G", "", "tailF"}}, ssOp{op: ssOpSwapLink{"tailF", "H", "", "tailG"}}, ssOp{op: ssOpSwapLink{"tailG", "I", "", "tailH"}}, ssOp{op: ssOpSwapLink{"tailH", "J", "", "tailI"}}, ssOp{op: ssOpSwapLink{"tailI", "K", "", "taillink2/.."}}, ) defer func() { if t.Failed() { dumpStack(t, ss) } }() defer ss.Close() // Basic expected contents. initialState := []expectedStackEntry{ // Top entry is not a tail-chain. expectedStackEntry{"A", []string{"subdir1"}}, // The first tail-chain should have no unwalked links. expectedStackEntry{"B", nil}, expectedStackEntry{"C", nil}, expectedStackEntry{"D", nil}, // Final entry in the first tail-chain. expectedStackEntry{"E", []string{"subdir2"}}, // The second tail-chain should have no unwalked links. expectedStackEntry{"F", nil}, expectedStackEntry{"G", nil}, expectedStackEntry{"H", nil}, expectedStackEntry{"I", nil}, expectedStackEntry{"J", nil}, // Final entry in the second tail-chain. expectedStackEntry{"K", []string{"taillink2", ".."}}, } testStackContents(t, "initial state", ss, initialState...) // Trying to pop "." does nothing. for i := 0; i < 20; i++ { require.NoError(t, ss.PopPart("."), `popping "." should never fail`) // NOTE: Same contents as above. testStackContents(t, "noop pop .", ss, initialState...) } // Popping any of the early tail chain entries must fail. for _, badPart := range []string{"subdir1", "subdir2", ".."} { require.ErrorIsf(t, ss.PopPart(badPart), errBrokenSymlinkStack, "bad pop %q", badPart) // NOTE: Same contents as above. testStackContents(t, "bad pop "+badPart, ss, initialState...) } // Dropping the second-last entry should keep the tail-chain. require.NoError(t, ss.PopPart("taillink2"), "pop taillink2") testStackContents(t, "pop non-last element in second tail-chain", ss, // Top entry is not a tail-chain. expectedStackEntry{"A", []string{"subdir1"}}, // The first tail-chain should have no unwalked links. expectedStackEntry{"B", nil}, expectedStackEntry{"C", nil}, expectedStackEntry{"D", nil}, // Final entry in the first tail-chain. expectedStackEntry{"E", []string{"subdir2"}}, // The second tail-chain should have no unwalked links. expectedStackEntry{"F", nil}, expectedStackEntry{"G", nil}, expectedStackEntry{"H", nil}, expectedStackEntry{"I", nil}, expectedStackEntry{"J", nil}, // Final entry in the second tail-chain. expectedStackEntry{"K", []string{".."}}, ) // Dropping the last entry should only drop the final tail-chain. require.NoError(t, ss.PopPart(".."), "pop ..") testStackContents(t, "pop last element in second tail-chain", ss, // Top entry is not a tail-chain. expectedStackEntry{"A", []string{"subdir1"}}, // The first tail-chain should have no unwalked links. expectedStackEntry{"B", nil}, expectedStackEntry{"C", nil}, expectedStackEntry{"D", nil}, // Final entry in the first tail-chain. expectedStackEntry{"E", []string{"subdir2"}}, ) // Dropping the last entry should only drop the tail-chain. require.NoError(t, ss.PopPart("subdir2"), "pop subdir2") testStackContents(t, "pop last element in first tail-chain", ss, // Top entry is not a tail-chain. expectedStackEntry{"A", []string{"subdir1"}}, ) // Dropping the last entry should empty the stack. require.NoError(t, ss.PopPart("subdir1"), "pop subdir1") testStackContents(t, "pop last element", ss) assert.True(t, ss.IsEmpty(), "pop last element should empty stack") } filepath-securejoin-0.3.4/mkdir_linux.go000066400000000000000000000203021470137774400203450ustar00rootroot00000000000000//go:build linux // Copyright (C) 2024 SUSE LLC. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package securejoin import ( "errors" "fmt" "os" "path/filepath" "slices" "strings" "golang.org/x/sys/unix" ) var ( errInvalidMode = errors.New("invalid permission mode") errPossibleAttack = errors.New("possible attack detected") ) // MkdirAllHandle is equivalent to [MkdirAll], except that it is safer to use // in two respects: // // - The caller provides the root directory as an *[os.File] (preferably O_PATH) // handle. This means that the caller can be sure which root directory is // being used. Note that this can be emulated by using /proc/self/fd/... as // the root path with [os.MkdirAll]. // // - Once all of the directories have been created, an *[os.File] O_PATH handle // to the directory at unsafePath is returned to the caller. This is done in // an effectively-race-free way (an attacker would only be able to swap the // final directory component), which is not possible to emulate with // [MkdirAll]. // // In addition, the returned handle is obtained far more efficiently than doing // a brand new lookup of unsafePath (such as with [SecureJoin] or openat2) after // doing [MkdirAll]. If you intend to open the directory after creating it, you // should use MkdirAllHandle. func MkdirAllHandle(root *os.File, unsafePath string, mode int) (_ *os.File, Err error) { // Make sure there are no os.FileMode bits set. if mode&^0o7777 != 0 { return nil, fmt.Errorf("%w for mkdir 0o%.3o", errInvalidMode, mode) } // On Linux, mkdirat(2) (and os.Mkdir) silently ignore the suid and sgid // bits. We could also silently ignore them but since we have very few // users it seems more prudent to return an error so users notice that // these bits will not be set. if mode&^0o1777 != 0 { return nil, fmt.Errorf("%w for mkdir 0o%.3o: suid and sgid are ignored by mkdir", errInvalidMode, mode) } // Try to open as much of the path as possible. currentDir, remainingPath, err := partialLookupInRoot(root, unsafePath) defer func() { if Err != nil { _ = currentDir.Close() } }() if err != nil && !errors.Is(err, unix.ENOENT) { return nil, fmt.Errorf("find existing subpath of %q: %w", unsafePath, err) } // If there is an attacker deleting directories as we walk into them, // detect this proactively. Note this is guaranteed to detect if the // attacker deleted any part of the tree up to currentDir. // // Once we walk into a dead directory, partialLookupInRoot would not be // able to walk further down the tree (directories must be empty before // they are deleted), and if the attacker has removed the entire tree we // can be sure that anything that was originally inside a dead directory // must also be deleted and thus is a dead directory in its own right. // // This is mostly a quality-of-life check, because mkdir will simply fail // later if the attacker deletes the tree after this check. if err := isDeadInode(currentDir); err != nil { return nil, fmt.Errorf("finding existing subpath of %q: %w", unsafePath, err) } // Re-open the path to match the O_DIRECTORY reopen loop later (so that we // always return a non-O_PATH handle). We also check that we actually got a // directory. if reopenDir, err := Reopen(currentDir, unix.O_DIRECTORY|unix.O_CLOEXEC); errors.Is(err, unix.ENOTDIR) { return nil, fmt.Errorf("cannot create subdirectories in %q: %w", currentDir.Name(), unix.ENOTDIR) } else if err != nil { return nil, fmt.Errorf("re-opening handle to %q: %w", currentDir.Name(), err) } else { _ = currentDir.Close() currentDir = reopenDir } remainingParts := strings.Split(remainingPath, string(filepath.Separator)) if slices.Contains(remainingParts, "..") { // The path contained ".." components after the end of the "real" // components. We could try to safely resolve ".." here but that would // add a bunch of extra logic for something that it's not clear even // needs to be supported. So just return an error. // // If we do filepath.Clean(remainingPath) then we end up with the // problem that ".." can erase a trailing dangling symlink and produce // a path that doesn't quite match what the user asked for. return nil, fmt.Errorf("%w: yet-to-be-created path %q contains '..' components", unix.ENOENT, remainingPath) } // Make sure the mode doesn't have any type bits. mode &^= unix.S_IFMT // Create the remaining components. for _, part := range remainingParts { switch part { case "", ".": // Skip over no-op paths. continue } // NOTE: mkdir(2) will not follow trailing symlinks, so we can safely // create the final component without worrying about symlink-exchange // attacks. if err := unix.Mkdirat(int(currentDir.Fd()), part, uint32(mode)); err != nil { err = &os.PathError{Op: "mkdirat", Path: currentDir.Name() + "/" + part, Err: err} // Make the error a bit nicer if the directory is dead. if err2 := isDeadInode(currentDir); err2 != nil { err = fmt.Errorf("%w (%w)", err, err2) } return nil, err } // Get a handle to the next component. O_DIRECTORY means we don't need // to use O_PATH. var nextDir *os.File if hasOpenat2() { nextDir, err = openat2File(currentDir, part, &unix.OpenHow{ Flags: unix.O_NOFOLLOW | unix.O_DIRECTORY | unix.O_CLOEXEC, Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_XDEV, }) } else { nextDir, err = openatFile(currentDir, part, unix.O_NOFOLLOW|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) } if err != nil { return nil, err } _ = currentDir.Close() currentDir = nextDir // It's possible that the directory we just opened was swapped by an // attacker. Unfortunately there isn't much we can do to protect // against this, and MkdirAll's behaviour is that we will reuse // existing directories anyway so the need to protect against this is // incredibly limited (and arguably doesn't even deserve mention here). // // Ideally we might want to check that the owner and mode match what we // would've created -- unfortunately, it is non-trivial to verify that // the owner and mode of the created directory match. While plain Unix // DAC rules seem simple enough to emulate, there are a bunch of other // factors that can change the mode or owner of created directories // (default POSIX ACLs, mount options like uid=1,gid=2,umask=0 on // filesystems like vfat, etc etc). We used to try to verify this but // it just lead to a series of spurious errors. // // We could also check that the directory is non-empty, but // unfortunately some pseduofilesystems (like cgroupfs) create // non-empty directories, which would result in different spurious // errors. } return currentDir, nil } // MkdirAll is a race-safe alternative to the [os.MkdirAll] function, // where the new directory is guaranteed to be within the root directory (if an // attacker can move directories from inside the root to outside the root, the // created directory tree might be outside of the root but the key constraint // is that at no point will we walk outside of the directory tree we are // creating). // // Effectively, MkdirAll(root, unsafePath, mode) is equivalent to // // path, _ := securejoin.SecureJoin(root, unsafePath) // err := os.MkdirAll(path, mode) // // But is much safer. The above implementation is unsafe because if an attacker // can modify the filesystem tree between [SecureJoin] and [os.MkdirAll], it is // possible for MkdirAll to resolve unsafe symlink components and create // directories outside of the root. // // If you plan to open the directory after you have created it or want to use // an open directory handle as the root, you should use [MkdirAllHandle] instead. // This function is a wrapper around [MkdirAllHandle]. // // NOTE: The mode argument must be set the unix mode bits (unix.S_I...), not // the Go generic mode bits ([os.FileMode]...). func MkdirAll(root, unsafePath string, mode int) error { rootDir, err := os.OpenFile(root, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) if err != nil { return err } defer rootDir.Close() f, err := MkdirAllHandle(rootDir, unsafePath, mode) if err != nil { return err } _ = f.Close() return nil } filepath-securejoin-0.3.4/mkdir_linux_test.go000066400000000000000000000440101470137774400214060ustar00rootroot00000000000000//go:build linux // Copyright (C) 2024 SUSE LLC. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package securejoin import ( "errors" "fmt" "os" "path/filepath" "runtime" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sys/unix" ) type mkdirAllFunc func(t *testing.T, root, unsafePath string, mode int) error var mkdirAll_MkdirAll mkdirAllFunc = func(t *testing.T, root, unsafePath string, mode int) error { // We can't check expectedPath here. return MkdirAll(root, unsafePath, mode) } var mkdirAll_MkdirAllHandle mkdirAllFunc = func(t *testing.T, root, unsafePath string, mode int) error { // Same logic as MkdirAll. rootDir, err := os.OpenFile(root, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) if err != nil { return err } defer rootDir.Close() handle, err := MkdirAllHandle(rootDir, unsafePath, mode) if err != nil { return err } defer handle.Close() // We can use SecureJoin here because we aren't being attacked in this // particular test. Obviously this check is bogus for actual programs. expectedPath, err := SecureJoin(root, unsafePath) require.NoError(t, err) // Now double-check that the handle is correct. gotPath, err := procSelfFdReadlink(handle) require.NoError(t, err, "get real path of returned handle") assert.Equal(t, expectedPath, gotPath, "wrong final path from MkdirAllHandle") // Also check that the f.Name() is correct while we're at it (this is // not always guaranteed but it's better to try at least). assert.Equal(t, expectedPath, handle.Name(), "handle from MkdirAllHandle has the wrong .Name()") return nil } func checkMkdirAll(t *testing.T, mkdirAll mkdirAllFunc, root, unsafePath string, mode, expectedMode int, expectedErr error) { rootDir, err := os.OpenFile(root, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) require.NoError(t, err) defer rootDir.Close() // Before trying to make the tree, figure out what components don't exist // yet so we can check them later. handle, remainingPath, err := partialLookupInRoot(rootDir, unsafePath) handleName := "" if handle != nil { handleName = handle.Name() defer handle.Close() } defer func() { if t.Failed() { t.Logf("partialLookupInRoot(%s, %s) -> (<%s>, %s, %v)", root, unsafePath, handleName, remainingPath, err) } }() // Actually make the tree. err = mkdirAll(t, root, unsafePath, mode) assert.ErrorIsf(t, err, expectedErr, "MkdirAll(%q, %q)", root, unsafePath) remainingPath = filepath.Join("/", remainingPath) for remainingPath != filepath.Dir(remainingPath) { stat, err := fstatatFile(handle, "./"+remainingPath, unix.AT_SYMLINK_NOFOLLOW) if expectedErr == nil { // Check that the new components have the right mode. if assert.NoErrorf(t, err, "unexpected error when checking new directory %q", remainingPath) { assert.Equalf(t, uint32(unix.S_IFDIR|expectedMode), stat.Mode, "new directory %q has the wrong mode", remainingPath) } } else { // Check that none of the components are directories (i.e. make // sure that the MkdirAll was a no-op). if err == nil { assert.NotEqualf(t, uint32(unix.S_IFDIR), stat.Mode&unix.S_IFMT, "failed MkdirAll created a new directory at %q", remainingPath) } } // Jump up a level. remainingPath = filepath.Dir(remainingPath) } } func testMkdirAll_Basic(t *testing.T, mkdirAll mkdirAllFunc) { // We create a new tree for each test, but the template is the same. tree := []string{ "dir a", "dir b/c/d/e/f", "file b/c/file", "symlink e /b/c/d/e", "symlink b-file b/c/file", // Dangling symlinks. "symlink a-fake1 a/fake", "symlink a-fake2 a/fake/foo/bar/..", "symlink a-fake3 a/fake/../../b", // Test non-lexical symlinks. "dir target", "dir link1", "symlink link1/target_abs /target", "symlink link1/target_rel ../target", "dir link2", "symlink link2/link1_abs /link1", "symlink link2/link1_rel ../link1", "dir link3", "symlink link3/target_abs /link2/link1_rel/target_rel", "symlink link3/target_rel ../link2/link1_rel/target_rel", "symlink link3/deep_dangling1 ../link2/link1_rel/target_rel/nonexist", "symlink link3/deep_dangling2 ../link2/link1_rel/target_rel/nonexist", // Symlink loop. "dir loop", "symlink loop/link ../loop/link", // S_ISGID directory. "dir sgid-self ::2755", "dir sgid-sticky-self ::3755", } withWithoutOpenat2(t, true, func(t *testing.T) { for name, test := range map[string]struct { unsafePath string expectedErr error expectedModeBits int }{ "existing": {unsafePath: "a"}, "basic": {unsafePath: "a/b/c/d/e/f/g/h/i/j"}, "dotdot-in-nonexisting": {unsafePath: "a/b/c/d/e/f/g/h/i/j/k/../lmnop", expectedErr: unix.ENOENT}, "dotdot-in-existing": {unsafePath: "b/c/../c/./d/e/f/g/h"}, "dotdot-after-symlink": {unsafePath: "e/../dd/ee/ff"}, // Check that trying to create under a file fails. "nondir-trailing": {unsafePath: "b/c/file", expectedErr: unix.ENOTDIR}, "nondir-dotdot": {unsafePath: "b/c/file/../d", expectedErr: unix.ENOTDIR}, "nondir-subdir": {unsafePath: "b/c/file/subdir", expectedErr: unix.ENOTDIR}, "nondir-symlink-trailing": {unsafePath: "b-file", expectedErr: unix.ENOTDIR}, "nondir-symlink-dotdot": {unsafePath: "b-file/../d", expectedErr: unix.ENOTDIR}, "nondir-symlink-subdir": {unsafePath: "b-file/subdir", expectedErr: unix.ENOTDIR}, // Dangling symlinks are not followed. "dangling1-trailing": {unsafePath: "a-fake1", expectedErr: unix.EEXIST}, "dangling1-basic": {unsafePath: "a-fake1/foo", expectedErr: unix.EEXIST}, "dangling1-dotdot": {unsafePath: "a-fake1/../bar/baz", expectedErr: unix.ENOENT}, "dangling2-trailing": {unsafePath: "a-fake2", expectedErr: unix.EEXIST}, "dangling2-basic": {unsafePath: "a-fake2/foo", expectedErr: unix.EEXIST}, "dangling2-dotdot": {unsafePath: "a-fake2/../bar/baz", expectedErr: unix.ENOENT}, "dangling3-trailing": {unsafePath: "a-fake3", expectedErr: unix.EEXIST}, "dangling3-basic": {unsafePath: "a-fake3/foo", expectedErr: unix.EEXIST}, "dangling3-dotdot": {unsafePath: "a-fake3/../bar/baz", expectedErr: unix.ENOENT}, // Non-lexical symlinks should work. "nonlexical-basic": {unsafePath: "target/foo"}, "nonlexical-level1-abs": {unsafePath: "link1/target_abs/foo"}, "nonlexical-level1-rel": {unsafePath: "link1/target_rel/foo"}, "nonlexical-level2-abs-abs": {unsafePath: "link2/link1_abs/target_abs/foo"}, "nonlexical-level2-abs-rel": {unsafePath: "link2/link1_abs/target_rel/foo"}, "nonlexical-level2-abs-open": {unsafePath: "link2/link1_abs/../target/foo"}, "nonlexical-level2-rel-abs": {unsafePath: "link2/link1_rel/target_abs/foo"}, "nonlexical-level2-rel-rel": {unsafePath: "link2/link1_rel/target_rel/foo"}, "nonlexical-level2-rel-open": {unsafePath: "link2/link1_rel/../target/foo"}, "nonlexical-level3-abs": {unsafePath: "link3/target_abs/foo"}, "nonlexical-level3-rel": {unsafePath: "link3/target_rel/foo"}, // But really tricky dangling symlinks should fail. "dangling-tricky1-trailing": {unsafePath: "link3/deep_dangling1", expectedErr: unix.EEXIST}, "dangling-tricky1-basic": {unsafePath: "link3/deep_dangling1/foo", expectedErr: unix.EEXIST}, "dangling-tricky1-dotdot": {unsafePath: "link3/deep_dangling1/../bar", expectedErr: unix.ENOENT}, "dangling-tricky2-trailing": {unsafePath: "link3/deep_dangling2", expectedErr: unix.EEXIST}, "dangling-tricky2-basic": {unsafePath: "link3/deep_dangling2/foo", expectedErr: unix.EEXIST}, "dangling-tricky2-dotdot": {unsafePath: "link3/deep_dangling2/../bar", expectedErr: unix.ENOENT}, // And trying to mkdir inside a loop should fail. "loop-trailing": {unsafePath: "loop/link", expectedErr: unix.ELOOP}, "loop-basic": {unsafePath: "loop/link/foo", expectedErr: unix.ELOOP}, "loop-dotdot": {unsafePath: "loop/link/../foo", expectedErr: unix.ELOOP}, // Make sure the S_ISGID handling is correct. "sgid-dir-ownedbyus": {unsafePath: "sgid-self/foo/bar/baz", expectedModeBits: unix.S_ISGID}, "sgid-sticky-dir-ownedbyus": {unsafePath: "sgid-sticky-self/foo/bar/baz", expectedModeBits: unix.S_ISGID}, } { test := test // copy iterator t.Run(name, func(t *testing.T) { root := createTree(t, tree...) const mode = 0o711 checkMkdirAll(t, mkdirAll, root, test.unsafePath, mode, test.expectedModeBits|mode, test.expectedErr) }) } }) } func TestMkdirAll_Basic(t *testing.T) { testMkdirAll_Basic(t, mkdirAll_MkdirAll) } func TestMkdirAllHandle_Basic(t *testing.T) { testMkdirAll_Basic(t, mkdirAll_MkdirAllHandle) } func testMkdirAll_AsRoot(t *testing.T, mkdirAll mkdirAllFunc) { requireRoot(t) // chown // We create a new tree for each test, but the template is the same. tree := []string{ // S_ISGID directories. "dir sgid-self ::2755", "dir sgid-other 1000:1000:2755", "dir sgid-sticky-self ::3755", "dir sgid-sticky-other 1000:1000:3755", } withWithoutOpenat2(t, true, func(t *testing.T) { for name, test := range map[string]struct { unsafePath string expectedErr error expectedModeBits int }{ // Make sure the S_ISGID handling is correct. "sgid-dir-ownedbyus": {unsafePath: "sgid-self/foo/bar/baz", expectedModeBits: unix.S_ISGID}, "sgid-dir-ownedbyother": {unsafePath: "sgid-other/foo/bar/baz", expectedModeBits: unix.S_ISGID}, "sgid-sticky-dir-ownedbyus": {unsafePath: "sgid-sticky-self/foo/bar/baz", expectedModeBits: unix.S_ISGID}, "sgid-sticky-dir-ownedbyother": {unsafePath: "sgid-sticky-other/foo/bar/baz", expectedModeBits: unix.S_ISGID}, } { test := test // copy iterator t.Run(name, func(t *testing.T) { root := createTree(t, tree...) const mode = 0o711 checkMkdirAll(t, mkdirAll, root, test.unsafePath, mode, test.expectedModeBits|mode, test.expectedErr) }) } }) } func TestMkdirAll_AsRoot(t *testing.T) { testMkdirAll_AsRoot(t, mkdirAll_MkdirAll) } func TestMkdirAllHandle_AsRoot(t *testing.T) { testMkdirAll_AsRoot(t, mkdirAll_MkdirAllHandle) } func testMkdirAll_InvalidMode(t *testing.T, mkdirAll mkdirAllFunc) { for _, test := range []struct { mode int expectedErr error }{ // os.FileMode bits are invalid. {int(os.ModeDir | 0o777), errInvalidMode}, {int(os.ModeSticky | 0o777), errInvalidMode}, {int(os.ModeIrregular | 0o777), errInvalidMode}, // unix.S_IFMT bits are also invalid. {unix.S_IFDIR | 0o777, errInvalidMode}, {unix.S_IFREG | 0o777, errInvalidMode}, {unix.S_IFIFO | 0o777, errInvalidMode}, // suid/sgid bits are silently ignored by mkdirat and so we return an // error explicitly. {unix.S_ISUID | 0o777, errInvalidMode}, {unix.S_ISGID | 0o777, errInvalidMode}, {unix.S_ISUID | unix.S_ISGID | unix.S_ISVTX | 0o777, errInvalidMode}, // Proper sticky bit should work. {unix.S_ISVTX | 0o777, nil}, // Regular mode bits. {0o777, nil}, {0o711, nil}, } { root := t.TempDir() err := mkdirAll(t, root, "a/b/c", test.mode) assert.ErrorIsf(t, err, test.expectedErr, "mkdirall 0o%.3o", test.mode) } } func TestMkdirAll_InvalidMode(t *testing.T) { testMkdirAll_InvalidMode(t, mkdirAll_MkdirAll) } func TestMkdirAllHandle_InvalidMode(t *testing.T) { testMkdirAll_InvalidMode(t, mkdirAll_MkdirAllHandle) } type racingMkdirMeta struct { passOkCount, passErrCount, failCount int passErrCounts map[error]int } func newRacingMkdirMeta() *racingMkdirMeta { return &racingMkdirMeta{ passErrCounts: map[error]int{}, } } func (m *racingMkdirMeta) checkMkdirAllHandle_Racing(t *testing.T, root, unsafePath string, mode int, allowedErrs []error) { rootDir, err := os.OpenFile(root, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) require.NoError(t, err, "open root") defer rootDir.Close() handle, err := MkdirAllHandle(rootDir, unsafePath, mode) if err != nil { for _, allowedErr := range allowedErrs { if errors.Is(err, allowedErr) { m.passErrCounts[allowedErr]++ m.passErrCount++ return } } assert.NoError(t, err) m.failCount++ return } defer handle.Close() // It's possible for an attacker to have swapped the final directory, but // this is okay because MkdirAll will use pre-existing directories anyway. // So there's no need to check the returned handle. // TODO: Does it make sense to even try to check the handle path? m.passOkCount++ } func TestMkdirAllHandle_RacingRename(t *testing.T) { withWithoutOpenat2(t, false, func(t *testing.T) { treeSpec := []string{ "dir target/a/b/c", "dir swapdir-empty-ok ::0711", "dir swapdir-empty-badmode ::0777", "dir swapdir-nonempty1 ::0711", "file swapdir-nonempty1/aaa", "dir swapdir-nonempty2 ::0711", "dir swapdir-nonempty2/f ::0711", "file swapfile foobar ::0711", } type test struct { name string pathA, pathB string unsafePath string allowedErrs []error } tests := []test{ {"good", "target/a/b/c/d/e", "swapdir-empty-ok", "target/a/b/c/d/e/f/g/h/i/j/k", nil}, {"trailing", "target/a/b/c/d/e", "swapdir-empty-badmode", "target/a/b/c/d/e", nil}, {"partial", "target/a/b/c/d/e", "swapdir-empty-badmode", "target/a/b/c/d/e/f/g/h/i/j/k", nil}, {"trailing", "target/a/b/c/d/e", "swapdir-nonempty1", "target/a/b/c/d/e", nil}, {"partial", "target/a/b/c/d/e", "swapdir-nonempty1", "target/a/b/c/d/e/f/g/h/i/j/k", nil}, {"trailing", "target/a/b/c/d/e", "swapdir-nonempty2", "target/a/b/c/d/e", nil}, {"partial", "target/a/b/c/d/e", "swapdir-nonempty2", "target/a/b/c/d/e/f/g/h/i/j/k", []error{unix.EEXIST}}, {"trailing", "target/a/b/c/d/e", "swapfile", "target/a/b/c/d/e", []error{unix.ENOTDIR}}, {"partial", "target/a/b/c/d/e", "swapfile", "target/a/b/c/d/e/f/g/h/i/j/k", []error{unix.ENOTDIR}}, } if unix.Geteuid() == 0 { // Add some wrong-uid cases if we are root. treeSpec = append(treeSpec, "dir swapdir-empty-badowner1 123:0:0711", "dir swapdir-empty-badowner2 0:456:0711", "dir swapdir-empty-badowner3 111:222:0711", ) tests = append(tests, []test{ {"trailing", "target/a/b/c/d/e", "swapdir-empty-badowner1", "target/a/b/c/d/e", nil}, {"partial", "target/a/b/c/d/e", "swapdir-empty-badowner1", "target/a/b/c/d/e/f/g/h/i/j/k", nil}, {"trailing", "target/a/b/c/d/e", "swapdir-empty-badowner2", "target/a/b/c/d/e", nil}, {"partial", "target/a/b/c/d/e", "swapdir-empty-badowner2", "target/a/b/c/d/e/f/g/h/i/j/k", nil}, {"trailing", "target/a/b/c/d/e", "swapdir-empty-badowner3", "target/a/b/c/d/e", nil}, {"partial", "target/a/b/c/d/e", "swapdir-empty-badowner3", "target/a/b/c/d/e/f/g/h/i/j/k", nil}, }...) } for _, test := range tests { test := test // copy iterator t.Run(fmt.Sprintf("%s-%s", test.pathB, test.name), func(t *testing.T) { rootCh := make(chan string) defer close(rootCh) go func(rootCh <-chan string) { var root string for { select { case newRoot, ok := <-rootCh: if !ok { return } root = newRoot default: if root != "" { pathA := filepath.Join(root, test.pathA) pathB := filepath.Join(root, test.pathB) _ = unix.Renameat2(unix.AT_FDCWD, pathA, unix.AT_FDCWD, pathB, unix.RENAME_EXCHANGE) } } } }(rootCh) // Do several runs to try to catch bugs. const testRuns = 2000 m := newRacingMkdirMeta() for i := 0; i < testRuns; i++ { root := createTree(t, treeSpec...) rootCh <- root runtime.Gosched() // give the thread some time to do a rename m.checkMkdirAllHandle_Racing(t, root, test.unsafePath, 0o711, test.allowedErrs) rootCh <- "" // Clean up the root after each run so we don't exhaust all // space in the tmpfs. _ = os.RemoveAll(root) } pct := func(count int) string { return fmt.Sprintf("%d(%.3f%%)", count, 100.0*float64(count)/float64(testRuns)) } // Output some stats. t.Logf("after %d runs: passOk=%s passErr=%s fail=%s", testRuns, pct(m.passOkCount), pct(m.passErrCount), pct(m.failCount)) if len(m.passErrCounts) > 0 { t.Logf(" passErr breakdown:") for err, count := range m.passErrCounts { t.Logf(" %3.d: %v", count, err) } } }) } }) } func TestMkdirAllHandle_RacingDelete(t *testing.T) { withWithoutOpenat2(t, false, func(t *testing.T) { treeSpec := []string{ "dir target/a/b/c", } for _, test := range []struct { name string rmPath string unsafePath string allowedErrs []error }{ {"rm-top", "target", "target/a/b/c/d/e/f/g/h/i/j/k", []error{errInvalidDirectory, unix.ENOENT}}, {"rm-existing", "target/a/b/c", "target/a/b/c/d/e/f/g/h/i/j/k", []error{errInvalidDirectory, unix.ENOENT}}, {"rm-nonexisting", "target/a/b/c/d/e", "target/a/b/c/d/e/f/g/h/i/j/k", []error{errInvalidDirectory, unix.ENOENT}}, } { test := test // copy iterator t.Run(test.rmPath, func(t *testing.T) { rootCh := make(chan string) defer close(rootCh) go func(rootCh <-chan string) { var root string for { select { case newRoot, ok := <-rootCh: if !ok { return } root = newRoot default: if root != "" { _ = os.RemoveAll(filepath.Join(root, test.rmPath)) } } } }(rootCh) // Do several runs to try to catch bugs. const testRuns = 2000 m := newRacingMkdirMeta() for i := 0; i < testRuns; i++ { root := createTree(t, treeSpec...) rootCh <- root m.checkMkdirAllHandle_Racing(t, root, test.unsafePath, 0o711, test.allowedErrs) rootCh <- "" // Clean up the root after each run so we don't exhaust all // space in the tmpfs. _ = os.RemoveAll(root + "/..") } pct := func(count int) string { return fmt.Sprintf("%d(%.3f%%)", count, 100.0*float64(count)/float64(testRuns)) } // Output some stats. t.Logf("after %d runs: passOk=%s passErr=%s fail=%s", testRuns, pct(m.passOkCount), pct(m.passErrCount), pct(m.failCount)) if len(m.passErrCounts) > 0 { t.Logf(" passErr breakdown:") for err, count := range m.passErrCounts { t.Logf(" %3.d: %v", count, err) } } }) } }) } filepath-securejoin-0.3.4/open_linux.go000066400000000000000000000077311470137774400202130ustar00rootroot00000000000000//go:build linux // Copyright (C) 2024 SUSE LLC. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package securejoin import ( "fmt" "os" "strconv" "golang.org/x/sys/unix" ) // OpenatInRoot is equivalent to [OpenInRoot], except that the root is provided // using an *[os.File] handle, to ensure that the correct root directory is used. func OpenatInRoot(root *os.File, unsafePath string) (*os.File, error) { handle, err := completeLookupInRoot(root, unsafePath) if err != nil { return nil, &os.PathError{Op: "securejoin.OpenInRoot", Path: unsafePath, Err: err} } return handle, nil } // OpenInRoot safely opens the provided unsafePath within the root. // Effectively, OpenInRoot(root, unsafePath) is equivalent to // // path, _ := securejoin.SecureJoin(root, unsafePath) // handle, err := os.OpenFile(path, unix.O_PATH|unix.O_CLOEXEC) // // But is much safer. The above implementation is unsafe because if an attacker // can modify the filesystem tree between [SecureJoin] and [os.OpenFile], it is // possible for the returned file to be outside of the root. // // Note that the returned handle is an O_PATH handle, meaning that only a very // limited set of operations will work on the handle. This is done to avoid // accidentally opening an untrusted file that could cause issues (such as a // disconnected TTY that could cause a DoS, or some other issue). In order to // use the returned handle, you can "upgrade" it to a proper handle using // [Reopen]. func OpenInRoot(root, unsafePath string) (*os.File, error) { rootDir, err := os.OpenFile(root, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) if err != nil { return nil, err } defer rootDir.Close() return OpenatInRoot(rootDir, unsafePath) } // Reopen takes an *[os.File] handle and re-opens it through /proc/self/fd. // Reopen(file, flags) is effectively equivalent to // // fdPath := fmt.Sprintf("/proc/self/fd/%d", file.Fd()) // os.OpenFile(fdPath, flags|unix.O_CLOEXEC) // // But with some extra hardenings to ensure that we are not tricked by a // maliciously-configured /proc mount. While this attack scenario is not // common, in container runtimes it is possible for higher-level runtimes to be // tricked into configuring an unsafe /proc that can be used to attack file // operations. See [CVE-2019-19921] for more details. // // [CVE-2019-19921]: https://github.com/advisories/GHSA-fh74-hm69-rqjw func Reopen(handle *os.File, flags int) (*os.File, error) { procRoot, err := getProcRoot() if err != nil { return nil, err } // We can't operate on /proc/thread-self/fd/$n directly when doing a // re-open, so we need to open /proc/thread-self/fd and then open a single // final component. procFdDir, closer, err := procThreadSelf(procRoot, "fd/") if err != nil { return nil, fmt.Errorf("get safe /proc/thread-self/fd handle: %w", err) } defer procFdDir.Close() defer closer() // Try to detect if there is a mount on top of the magic-link we are about // to open. If we are using unsafeHostProcRoot(), this could change after // we check it (and there's nothing we can do about that) but for // privateProcRoot() this should be guaranteed to be safe (at least since // Linux 5.12[1], when anonymous mount namespaces were completely isolated // from external mounts including mount propagation events). // // [1]: Linux commit ee2e3f50629f ("mount: fix mounting of detached mounts // onto targets that reside on shared mounts"). fdStr := strconv.Itoa(int(handle.Fd())) if err := checkSymlinkOvermount(procRoot, procFdDir, fdStr); err != nil { return nil, fmt.Errorf("check safety of /proc/thread-self/fd/%s magiclink: %w", fdStr, err) } flags |= unix.O_CLOEXEC // Rather than just wrapping openatFile, open-code it so we can copy // handle.Name(). reopenFd, err := unix.Openat(int(procFdDir.Fd()), fdStr, flags, 0) if err != nil { return nil, fmt.Errorf("reopen fd %d: %w", handle.Fd(), err) } return os.NewFile(uintptr(reopenFd), handle.Name()), nil } filepath-securejoin-0.3.4/open_linux_test.go000066400000000000000000000532771470137774400212600ustar00rootroot00000000000000//go:build linux // Copyright (C) 2024 SUSE LLC. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package securejoin import ( "os" "path/filepath" "runtime" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sys/unix" ) type openInRootFunc func(root, unsafePath string) (*os.File, error) type openResult struct { handlePath string err error fileType uint32 } // O_LARGEFILE is automatically added by the kernel when opening files on // 64-bit machines. Unfortunately, it is architecture-dependent and // unix.O_LARGEFILE is 0 (presumably to avoid users setting it). So we need to // initialise it at init. var O_LARGEFILE = 0x8000 func init() { switch runtime.GOARCH { case "arm", "arm64": O_LARGEFILE = 0x20000 case "mips", "mips64", "mips64le", "mips64p32", "mips64p32le": O_LARGEFILE = 0x2000 case "ppc", "ppc64", "ppc64le": O_LARGEFILE = 0x10000 case "sparc", "sparc64": O_LARGEFILE = 0x40000 default: // 0x8000 is the default flag in asm-generic. } } func checkReopen(t *testing.T, handle *os.File, flags int, expectedErr error) { newHandle, err := Reopen(handle, flags) if newHandle != nil { defer newHandle.Close() } if expectedErr != nil { if assert.Error(t, err) { assert.ErrorIs(t, err, expectedErr) } else { t.Errorf("unexpected handle %q", handle.Name()) } return } assert.NoError(t, err) // Get the original handle path. handlePath, err := procSelfFdReadlink(handle) require.NoError(t, err, "get real path of original handle") // Make sure the handle matches the readlink path. assert.Equal(t, handlePath, handle.Name(), "handle.Name() matching real original handle path") // Check that the new and old handle have the same path. newHandlePath, err := procSelfFdReadlink(newHandle) require.NoError(t, err, "get real path of reopened handle") assert.Equal(t, handlePath, newHandlePath, "old and reopen handle paths") assert.Equal(t, handle.Name(), newHandle.Name(), "old and reopen handle.Name()") // Check the fd flags. newHandleFdFlags, err := unix.FcntlInt(newHandle.Fd(), unix.F_GETFD, 0) require.NoError(t, err, "fcntl(F_GETFD)") assert.Equal(t, unix.FD_CLOEXEC, newHandleFdFlags&unix.FD_CLOEXEC, "FD_CLOEXEC flag must be set") // Check the file handle flags. newHandleStatusFlags, err := unix.FcntlInt(newHandle.Fd(), unix.F_GETFL, 0) require.NoError(t, err, "fcntl(F_GETFL)") flags &^= unix.O_CLOEXEC // O_CLOEXEC is checked by F_GETFD newHandleStatusFlags &^= O_LARGEFILE // ignore the O_LARGEFILE flag assert.Equal(t, flags, newHandleStatusFlags, "re-opened handle status flags must match re-open flags (%+x)") } func checkOpenInRoot(t *testing.T, openInRootFn openInRootFunc, root, unsafePath string, expected openResult) { handle, err := openInRootFn(root, unsafePath) if handle != nil { defer handle.Close() } if expected.err != nil { if assert.Error(t, err) { assert.ErrorIs(t, err, expected.err) } else { t.Errorf("unexpected handle %q", handle.Name()) } return } assert.NoError(t, err) // Check the handle path. gotPath, err := procSelfFdReadlink(handle) require.NoError(t, err, "get real path of returned handle") assert.Equal(t, expected.handlePath, gotPath, "real handle path") // Make sure the handle matches the readlink path. assert.Equal(t, gotPath, handle.Name(), "handle.Name() matching real handle path") // Check the handle type. unixStat, err := fstat(handle) require.NoError(t, err, "fstat handle") assert.Equal(t, expected.fileType, unixStat.Mode&unix.S_IFMT, "handle S_IFMT type") // Check that re-opening produces a handle with the same path. switch expected.fileType { case unix.S_IFDIR: checkReopen(t, handle, unix.O_RDONLY, nil) checkReopen(t, handle, unix.O_DIRECTORY, nil) case unix.S_IFREG: checkReopen(t, handle, unix.O_RDWR, nil) checkReopen(t, handle, unix.O_DIRECTORY, unix.ENOTDIR) // Only files and directories are safe to open this way. Use O_PATH for // everything else. default: checkReopen(t, handle, unix.O_PATH, nil) checkReopen(t, handle, unix.O_PATH|unix.O_DIRECTORY, unix.ENOTDIR) } } func testOpenInRoot(t *testing.T, openInRootFn openInRootFunc) { tree := []string{ "dir a", "dir b/c/d/e/f", "file b/c/file", "symlink e /b/c/d/e", "symlink b-file b/c/file", // Dangling symlinks. "symlink a-fake1 a/fake", "symlink a-fake2 a/fake/foo/bar/..", "symlink a-fake3 a/fake/../../b", "dir c", "symlink c/a-fake1 a/fake", "symlink c/a-fake2 a/fake/foo/bar/..", "symlink c/a-fake3 a/fake/../../b", // Test non-lexical symlinks. "dir target", "dir link1", "symlink link1/target_abs /target", "symlink link1/target_rel ../target", "dir link2", "symlink link2/link1_abs /link1", "symlink link2/link1_rel ../link1", "dir link3", "symlink link3/target_abs /link2/link1_rel/target_rel", "symlink link3/target_rel ../link2/link1_rel/target_rel", "symlink link3/deep_dangling1 ../link2/link1_rel/target_rel/nonexist", "symlink link3/deep_dangling2 ../link2/link1_rel/target_rel/nonexist", // Deep dangling symlinks (with single components). "dir dangling", "symlink dangling/a b/c", "dir dangling/b", "symlink dangling/b/c ../c", "symlink dangling/c d/e", "dir dangling/d", "symlink dangling/d/e ../e", "symlink dangling/e f/../g", "dir dangling/f", "symlink dangling/g h/i/j/nonexistent", "dir dangling/h/i/j", // Deep dangling symlink using a non-dir component. "dir dangling-file", "symlink dangling-file/a b/c", "dir dangling-file/b", "symlink dangling-file/b/c ../c", "symlink dangling-file/c d/e", "dir dangling-file/d", "symlink dangling-file/d/e ../e", "symlink dangling-file/e f/../g", "dir dangling-file/f", "symlink dangling-file/g h/i/j/file/foo", "dir dangling-file/h/i/j", "file dangling-file/h/i/j/file", // Some "bad" inodes that a regular user can create. "fifo b/fifo", "sock b/sock", // Symlink loops. "dir loop", "symlink loop/basic-loop1 basic-loop1", "symlink loop/basic-loop2 /loop/basic-loop2", "symlink loop/basic-loop3 ../loop/basic-loop3", "dir loop/a", "symlink loop/a/link ../b/link", "dir loop/b", "symlink loop/b/link /loop/c/link", "dir loop/c", "symlink loop/c/link /loop/d/link", "symlink loop/d e", "dir loop/e", "symlink loop/e/link ../a/link", "symlink loop/link a/link", } root := createTree(t, tree...) for name, test := range map[string]struct { unsafePath string expected openResult }{ // Complete lookups. "complete-dir1": {"a", openResult{handlePath: "/a", fileType: unix.S_IFDIR}}, "complete-dir2": {"b/c/d/e/f", openResult{handlePath: "/b/c/d/e/f", fileType: unix.S_IFDIR}}, "complete-dir3": {"b///././c////.//d/./././///e////.//./f//././././", openResult{handlePath: "/b/c/d/e/f", fileType: unix.S_IFDIR}}, "complete-file": {"b/c/file", openResult{handlePath: "/b/c/file", fileType: unix.S_IFREG}}, "complete-file-link": {"b-file", openResult{handlePath: "/b/c/file", fileType: unix.S_IFREG}}, "complete-fifo": {"b/fifo", openResult{handlePath: "/b/fifo", fileType: unix.S_IFIFO}}, "complete-sock": {"b/sock", openResult{handlePath: "/b/sock", fileType: unix.S_IFSOCK}}, // Partial lookups. "partial-dir-basic": {"a/b/c/d/e/f/g/h", openResult{err: unix.ENOENT}}, "partial-dir-dotdot": {"a/foo/../bar/baz", openResult{err: unix.ENOENT}}, // Complete lookups of non-lexical symlinks. "nonlexical-basic-complete1": {"target", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-basic-complete2": {"target/", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-basic-complete3": {"target//", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-basic-partial": {"target/foo", openResult{err: unix.ENOENT}}, "nonlexical-basic-partial-dotdot": {"target/../target/foo/bar/../baz", openResult{err: unix.ENOENT}}, "nonlexical-level1-abs-complete1": {"link1/target_abs", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level1-abs-complete2": {"link1/target_abs/", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level1-abs-complete3": {"link1/target_abs//", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level1-abs-partial": {"link1/target_abs/foo", openResult{err: unix.ENOENT}}, "nonlexical-level1-abs-partial-dotdot": {"link1/target_abs/../target/foo/bar/../baz", openResult{err: unix.ENOENT}}, "nonlexical-level1-rel-complete1": {"link1/target_rel", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level1-rel-complete2": {"link1/target_rel/", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level1-rel-complete3": {"link1/target_rel//", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level1-rel-partial": {"link1/target_rel/foo", openResult{err: unix.ENOENT}}, "nonlexical-level1-rel-partial-dotdot": {"link1/target_rel/../target/foo/bar/../baz", openResult{err: unix.ENOENT}}, "nonlexical-level2-abs-abs-complete1": {"link2/link1_abs/target_abs", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level2-abs-abs-complete2": {"link2/link1_abs/target_abs/", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level2-abs-abs-complete3": {"link2/link1_abs/target_abs//", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level2-abs-abs-partial": {"link2/link1_abs/target_abs/foo", openResult{err: unix.ENOENT}}, "nonlexical-level2-abs-abs-partial-dotdot": {"link2/link1_abs/target_abs/../target/foo/bar/../baz", openResult{err: unix.ENOENT}}, "nonlexical-level2-abs-rel-complete1": {"link2/link1_abs/target_rel", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level2-abs-rel-complete2": {"link2/link1_abs/target_rel/", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level2-abs-rel-complete3": {"link2/link1_abs/target_rel//", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level2-abs-rel-partial": {"link2/link1_abs/target_rel/foo", openResult{err: unix.ENOENT}}, "nonlexical-level2-abs-rel-partial-dotdot": {"link2/link1_abs/target_rel/../target/foo/bar/../baz", openResult{err: unix.ENOENT}}, "nonlexical-level2-abs-open-complete1": {"link2/link1_abs/../target", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level2-abs-open-complete2": {"link2/link1_abs/../target/", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level2-abs-open-complete3": {"link2/link1_abs/../target//", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level2-abs-open-partial": {"link2/link1_abs/../target/foo", openResult{err: unix.ENOENT}}, "nonlexical-level2-abs-open-partial-dotdot": {"link2/link1_abs/../target/../target/foo/bar/../baz", openResult{err: unix.ENOENT}}, "nonlexical-level2-rel-abs-complete1": {"link2/link1_rel/target_abs", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level2-rel-abs-complete2": {"link2/link1_rel/target_abs/", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level2-rel-abs-complete3": {"link2/link1_rel/target_abs//", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level2-rel-abs-partial": {"link2/link1_rel/target_abs/foo", openResult{err: unix.ENOENT}}, "nonlexical-level2-rel-abs-partial-dotdot": {"link2/link1_rel/target_abs/../target/foo/bar/../baz", openResult{err: unix.ENOENT}}, "nonlexical-level2-rel-rel-complete1": {"link2/link1_rel/target_rel", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level2-rel-rel-complete2": {"link2/link1_rel/target_rel/", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level2-rel-rel-complete3": {"link2/link1_rel/target_rel//", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level2-rel-rel-partial": {"link2/link1_rel/target_rel/foo", openResult{err: unix.ENOENT}}, "nonlexical-level2-rel-rel-partial-dotdot": {"link2/link1_rel/target_rel/../target/foo/bar/../baz", openResult{err: unix.ENOENT}}, "nonlexical-level2-rel-open-complete1": {"link2/link1_rel/../target", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level2-rel-open-complete2": {"link2/link1_rel/../target/", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level2-rel-open-complete3": {"link2/link1_rel/../target//", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level2-rel-open-partial": {"link2/link1_rel/../target/foo", openResult{err: unix.ENOENT}}, "nonlexical-level2-rel-open-partial-dotdot": {"link2/link1_rel/../target/../target/foo/bar/../baz", openResult{err: unix.ENOENT}}, "nonlexical-level3-abs-complete1": {"link3/target_abs", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level3-abs-complete2": {"link3/target_abs/", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level3-abs-complete3": {"link3/target_abs//", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level3-abs-partial": {"link3/target_abs/foo", openResult{err: unix.ENOENT}}, "nonlexical-level3-abs-partial-dotdot": {"link3/target_abs/../target/foo/bar/../baz", openResult{err: unix.ENOENT}}, "nonlexical-level3-rel-complete1": {"link3/target_rel", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level3-rel-complete2": {"link3/target_rel/", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level3-rel-complete3": {"link3/target_rel//", openResult{handlePath: "/target", fileType: unix.S_IFDIR}}, "nonlexical-level3-rel-partial": {"link3/target_rel/foo", openResult{err: unix.ENOENT}}, "nonlexical-level3-rel-partial-dotdot": {"link3/target_rel/../target/foo/bar/../baz", openResult{err: unix.ENOENT}}, // Partial lookups due to hitting a non-directory. "partial-nondir-slash1": {"b/c/file/", openResult{err: unix.ENOTDIR}}, "partial-nondir-slash2": {"b/c/file//", openResult{err: unix.ENOTDIR}}, "partial-nondir-dot": {"b/c/file/.", openResult{err: unix.ENOTDIR}}, "partial-nondir-dotdot1": {"b/c/file/..", openResult{err: unix.ENOTDIR}}, "partial-nondir-dotdot2": {"b/c/file/../foo/bar", openResult{err: unix.ENOTDIR}}, "partial-nondir-symlink-slash1": {"b-file/", openResult{err: unix.ENOTDIR}}, "partial-nondir-symlink-slash2": {"b-file//", openResult{err: unix.ENOTDIR}}, "partial-nondir-symlink-dot": {"b-file/.", openResult{err: unix.ENOTDIR}}, "partial-nondir-symlink-dotdot1": {"b-file/..", openResult{err: unix.ENOTDIR}}, "partial-nondir-symlink-dotdot2": {"b-file/../foo/bar", openResult{err: unix.ENOTDIR}}, "partial-fifo-slash1": {"b/fifo/", openResult{err: unix.ENOTDIR}}, "partial-fifo-slash2": {"b/fifo//", openResult{err: unix.ENOTDIR}}, "partial-fifo-dot": {"b/fifo/.", openResult{err: unix.ENOTDIR}}, "partial-fifo-dotdot1": {"b/fifo/..", openResult{err: unix.ENOTDIR}}, "partial-fifo-dotdot2": {"b/fifo/../foo/bar", openResult{err: unix.ENOTDIR}}, "partial-sock-slash1": {"b/sock/", openResult{err: unix.ENOTDIR}}, "partial-sock-slash2": {"b/sock//", openResult{err: unix.ENOTDIR}}, "partial-sock-dot": {"b/sock/.", openResult{err: unix.ENOTDIR}}, "partial-sock-dotdot1": {"b/sock/..", openResult{err: unix.ENOTDIR}}, "partial-sock-dotdot2": {"b/sock/../foo/bar", openResult{err: unix.ENOTDIR}}, // Dangling symlinks are treated as though they are non-existent. "dangling1-inroot-trailing": {"a-fake1", openResult{err: unix.ENOENT}}, "dangling1-inroot-partial": {"a-fake1/foo", openResult{err: unix.ENOENT}}, "dangling1-inroot-partial-dotdot": {"a-fake1/../bar/baz", openResult{err: unix.ENOENT}}, "dangling1-sub-trailing": {"c/a-fake1", openResult{err: unix.ENOENT}}, "dangling1-sub-partial": {"c/a-fake1/foo", openResult{err: unix.ENOENT}}, "dangling1-sub-partial-dotdot": {"c/a-fake1/../bar/baz", openResult{err: unix.ENOENT}}, "dangling2-inroot-trailing": {"a-fake2", openResult{err: unix.ENOENT}}, "dangling2-inroot-partial": {"a-fake2/foo", openResult{err: unix.ENOENT}}, "dangling2-inroot-partial-dotdot": {"a-fake2/../bar/baz", openResult{err: unix.ENOENT}}, "dangling2-sub-trailing": {"c/a-fake2", openResult{err: unix.ENOENT}}, "dangling2-sub-partial": {"c/a-fake2/foo", openResult{err: unix.ENOENT}}, "dangling2-sub-partial-dotdot": {"c/a-fake2/../bar/baz", openResult{err: unix.ENOENT}}, "dangling3-inroot-trailing": {"a-fake3", openResult{err: unix.ENOENT}}, "dangling3-inroot-partial": {"a-fake3/foo", openResult{err: unix.ENOENT}}, "dangling3-inroot-partial-dotdot": {"a-fake3/../bar/baz", openResult{err: unix.ENOENT}}, "dangling3-sub-trailing": {"c/a-fake3", openResult{err: unix.ENOENT}}, "dangling3-sub-partial": {"c/a-fake3/foo", openResult{err: unix.ENOENT}}, "dangling3-sub-partial-dotdot": {"c/a-fake3/../bar/baz", openResult{err: unix.ENOENT}}, // Tricky dangling symlinks. "dangling-tricky1-trailing": {"link3/deep_dangling1", openResult{err: unix.ENOENT}}, "dangling-tricky1-partial": {"link3/deep_dangling1/foo", openResult{err: unix.ENOENT}}, "dangling-tricky1-partial-dotdot": {"link3/deep_dangling1/..", openResult{err: unix.ENOENT}}, "dangling-tricky2-trailing": {"link3/deep_dangling2", openResult{err: unix.ENOENT}}, "dangling-tricky2-partial": {"link3/deep_dangling2/foo", openResult{err: unix.ENOENT}}, "dangling-tricky2-partial-dotdot": {"link3/deep_dangling2/..", openResult{err: unix.ENOENT}}, // Really deep dangling links. "deep-dangling1": {"dangling/a", openResult{err: unix.ENOENT}}, "deep-dangling2": {"dangling/b/c", openResult{err: unix.ENOENT}}, "deep-dangling3": {"dangling/c", openResult{err: unix.ENOENT}}, "deep-dangling4": {"dangling/d/e", openResult{err: unix.ENOENT}}, "deep-dangling5": {"dangling/e", openResult{err: unix.ENOENT}}, "deep-dangling6": {"dangling/g", openResult{err: unix.ENOENT}}, "deep-dangling-fileasdir1": {"dangling-file/a", openResult{err: unix.ENOTDIR}}, "deep-dangling-fileasdir2": {"dangling-file/b/c", openResult{err: unix.ENOTDIR}}, "deep-dangling-fileasdir3": {"dangling-file/c", openResult{err: unix.ENOTDIR}}, "deep-dangling-fileasdir4": {"dangling-file/d/e", openResult{err: unix.ENOTDIR}}, "deep-dangling-fileasdir5": {"dangling-file/e", openResult{err: unix.ENOTDIR}}, "deep-dangling-fileasdir6": {"dangling-file/g", openResult{err: unix.ENOTDIR}}, // Symlink loops. "loop": {"loop/link", openResult{err: unix.ELOOP}}, "loop-basic1": {"loop/basic-loop1", openResult{err: unix.ELOOP}}, "loop-basic2": {"loop/basic-loop2", openResult{err: unix.ELOOP}}, "loop-basic3": {"loop/basic-loop3", openResult{err: unix.ELOOP}}, } { test := test // copy iterator // Update the handlePath to be inside our root. if test.expected.handlePath != "" { test.expected.handlePath = filepath.Join(root, test.expected.handlePath) } t.Run(name, func(t *testing.T) { checkOpenInRoot(t, openInRootFn, root, test.unsafePath, test.expected) }) } } func TestOpenInRoot(t *testing.T) { withWithoutOpenat2(t, true, func(t *testing.T) { testOpenInRoot(t, OpenInRoot) }) } func TestOpenInRootHandle(t *testing.T) { withWithoutOpenat2(t, true, func(t *testing.T) { testOpenInRoot(t, func(root, unsafePath string) (*os.File, error) { rootDir, err := os.OpenFile(root, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) if err != nil { return nil, err } defer rootDir.Close() return OpenatInRoot(rootDir, unsafePath) }) }) } func TestOpenInRoot_BadInode(t *testing.T) { requireRoot(t) // mknod withWithoutOpenat2(t, true, func(t *testing.T) { tree := []string{ // Make sure we don't open "bad" inodes. "dir foo", "char foo/whiteout 0 0", "block foo/whiteout-blk 0 0", } root := createTree(t, tree...) rootDir, err := os.OpenFile(root, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) require.NoError(t, err) defer rootDir.Close() for name, test := range map[string]struct { unsafePath string expected openResult }{ // Complete lookups. "char-trailing": {"foo/whiteout", openResult{handlePath: "/foo/whiteout", fileType: unix.S_IFCHR}}, "blk-trailing": {"foo/whiteout-blk", openResult{handlePath: "/foo/whiteout-blk", fileType: unix.S_IFBLK}}, // Partial lookups due to hitting a non-directory. "char-dot": {"foo/whiteout/.", openResult{err: unix.ENOTDIR}}, "char-dotdot1": {"foo/whiteout/..", openResult{err: unix.ENOTDIR}}, "char-dotdot2": {"foo/whiteout/../foo/bar", openResult{err: unix.ENOTDIR}}, "blk-dot": {"foo/whiteout-blk/.", openResult{err: unix.ENOTDIR}}, "blk-dotdot1": {"foo/whiteout-blk/..", openResult{err: unix.ENOTDIR}}, "blk-dotdot2": {"foo/whiteout-blk/../foo/bar", openResult{err: unix.ENOTDIR}}, } { test := test // copy iterator // Update the handlePath to be inside our root. if test.expected.handlePath != "" { test.expected.handlePath = filepath.Join(root, test.expected.handlePath) } t.Run(name, func(t *testing.T) { checkOpenInRoot(t, OpenInRoot, root, test.unsafePath, test.expected) }) } }) } filepath-securejoin-0.3.4/openat2_linux.go000066400000000000000000000100241470137774400206070ustar00rootroot00000000000000//go:build linux // Copyright (C) 2024 SUSE LLC. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package securejoin import ( "errors" "fmt" "os" "path/filepath" "strings" "sync" "golang.org/x/sys/unix" ) var hasOpenat2 = sync.OnceValue(func() bool { fd, err := unix.Openat2(unix.AT_FDCWD, ".", &unix.OpenHow{ Flags: unix.O_PATH | unix.O_CLOEXEC, Resolve: unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_IN_ROOT, }) if err != nil { return false } _ = unix.Close(fd) return true }) func scopedLookupShouldRetry(how *unix.OpenHow, err error) bool { // RESOLVE_IN_ROOT (and RESOLVE_BENEATH) can return -EAGAIN if we resolve // ".." while a mount or rename occurs anywhere on the system. This could // happen spuriously, or as the result of an attacker trying to mess with // us during lookup. // // In addition, scoped lookups have a "safety check" at the end of // complete_walk which will return -EXDEV if the final path is not in the // root. return how.Resolve&(unix.RESOLVE_IN_ROOT|unix.RESOLVE_BENEATH) != 0 && (errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EXDEV)) } const scopedLookupMaxRetries = 10 func openat2File(dir *os.File, path string, how *unix.OpenHow) (*os.File, error) { fullPath := dir.Name() + "/" + path // Make sure we always set O_CLOEXEC. how.Flags |= unix.O_CLOEXEC var tries int for tries < scopedLookupMaxRetries { fd, err := unix.Openat2(int(dir.Fd()), path, how) if err != nil { if scopedLookupShouldRetry(how, err) { // We retry a couple of times to avoid the spurious errors, and // if we are being attacked then returning -EAGAIN is the best // we can do. tries++ continue } return nil, &os.PathError{Op: "openat2", Path: fullPath, Err: err} } // If we are using RESOLVE_IN_ROOT, the name we generated may be wrong. // NOTE: The procRoot code MUST NOT use RESOLVE_IN_ROOT, otherwise // you'll get infinite recursion here. if how.Resolve&unix.RESOLVE_IN_ROOT == unix.RESOLVE_IN_ROOT { if actualPath, err := rawProcSelfFdReadlink(fd); err == nil { fullPath = actualPath } } return os.NewFile(uintptr(fd), fullPath), nil } return nil, &os.PathError{Op: "openat2", Path: fullPath, Err: errPossibleAttack} } func lookupOpenat2(root *os.File, unsafePath string, partial bool) (*os.File, string, error) { if !partial { file, err := openat2File(root, unsafePath, &unix.OpenHow{ Flags: unix.O_PATH | unix.O_CLOEXEC, Resolve: unix.RESOLVE_IN_ROOT | unix.RESOLVE_NO_MAGICLINKS, }) return file, "", err } return partialLookupOpenat2(root, unsafePath) } // partialLookupOpenat2 is an alternative implementation of // partialLookupInRoot, using openat2(RESOLVE_IN_ROOT) to more safely get a // handle to the deepest existing child of the requested path within the root. func partialLookupOpenat2(root *os.File, unsafePath string) (*os.File, string, error) { // TODO: Implement this as a git-bisect-like binary search. unsafePath = filepath.ToSlash(unsafePath) // noop endIdx := len(unsafePath) var lastError error for endIdx > 0 { subpath := unsafePath[:endIdx] handle, err := openat2File(root, subpath, &unix.OpenHow{ Flags: unix.O_PATH | unix.O_CLOEXEC, Resolve: unix.RESOLVE_IN_ROOT | unix.RESOLVE_NO_MAGICLINKS, }) if err == nil { // Jump over the slash if we have a non-"" remainingPath. if endIdx < len(unsafePath) { endIdx += 1 } // We found a subpath! return handle, unsafePath[endIdx:], lastError } if errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ENOTDIR) { // That path doesn't exist, let's try the next directory up. endIdx = strings.LastIndexByte(subpath, '/') lastError = err continue } return nil, "", fmt.Errorf("open subpath: %w", err) } // If we couldn't open anything, the whole subpath is missing. Return a // copy of the root fd so that the caller doesn't close this one by // accident. rootClone, err := dupFile(root) if err != nil { return nil, "", err } return rootClone, unsafePath, lastError } filepath-securejoin-0.3.4/openat_linux.go000066400000000000000000000032561470137774400205360ustar00rootroot00000000000000//go:build linux // Copyright (C) 2024 SUSE LLC. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package securejoin import ( "os" "path/filepath" "golang.org/x/sys/unix" ) func dupFile(f *os.File) (*os.File, error) { fd, err := unix.FcntlInt(f.Fd(), unix.F_DUPFD_CLOEXEC, 0) if err != nil { return nil, os.NewSyscallError("fcntl(F_DUPFD_CLOEXEC)", err) } return os.NewFile(uintptr(fd), f.Name()), nil } func openatFile(dir *os.File, path string, flags int, mode int) (*os.File, error) { // Make sure we always set O_CLOEXEC. flags |= unix.O_CLOEXEC fd, err := unix.Openat(int(dir.Fd()), path, flags, uint32(mode)) if err != nil { return nil, &os.PathError{Op: "openat", Path: dir.Name() + "/" + path, Err: err} } // All of the paths we use with openatFile(2) are guaranteed to be // lexically safe, so we can use path.Join here. fullPath := filepath.Join(dir.Name(), path) return os.NewFile(uintptr(fd), fullPath), nil } func fstatatFile(dir *os.File, path string, flags int) (unix.Stat_t, error) { var stat unix.Stat_t if err := unix.Fstatat(int(dir.Fd()), path, &stat, flags); err != nil { return stat, &os.PathError{Op: "fstatat", Path: dir.Name() + "/" + path, Err: err} } return stat, nil } func readlinkatFile(dir *os.File, path string) (string, error) { size := 4096 for { linkBuf := make([]byte, size) n, err := unix.Readlinkat(int(dir.Fd()), path, linkBuf) if err != nil { return "", &os.PathError{Op: "readlinkat", Path: dir.Name() + "/" + path, Err: err} } if n != size { return string(linkBuf[:n]), nil } // Possible truncation, resize the buffer. size *= 2 } } filepath-securejoin-0.3.4/procfs_linux.go000066400000000000000000000355751470137774400205550ustar00rootroot00000000000000//go:build linux // Copyright (C) 2024 SUSE LLC. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package securejoin import ( "errors" "fmt" "os" "runtime" "strconv" "sync" "golang.org/x/sys/unix" ) func fstat(f *os.File) (unix.Stat_t, error) { var stat unix.Stat_t if err := unix.Fstat(int(f.Fd()), &stat); err != nil { return stat, &os.PathError{Op: "fstat", Path: f.Name(), Err: err} } return stat, nil } func fstatfs(f *os.File) (unix.Statfs_t, error) { var statfs unix.Statfs_t if err := unix.Fstatfs(int(f.Fd()), &statfs); err != nil { return statfs, &os.PathError{Op: "fstatfs", Path: f.Name(), Err: err} } return statfs, nil } // The kernel guarantees that the root inode of a procfs mount has an // f_type of PROC_SUPER_MAGIC and st_ino of PROC_ROOT_INO. const ( procSuperMagic = 0x9fa0 // PROC_SUPER_MAGIC procRootIno = 1 // PROC_ROOT_INO ) func verifyProcRoot(procRoot *os.File) error { if statfs, err := fstatfs(procRoot); err != nil { return err } else if statfs.Type != procSuperMagic { return fmt.Errorf("%w: incorrect procfs root filesystem type 0x%x", errUnsafeProcfs, statfs.Type) } if stat, err := fstat(procRoot); err != nil { return err } else if stat.Ino != procRootIno { return fmt.Errorf("%w: incorrect procfs root inode number %d", errUnsafeProcfs, stat.Ino) } return nil } var hasNewMountApi = sync.OnceValue(func() bool { // All of the pieces of the new mount API we use (fsopen, fsconfig, // fsmount, open_tree) were added together in Linux 5.1[1,2], so we can // just check for one of the syscalls and the others should also be // available. // // Just try to use open_tree(2) to open a file without OPEN_TREE_CLONE. // This is equivalent to openat(2), but tells us if open_tree is // available (and thus all of the other basic new mount API syscalls). // open_tree(2) is most light-weight syscall to test here. // // [1]: merge commit 400913252d09 // [2]: fd, err := unix.OpenTree(-int(unix.EBADF), "/", unix.OPEN_TREE_CLOEXEC) if err != nil { return false } _ = unix.Close(fd) return true }) func fsopen(fsName string, flags int) (*os.File, error) { // Make sure we always set O_CLOEXEC. flags |= unix.FSOPEN_CLOEXEC fd, err := unix.Fsopen(fsName, flags) if err != nil { return nil, os.NewSyscallError("fsopen "+fsName, err) } return os.NewFile(uintptr(fd), "fscontext:"+fsName), nil } func fsmount(ctx *os.File, flags, mountAttrs int) (*os.File, error) { // Make sure we always set O_CLOEXEC. flags |= unix.FSMOUNT_CLOEXEC fd, err := unix.Fsmount(int(ctx.Fd()), flags, mountAttrs) if err != nil { return nil, os.NewSyscallError("fsmount "+ctx.Name(), err) } return os.NewFile(uintptr(fd), "fsmount:"+ctx.Name()), nil } func newPrivateProcMount() (*os.File, error) { procfsCtx, err := fsopen("proc", unix.FSOPEN_CLOEXEC) if err != nil { return nil, err } defer procfsCtx.Close() // Try to configure hidepid=ptraceable,subset=pid if possible, but ignore errors. _ = unix.FsconfigSetString(int(procfsCtx.Fd()), "hidepid", "ptraceable") _ = unix.FsconfigSetString(int(procfsCtx.Fd()), "subset", "pid") // Get an actual handle. if err := unix.FsconfigCreate(int(procfsCtx.Fd())); err != nil { return nil, os.NewSyscallError("fsconfig create procfs", err) } return fsmount(procfsCtx, unix.FSMOUNT_CLOEXEC, unix.MS_RDONLY|unix.MS_NODEV|unix.MS_NOEXEC|unix.MS_NOSUID) } func openTree(dir *os.File, path string, flags uint) (*os.File, error) { dirFd := -int(unix.EBADF) dirName := "." if dir != nil { dirFd = int(dir.Fd()) dirName = dir.Name() } // Make sure we always set O_CLOEXEC. flags |= unix.OPEN_TREE_CLOEXEC fd, err := unix.OpenTree(dirFd, path, flags) if err != nil { return nil, &os.PathError{Op: "open_tree", Path: path, Err: err} } return os.NewFile(uintptr(fd), dirName+"/"+path), nil } func clonePrivateProcMount() (_ *os.File, Err error) { // Try to make a clone without using AT_RECURSIVE if we can. If this works, // we can be sure there are no over-mounts and so if the root is valid then // we're golden. Otherwise, we have to deal with over-mounts. procfsHandle, err := openTree(nil, "/proc", unix.OPEN_TREE_CLONE) if err != nil || hookForcePrivateProcRootOpenTreeAtRecursive(procfsHandle) { procfsHandle, err = openTree(nil, "/proc", unix.OPEN_TREE_CLONE|unix.AT_RECURSIVE) } if err != nil { return nil, fmt.Errorf("creating a detached procfs clone: %w", err) } defer func() { if Err != nil { _ = procfsHandle.Close() } }() if err := verifyProcRoot(procfsHandle); err != nil { return nil, err } return procfsHandle, nil } func privateProcRoot() (*os.File, error) { if !hasNewMountApi() || hookForceGetProcRootUnsafe() { return nil, fmt.Errorf("new mount api: %w", unix.ENOTSUP) } // Try to create a new procfs mount from scratch if we can. This ensures we // can get a procfs mount even if /proc is fake (for whatever reason). procRoot, err := newPrivateProcMount() if err != nil || hookForcePrivateProcRootOpenTree(procRoot) { // Try to clone /proc then... procRoot, err = clonePrivateProcMount() } return procRoot, err } func unsafeHostProcRoot() (_ *os.File, Err error) { procRoot, err := os.OpenFile("/proc", unix.O_PATH|unix.O_NOFOLLOW|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) if err != nil { return nil, err } defer func() { if Err != nil { _ = procRoot.Close() } }() if err := verifyProcRoot(procRoot); err != nil { return nil, err } return procRoot, nil } func doGetProcRoot() (*os.File, error) { procRoot, err := privateProcRoot() if err != nil { // Fall back to using a /proc handle if making a private mount failed. // If we have openat2, at least we can avoid some kinds of over-mount // attacks, but without openat2 there's not much we can do. procRoot, err = unsafeHostProcRoot() } return procRoot, err } var getProcRoot = sync.OnceValues(func() (*os.File, error) { return doGetProcRoot() }) var hasProcThreadSelf = sync.OnceValue(func() bool { return unix.Access("/proc/thread-self/", unix.F_OK) == nil }) var errUnsafeProcfs = errors.New("unsafe procfs detected") type procThreadSelfCloser func() // procThreadSelf returns a handle to /proc/thread-self/ (or an // equivalent handle on older kernels where /proc/thread-self doesn't exist). // Once finished with the handle, you must call the returned closer function // (runtime.UnlockOSThread). You must not pass the returned *os.File to other // Go threads or use the handle after calling the closer. // // This is similar to ProcThreadSelf from runc, but with extra hardening // applied and using *os.File. func procThreadSelf(procRoot *os.File, subpath string) (_ *os.File, _ procThreadSelfCloser, Err error) { // We need to lock our thread until the caller is done with the handle // because between getting the handle and using it we could get interrupted // by the Go runtime and hit the case where the underlying thread is // swapped out and the original thread is killed, resulting in // pull-your-hair-out-hard-to-debug issues in the caller. runtime.LockOSThread() defer func() { if Err != nil { runtime.UnlockOSThread() } }() // Figure out what prefix we want to use. threadSelf := "thread-self/" if !hasProcThreadSelf() || hookForceProcSelfTask() { /// Pre-3.17 kernels don't have /proc/thread-self, so do it manually. threadSelf = "self/task/" + strconv.Itoa(unix.Gettid()) + "/" if _, err := fstatatFile(procRoot, threadSelf, unix.AT_SYMLINK_NOFOLLOW); err != nil || hookForceProcSelf() { // In this case, we running in a pid namespace that doesn't match // the /proc mount we have. This can happen inside runc. // // Unfortunately, there is no nice way to get the correct TID to // use here because of the age of the kernel, so we have to just // use /proc/self and hope that it works. threadSelf = "self/" } } // Grab the handle. var ( handle *os.File err error ) if hasOpenat2() { // We prefer being able to use RESOLVE_NO_XDEV if we can, to be // absolutely sure we are operating on a clean /proc handle that // doesn't have any cheeky overmounts that could trick us (including // symlink mounts on top of /proc/thread-self). RESOLVE_BENEATH isn't // strictly needed, but just use it since we have it. // // NOTE: /proc/self is technically a magic-link (the contents of the // symlink are generated dynamically), but it doesn't use // nd_jump_link() so RESOLVE_NO_MAGICLINKS allows it. // // NOTE: We MUST NOT use RESOLVE_IN_ROOT here, as openat2File uses // procSelfFdReadlink to clean up the returned f.Name() if we use // RESOLVE_IN_ROOT (which would lead to an infinite recursion). handle, err = openat2File(procRoot, threadSelf+subpath, &unix.OpenHow{ Flags: unix.O_PATH | unix.O_NOFOLLOW | unix.O_CLOEXEC, Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_XDEV | unix.RESOLVE_NO_MAGICLINKS, }) if err != nil { return nil, nil, fmt.Errorf("%w: %w", errUnsafeProcfs, err) } } else { handle, err = openatFile(procRoot, threadSelf+subpath, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) if err != nil { return nil, nil, fmt.Errorf("%w: %w", errUnsafeProcfs, err) } defer func() { if Err != nil { _ = handle.Close() } }() // We can't detect bind-mounts of different parts of procfs on top of // /proc (a-la RESOLVE_NO_XDEV), but we can at least be sure that we // aren't on the wrong filesystem here. if statfs, err := fstatfs(handle); err != nil { return nil, nil, err } else if statfs.Type != procSuperMagic { return nil, nil, fmt.Errorf("%w: incorrect /proc/self/fd filesystem type 0x%x", errUnsafeProcfs, statfs.Type) } } return handle, runtime.UnlockOSThread, nil } var hasStatxMountId = sync.OnceValue(func() bool { var ( stx unix.Statx_t // We don't care which mount ID we get. The kernel will give us the // unique one if it is supported. wantStxMask uint32 = unix.STATX_MNT_ID_UNIQUE | unix.STATX_MNT_ID ) err := unix.Statx(-int(unix.EBADF), "/", 0, int(wantStxMask), &stx) return err == nil && stx.Mask&wantStxMask != 0 }) func getMountId(dir *os.File, path string) (uint64, error) { // If we don't have statx(STATX_MNT_ID*) support, we can't do anything. if !hasStatxMountId() { return 0, nil } var ( stx unix.Statx_t // We don't care which mount ID we get. The kernel will give us the // unique one if it is supported. wantStxMask uint32 = unix.STATX_MNT_ID_UNIQUE | unix.STATX_MNT_ID ) err := unix.Statx(int(dir.Fd()), path, unix.AT_EMPTY_PATH|unix.AT_SYMLINK_NOFOLLOW, int(wantStxMask), &stx) if stx.Mask&wantStxMask == 0 { // It's not a kernel limitation, for some reason we couldn't get a // mount ID. Assume it's some kind of attack. err = fmt.Errorf("%w: could not get mount id", errUnsafeProcfs) } if err != nil { return 0, &os.PathError{Op: "statx(STATX_MNT_ID_...)", Path: dir.Name() + "/" + path, Err: err} } return stx.Mnt_id, nil } func checkSymlinkOvermount(procRoot *os.File, dir *os.File, path string) error { // Get the mntId of our procfs handle. expectedMountId, err := getMountId(procRoot, "") if err != nil { return err } // Get the mntId of the target magic-link. gotMountId, err := getMountId(dir, path) if err != nil { return err } // As long as the directory mount is alive, even with wrapping mount IDs, // we would expect to see a different mount ID here. (Of course, if we're // using unsafeHostProcRoot() then an attaker could change this after we // did this check.) if expectedMountId != gotMountId { return fmt.Errorf("%w: symlink %s/%s has an overmount obscuring the real link (mount ids do not match %d != %d)", errUnsafeProcfs, dir.Name(), path, expectedMountId, gotMountId) } return nil } func doRawProcSelfFdReadlink(procRoot *os.File, fd int) (string, error) { fdPath := fmt.Sprintf("fd/%d", fd) procFdLink, closer, err := procThreadSelf(procRoot, fdPath) if err != nil { return "", fmt.Errorf("get safe /proc/thread-self/%s handle: %w", fdPath, err) } defer procFdLink.Close() defer closer() // Try to detect if there is a mount on top of the magic-link. Since we use the handle directly // provide to the closure. If the closure uses the handle directly, this // should be safe in general (a mount on top of the path afterwards would // not affect the handle itself) and will definitely be safe if we are // using privateProcRoot() (at least since Linux 5.12[1], when anonymous // mount namespaces were completely isolated from external mounts including // mount propagation events). // // [1]: Linux commit ee2e3f50629f ("mount: fix mounting of detached mounts // onto targets that reside on shared mounts"). if err := checkSymlinkOvermount(procRoot, procFdLink, ""); err != nil { return "", fmt.Errorf("check safety of /proc/thread-self/fd/%d magiclink: %w", fd, err) } // readlinkat implies AT_EMPTY_PATH since Linux 2.6.39. See Linux commit // 65cfc6722361 ("readlinkat(), fchownat() and fstatat() with empty // relative pathnames"). return readlinkatFile(procFdLink, "") } func rawProcSelfFdReadlink(fd int) (string, error) { procRoot, err := getProcRoot() if err != nil { return "", err } return doRawProcSelfFdReadlink(procRoot, fd) } func procSelfFdReadlink(f *os.File) (string, error) { return rawProcSelfFdReadlink(int(f.Fd())) } var ( errPossibleBreakout = errors.New("possible breakout detected") errInvalidDirectory = errors.New("wandered into deleted directory") errDeletedInode = errors.New("cannot verify path of deleted inode") ) func isDeadInode(file *os.File) error { // If the nlink of a file drops to 0, there is an attacker deleting // directories during our walk, which could result in weird /proc values. // It's better to error out in this case. stat, err := fstat(file) if err != nil { return fmt.Errorf("check for dead inode: %w", err) } if stat.Nlink == 0 { err := errDeletedInode if stat.Mode&unix.S_IFMT == unix.S_IFDIR { err = errInvalidDirectory } return fmt.Errorf("%w %q", err, file.Name()) } return nil } func checkProcSelfFdPath(path string, file *os.File) error { if err := isDeadInode(file); err != nil { return err } actualPath, err := procSelfFdReadlink(file) if err != nil { return fmt.Errorf("get path of handle: %w", err) } if actualPath != path { return fmt.Errorf("%w: handle path %q doesn't match expected path %q", errPossibleBreakout, actualPath, path) } return nil } // Test hooks used in the procfs tests to verify that the fallback logic works. // See testing_mocks_linux_test.go and procfs_linux_test.go for more details. var ( hookForcePrivateProcRootOpenTree = hookDummyFile hookForcePrivateProcRootOpenTreeAtRecursive = hookDummyFile hookForceGetProcRootUnsafe = hookDummy hookForceProcSelfTask = hookDummy hookForceProcSelf = hookDummy ) func hookDummy() bool { return false } func hookDummyFile(_ *os.File) bool { return false } filepath-securejoin-0.3.4/procfs_linux_test.go000066400000000000000000000354011470137774400216000ustar00rootroot00000000000000//go:build linux // Copyright (C) 2024 SUSE LLC. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package securejoin import ( "errors" "fmt" "os" "path" "runtime" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sys/unix" ) func doMount(t *testing.T, source, target, fsType string, flags uintptr) { var sourcePath string if source != "" { // In order to be able to bind-mount a symlink source we need to // bind-mount using an O_PATH|O_NOFOLLOW of the source. file, err := os.OpenFile(source, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) require.NoError(t, err) defer runtime.KeepAlive(file) defer file.Close() sourcePath = fmt.Sprintf("/proc/self/fd/%d", file.Fd()) } var targetPath string if target != "" { // In order to be able to mount on top of symlinks we need to // bind-mount through an O_PATH|O_NOFOLLOW of the target. file, err := os.OpenFile(target, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) require.NoError(t, err) defer runtime.KeepAlive(file) defer file.Close() targetPath = fmt.Sprintf("/proc/self/fd/%d", file.Fd()) } err := unix.Mount(sourcePath, targetPath, fsType, flags, "") if errors.Is(err, unix.ENOENT) { // Future kernels will block these kinds of mounts by marking all of // these dentries with dont_mount(), which returns -ENOENT from mount. // See , // which should make it into Linux 6.12. So ignore those errors. t.Skipf("current kernel does not allow /proc overmounts -- all proc operations are implicitly safe") } require.NoErrorf(t, err, "mount(%s<%s>, %s<%s>, %s, 0x%x)", sourcePath, source, targetPath, target, fsType, flags) } func setupMountNamespace(t *testing.T) { requireRoot(t) // Lock our thread because we need to create a custom mount namespace. Each // test run is run in its own goroutine (this is not _explicitly_ // guaranteed by Go but t.FailNow() uses Goexit, which means it has to be // true in practice) so locking the test to this thread means the other // tests will run on different goroutines. // // There is no UnlockOSThread() here, to ensure that the Go runtime will // kill this thread once this goroutine returns (ensuring no other // goroutines run in this context). runtime.LockOSThread() // New mount namespace (we are multi-threaded with a shared fs so we need // CLONE_FS to split us from the other threads in the Go process). err := unix.Unshare(unix.CLONE_FS | unix.CLONE_NEWNS) require.NoError(t, err, "new mount namespace") // Private /. err = unix.Mount("", "/", "", unix.MS_PRIVATE|unix.MS_REC, "") require.NoError(t, err) } func testProcThreadSelf(t *testing.T, procRoot *os.File, subpath string, expectErr bool) { handle, closer, err := procThreadSelf(procRoot, subpath) if expectErr { assert.ErrorIsf(t, err, errUnsafeProcfs, "should have detected /proc/thread-self/%s overmount", subpath) } else if assert.NoErrorf(t, err, "/proc/thread-self/%s open should succeed", subpath) { _ = handle.Close() closer() // LockOSThread stacks, so we can call this safely. } } type procRootFunc func() (*os.File, error) func testProcOvermountSubdir(t *testing.T, procRootFn procRootFunc, expectOvermounts bool) { testForceProcThreadSelf(t, func(t *testing.T) { setupMountNamespace(t) // Create some overmounts on /proc/{thread-self/,self/}. for _, procThreadSelfPath := range []string{ fmt.Sprintf("/proc/self/task/%d", unix.Gettid()), "/proc/self", } { for _, mount := range []struct { source, targetSubPath, fsType string flags uintptr }{ // A tmpfs on top of /proc/thread-self/fdinfo to check whether // verifyProcRoot() works on old kernels. {"", "fdinfo", "tmpfs", 0}, // A bind-mount of noop-write real procfs file on top of // /proc/thread-self/attr/current so we can test whether // verifyProcRoot() works for the file case. // // We don't use procThreadSelf for files in filepath-securejoin, but // this is to test the runc-equivalent behaviour for when this logic is // moved to libpathrs. {"/proc/self/sched", "attr/current", "", unix.MS_BIND}, // Bind-mounts on top of symlinks should be detected by // checkSymlinkOvermount. {"/proc/1/fd/0", "exe", "", unix.MS_BIND}, {"/proc/1/exe", "fd/0", "", unix.MS_BIND}, // TODO: Add a test for mounting on top of /proc/self or // /proc/thread-self. This should be detected with openat2. } { target := path.Join(procThreadSelfPath, mount.targetSubPath) doMount(t, mount.source, target, mount.fsType, mount.flags) } } procRoot, err := procRootFn() require.NoError(t, err) defer procRoot.Close() // We expect to always detect tmpfs overmounts if we have a /proc with // overmounts. detectFdinfo := expectOvermounts testProcThreadSelf(t, procRoot, "fdinfo", detectFdinfo) // We only expect to detect procfs bind-mounts if there are /proc // overmounts and we have openat2. detectAttrCurrent := expectOvermounts && hasOpenat2() testProcThreadSelf(t, procRoot, "attr/current", detectAttrCurrent) // For magic-links we expect to detect overmounts if there are any. symlinkOvermountErr := errUnsafeProcfs if !expectOvermounts { symlinkOvermountErr = nil } procSelf, closer, err := procThreadSelf(procRoot, ".") require.NoError(t, err) defer procSelf.Close() defer closer() // Open these paths directly to emulate a non-openat2 handle that // didn't detect a bind-mount to check that checkSymlinkOvermount works // properly for AT_EMPTY_PATH checks as well. procCwd, err := openatFile(procSelf, "cwd", unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) require.NoError(t, err) defer procCwd.Close() procExe, err := openatFile(procSelf, "exe", unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) require.NoError(t, err) defer procExe.Close() // no overmount err = checkSymlinkOvermount(procRoot, procCwd, "") assert.NoError(t, err, "checking /proc/self/cwd with no overmount should succeed") err = checkSymlinkOvermount(procRoot, procSelf, "cwd") assert.NoError(t, err, "checking /proc/self/cwd with no overmount should succeed") // basic overmount err = checkSymlinkOvermount(procRoot, procExe, "") assert.ErrorIs(t, err, symlinkOvermountErr, "unexpected /proc/self/exe overmount result") err = checkSymlinkOvermount(procRoot, procSelf, "exe") assert.ErrorIs(t, err, symlinkOvermountErr, "unexpected /proc/self/exe overmount result") // fd no overmount _, err = doRawProcSelfFdReadlink(procRoot, 1) assert.NoError(t, err, "checking /proc/self/fd/1 with no overmount should succeed") // fd overmount link, err := doRawProcSelfFdReadlink(procRoot, 0) assert.ErrorIs(t, err, symlinkOvermountErr, "unexpected /proc/self/fd/0 overmount result: got link %q", link) }) } func TestProcOvermountSubdir_unsafeHostProcRoot(t *testing.T) { withWithoutOpenat2(t, true, func(t *testing.T) { // If we use the host /proc directly, we should see overmounts. testProcOvermountSubdir(t, unsafeHostProcRoot, true) }) } func TestProcOvermountSubdir_newPrivateProcMount(t *testing.T) { if !hasNewMountApi() { t.Skip("test requires fsopen/open_tree support") } withWithoutOpenat2(t, true, func(t *testing.T) { // If we create our own procfs, the overmounts shouldn't appear. testProcOvermountSubdir(t, newPrivateProcMount, false) }) } func TestProcOvermountSubdir_clonePrivateProcMount(t *testing.T) { if !hasNewMountApi() { t.Skip("test requires fsopen/open_tree support") } withWithoutOpenat2(t, true, func(t *testing.T) { // If we use open_tree(2), we don't use AT_RECURSIVE when running in // this test (because the overmounts are not locked mounts) and so we // don't expect to see overmounts. testProcOvermountSubdir(t, clonePrivateProcMount, false) }) } func TestProcOvermountSubdir_doGetProcRoot(t *testing.T) { withWithoutOpenat2(t, true, func(t *testing.T) { // We expect to not get overmounts if we have the new mount API. // FIXME: It's possible to hit overmounts if there are locked mounts // and we hit the AT_RECURSIVE case... testProcOvermountSubdir(t, doGetProcRoot, !hasNewMountApi()) }) } func TestProcOvermountSubdir_doGetProcRoot_Mocked(t *testing.T) { if !hasNewMountApi() { t.Skip("test requires fsopen/open_tree support") } withWithoutOpenat2(t, true, func(t *testing.T) { testForceGetProcRoot(t, func(t *testing.T, expectOvermounts bool) { testProcOvermountSubdir(t, doGetProcRoot, expectOvermounts) }) }) } func canFsOpen() bool { f, err := fsopen("tmpfs", 0) if f != nil { _ = f.Close() } return err == nil } func testProcOvermount(t *testing.T, procRootFn procRootFunc, privateProcMount bool) { testForceProcThreadSelf(t, func(t *testing.T) { for _, mount := range []struct { source, fsType string flags uintptr }{ // Try a non-procfs filesystem overmount. {"", "tmpfs", 0}, // Try a procfs subdir overmount. {"/proc/tty", "bind", unix.MS_BIND}, } { mount := mount // copy iterator t.Run("procmount="+mount.fsType, func(t *testing.T) { setupMountNamespace(t) doMount(t, mount.source, "/proc", mount.fsType, mount.flags) procRoot, err := procRootFn() if procRoot != nil { defer procRoot.Close() } if privateProcMount { assert.NoError(t, err, "get proc handle should succeed") assert.NoError(t, verifyProcRoot(procRoot), "verify private proc mount should succeed") } else { if !assert.ErrorIs(t, err, errUnsafeProcfs, "get proc handle should fail") { t.Logf("procRootFn() = %v, %v", procRoot, err) } } }) } }) } func TestProcOvermount_unsafeHostProcRoot(t *testing.T) { testProcOvermount(t, unsafeHostProcRoot, false) } func TestProcOvermount_clonePrivateProcMount(t *testing.T) { if !hasNewMountApi() { t.Skip("test requires open_tree support") } testProcOvermount(t, clonePrivateProcMount, false) } func TestProcOvermount_newPrivateProcMount(t *testing.T) { if !hasNewMountApi() || !canFsOpen() { t.Skip("test requires fsopen support") } testProcOvermount(t, newPrivateProcMount, true) } func TestProcOvermount_doGetProcRoot(t *testing.T) { privateProcMount := canFsOpen() && !testingForcePrivateProcRootOpenTree(nil) testProcOvermount(t, doGetProcRoot, privateProcMount) } func TestProcOvermount_doGetProcRoot_Mocked(t *testing.T) { if !hasNewMountApi() { t.Skip("test requires fsopen/open_tree support") } testForceGetProcRoot(t, func(t *testing.T, expectOvermounts bool) { privateProcMount := canFsOpen() && !testingForcePrivateProcRootOpenTree(nil) testProcOvermount(t, doGetProcRoot, privateProcMount) }) } func TestProcSelfFdPath(t *testing.T) { testForceProcThreadSelf(t, func(t *testing.T) { root := t.TempDir() filePath := path.Join(root, "file") err := unix.Mknod(filePath, unix.S_IFREG|0o644, 0) require.NoError(t, err) symPath := path.Join(root, "sym") err = unix.Symlink(filePath, symPath) require.NoError(t, err) // Open through the symlink. handle, err := os.Open(symPath) defer handle.Close() // The check should fail if we expect the symlink path. err = checkProcSelfFdPath(symPath, handle) assert.ErrorIs(t, err, errPossibleBreakout, "checkProcSelfFdPath should fail for wrong path") // The check should fail if we expect the symlink path. err = checkProcSelfFdPath(filePath, handle) assert.NoError(t, err) }) } func TestProcSelfFdPath_DeadFile(t *testing.T) { testForceProcThreadSelf(t, func(t *testing.T) { root := t.TempDir() fullPath := path.Join(root, "file") handle, err := os.Create(fullPath) require.NoError(t, err) defer handle.Close() // The path still exists. err = checkProcSelfFdPath(fullPath, handle) assert.NoError(t, err, "checkProcSelfFdPath should succeed with regular file") // Delete the path. err = os.Remove(fullPath) require.NoError(t, err) // The check should fail now. err = checkProcSelfFdPath(fullPath, handle) assert.ErrorIs(t, err, errDeletedInode, "checkProcSelfFdPath should fail after deletion") // The check should fail even if the expected path ends with " (deleted)". err = checkProcSelfFdPath(fullPath+" (deleted)", handle) assert.ErrorIs(t, err, errDeletedInode, "checkProcSelfFdPath should fail after deletion even with (deleted) suffix") }) } func TestProcSelfFdPath_DeadDir(t *testing.T) { testForceProcThreadSelf(t, func(t *testing.T) { root := t.TempDir() fullPath := path.Join(root, "dir") err := os.Mkdir(fullPath, 0o755) require.NoError(t, err) handle, err := os.OpenFile(fullPath, unix.O_DIRECTORY|unix.O_CLOEXEC, 0) require.NoError(t, err) defer handle.Close() // The path still exists. err = checkProcSelfFdPath(fullPath, handle) assert.NoError(t, err, "checkProcSelfFdPath should succeed with regular directory") // Delete the path. err = os.Remove(fullPath) require.NoError(t, err) // The check should fail now. err = checkProcSelfFdPath(fullPath, handle) assert.ErrorIs(t, err, errInvalidDirectory, "checkProcSelfFdPath should fail after deletion") // The check should fail even if the expected path ends with " (deleted)". err = checkProcSelfFdPath(fullPath+" (deleted)", handle) assert.ErrorIs(t, err, errInvalidDirectory, "checkProcSelfFdPath should fail after deletion even with (deleted) suffix") }) } func testVerifyProcRoot(t *testing.T, procRoot string, expectedErr error, errString string) { fakeProcRoot, err := os.OpenFile(procRoot, unix.O_PATH|unix.O_CLOEXEC, 0) require.NoError(t, err) defer fakeProcRoot.Close() err = verifyProcRoot(fakeProcRoot) assert.ErrorIsf(t, err, expectedErr, "verifyProcRoot(%s)", procRoot) if expectedErr != nil { assert.ErrorContainsf(t, err, errString, "verifyProcRoot(%s)", procRoot) } } func TestVerifyProcRoot_Regular(t *testing.T) { testForceProcThreadSelf(t, func(t *testing.T) { testVerifyProcRoot(t, "/proc", nil, "") }) } func TestVerifyProcRoot_ProcNonRoot(t *testing.T) { testForceProcThreadSelf(t, func(t *testing.T) { testVerifyProcRoot(t, "/proc/self", errUnsafeProcfs, "incorrect procfs root inode number") testVerifyProcRoot(t, "/proc/mounts", errUnsafeProcfs, "incorrect procfs root inode number") testVerifyProcRoot(t, "/proc/stat", errUnsafeProcfs, "incorrect procfs root inode number") }) } func TestVerifyProcRoot_NotProc(t *testing.T) { testForceProcThreadSelf(t, func(t *testing.T) { testVerifyProcRoot(t, "/", errUnsafeProcfs, "incorrect procfs root filesystem type") testVerifyProcRoot(t, ".", errUnsafeProcfs, "incorrect procfs root filesystem type") testVerifyProcRoot(t, t.TempDir(), errUnsafeProcfs, "incorrect procfs root filesystem type") }) } func TestProcfsDummyHooks(t *testing.T) { assert.False(t, hookDummy(), "hookDummy should always return false") assert.False(t, hookDummyFile(nil), "hookDummyFile should always return false") } filepath-securejoin-0.3.4/testing_mocks_linux_test.go000066400000000000000000000042341470137774400231550ustar00rootroot00000000000000//go:build linux // Copyright (C) 2024 SUSE LLC. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package securejoin import ( "os" "testing" ) type forceGetProcRootLevel int const ( forceGetProcRootDefault forceGetProcRootLevel = iota forceGetProcRootOpenTree // force open_tree() forceGetProcRootOpenTreeAtRecursive // force open_tree(AT_RECURSIVE) forceGetProcRootUnsafe // force open() ) var testingForceGetProcRoot *forceGetProcRootLevel func testingCheckClose(check bool, f *os.File) bool { if check { if f != nil { _ = f.Close() } return true } return false } func testingForcePrivateProcRootOpenTree(f *os.File) bool { return testing.Testing() && testingForceGetProcRoot != nil && testingCheckClose(*testingForceGetProcRoot >= forceGetProcRootOpenTree, f) } func testingForcePrivateProcRootOpenTreeAtRecursive(f *os.File) bool { return testing.Testing() && testingForceGetProcRoot != nil && testingCheckClose(*testingForceGetProcRoot >= forceGetProcRootOpenTreeAtRecursive, f) } func testingForceGetProcRootUnsafe() bool { return testing.Testing() && testingForceGetProcRoot != nil && *testingForceGetProcRoot >= forceGetProcRootUnsafe } type forceProcThreadSelfLevel int const ( forceProcThreadSelfDefault forceProcThreadSelfLevel = iota forceProcSelfTask forceProcSelf ) var testingForceProcThreadSelf *forceProcThreadSelfLevel func testingForceProcSelfTask() bool { return testing.Testing() && testingForceProcThreadSelf != nil && *testingForceProcThreadSelf >= forceProcSelfTask } func testingForceProcSelf() bool { return testing.Testing() && testingForceProcThreadSelf != nil && *testingForceProcThreadSelf >= forceProcSelf } func init() { hookForceGetProcRootUnsafe = testingForceGetProcRootUnsafe hookForcePrivateProcRootOpenTree = testingForcePrivateProcRootOpenTree hookForcePrivateProcRootOpenTreeAtRecursive = testingForcePrivateProcRootOpenTreeAtRecursive hookForceProcSelf = testingForceProcSelf hookForceProcSelfTask = testingForceProcSelfTask } filepath-securejoin-0.3.4/util_linux_test.go000066400000000000000000000142621470137774400212630ustar00rootroot00000000000000//go:build linux // Copyright (C) 2024 SUSE LLC. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package securejoin import ( "errors" "fmt" "os" "path/filepath" "runtime" "strconv" "strings" "testing" "github.com/stretchr/testify/require" "golang.org/x/sys/unix" ) func requireRoot(t *testing.T) { if os.Geteuid() != 0 { t.Skip("test requires root") } } func withWithoutOpenat2(t *testing.T, doAuto bool, testFn func(t *testing.T)) { if doAuto { t.Run("openat2=auto", testFn) } for _, useOpenat2 := range []bool{true, false} { useOpenat2 := useOpenat2 // copy iterator t.Run(fmt.Sprintf("openat2=%v", useOpenat2), func(t *testing.T) { if useOpenat2 && !hasOpenat2() { t.Skip("no openat2 support") } origHasOpenat2 := hasOpenat2 hasOpenat2 = func() bool { return useOpenat2 } defer func() { hasOpenat2 = origHasOpenat2 }() testFn(t) }) } } func testForceGetProcRoot(t *testing.T, testFn func(t *testing.T, expectOvermounts bool)) { for _, test := range []struct { name string forceGetProcRoot forceGetProcRootLevel expectOvermounts bool }{ {`procfd="fsopen()"`, forceGetProcRootDefault, false}, {`procfd="open_tree_clone"`, forceGetProcRootOpenTree, false}, {`procfd="open_tree_clone(AT_RECURSIVE)"`, forceGetProcRootOpenTreeAtRecursive, true}, {`procfd="open()"`, forceGetProcRootUnsafe, true}, } { test := test // copy iterator t.Run(test.name, func(t *testing.T) { testingForceGetProcRoot = &test.forceGetProcRoot defer func() { testingForceGetProcRoot = nil }() testFn(t, test.expectOvermounts) }) } } func testForceProcThreadSelf(t *testing.T, testFn func(t *testing.T)) { for _, test := range []struct { name string forceProcThreadSelf forceProcThreadSelfLevel }{ {`thread-self="thread-self"`, forceProcThreadSelfDefault}, {`thread-self="self/task"`, forceProcSelfTask}, {`thread-self="self"`, forceProcSelf}, } { test := test // copy iterator t.Run(test.name, func(t *testing.T) { testingForceProcThreadSelf = &test.forceProcThreadSelf defer func() { testingForceProcThreadSelf = nil }() testFn(t) }) } } func hasRenameExchange() bool { err := unix.Renameat2(unix.AT_FDCWD, ".", unix.AT_FDCWD, ".", unix.RENAME_EXCHANGE) return !errors.Is(err, unix.ENOSYS) } func doRenameExchangeLoop(pauseCh chan struct{}, exitCh <-chan struct{}, dir *os.File, pathA, pathB string) { for { select { case <-exitCh: return case <-pauseCh: // Wait for caller to unpause us. select { case pauseCh <- struct{}{}: case <-exitCh: return } default: // Do the swap twice so that we only pause when we are in a // "correct" state. for i := 0; i < 2; i++ { err := unix.Renameat2(int(dir.Fd()), pathA, int(dir.Fd()), pathB, unix.RENAME_EXCHANGE) if err != nil && int(dir.Fd()) != -1 && !errors.Is(err, unix.EBADF) { // Should never happen, and if it does we will potentially // enter a bad filesystem state if we get paused. panic(fmt.Sprintf("renameat2([%d]%q, %q, ..., %q, RENAME_EXCHANGE) = %v", int(dir.Fd()), dir.Name(), pathA, pathB, err)) } } } // Make sure GC doesn't close the directory handle. runtime.KeepAlive(dir) } } // Format: // // dir // file // symlink // char // block // fifo // sock func createInTree(t *testing.T, root, spec string) { f := strings.Fields(spec) if len(f) < 2 { t.Fatalf("invalid spec %q", spec) } inoType, subPath, f := f[0], f[1], f[2:] fullPath := filepath.Join(root, subPath) var setOwnerMode *string switch inoType { case "dir": if len(f) >= 1 { setOwnerMode = &f[0] } err := os.MkdirAll(fullPath, 0o755) require.NoError(t, err) case "file": var contents []byte if len(f) >= 1 { contents = []byte(f[0]) } if len(f) >= 2 { setOwnerMode = &f[1] } err := os.WriteFile(fullPath, contents, 0o644) require.NoError(t, err) case "symlink": if len(f) < 1 { t.Fatalf("invalid spec %q", spec) } target := f[0] err := os.Symlink(target, fullPath) require.NoError(t, err) case "char", "block": if len(f) < 2 { t.Fatalf("invalid spec %q", spec) } if len(f) >= 3 { setOwnerMode = &f[2] } major, err := strconv.Atoi(f[0]) require.NoErrorf(t, err, "mknod %s: parse major", subPath) minor, err := strconv.Atoi(f[1]) require.NoErrorf(t, err, "mknod %s: parse minor", subPath) dev := unix.Mkdev(uint32(major), uint32(minor)) var mode uint32 = 0o644 switch inoType { case "char": mode |= unix.S_IFCHR case "block": mode |= unix.S_IFBLK } err = unix.Mknod(fullPath, mode, int(dev)) require.NoErrorf(t, err, "mknod (%s %d:%d) %s", inoType, major, minor, fullPath) case "fifo", "sock": if len(f) >= 1 { setOwnerMode = &f[0] } var mode uint32 = 0o644 switch inoType { case "fifo": mode |= unix.S_IFIFO case "sock": mode |= unix.S_IFSOCK } err := unix.Mknod(fullPath, mode, 0) require.NoErrorf(t, err, "mk%s %s", inoType, fullPath) } if setOwnerMode != nil { // :: fields := strings.Split(*setOwnerMode, ":") require.Lenf(t, fields, 3, "set owner-mode format uid:gid:mode") uidStr, gidStr, modeStr := fields[0], fields[1], fields[2] if uidStr != "" && gidStr != "" { uid, err := strconv.Atoi(uidStr) require.NoErrorf(t, err, "chown %s: parse uid", fullPath) gid, err := strconv.Atoi(gidStr) require.NoErrorf(t, err, "chown %s: parse gid", fullPath) err = unix.Chown(fullPath, uid, gid) require.NoErrorf(t, err, "chown %s", fullPath) } if modeStr != "" { mode, err := strconv.ParseUint(modeStr, 8, 32) require.NoErrorf(t, err, "chmod %s: parse mode", fullPath) err = unix.Chmod(fullPath, uint32(mode)) require.NoErrorf(t, err, "chmod %s", fullPath) } } } func createTree(t *testing.T, specs ...string) string { root := t.TempDir() // Put the root in a subdir. treeRoot := filepath.Join(root, "tree") os.MkdirAll(treeRoot, 0o755) for _, spec := range specs { createInTree(t, treeRoot, spec) } return treeRoot } filepath-securejoin-0.3.4/vfs.go000066400000000000000000000025471470137774400166310ustar00rootroot00000000000000// Copyright (C) 2017-2024 SUSE LLC. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package securejoin import "os" // In future this should be moved into a separate package, because now there // are several projects (umoci and go-mtree) that are using this sort of // interface. // VFS is the minimal interface necessary to use [SecureJoinVFS]. A nil VFS is // equivalent to using the standard [os].* family of functions. This is mainly // used for the purposes of mock testing, but also can be used to otherwise use // [SecureJoinVFS] with VFS-like system. type VFS interface { // Lstat returns an [os.FileInfo] describing the named file. If the // file is a symbolic link, the returned [os.FileInfo] describes the // symbolic link. Lstat makes no attempt to follow the link. // The semantics are identical to [os.Lstat]. Lstat(name string) (os.FileInfo, error) // Readlink returns the destination of the named symbolic link. // The semantics are identical to [os.Readlink]. Readlink(name string) (string, error) } // osVFS is the "nil" VFS, in that it just passes everything through to the os // module. type osVFS struct{} func (o osVFS) Lstat(name string) (os.FileInfo, error) { return os.Lstat(name) } func (o osVFS) Readlink(name string) (string, error) { return os.Readlink(name) }