diff --git a/go.mod b/go.mod index 84bfc3a..98e0dff 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/snyk/studio-mcp -go 1.25.7 +go 1.25.11 tool ( github.com/golang/mock/mockgen @@ -9,7 +9,7 @@ tool ( require ( github.com/adrg/xdg v0.5.3 - github.com/go-git/go-git/v5 v5.14.0 + github.com/go-git/go-git/v5 v5.19.1 github.com/golang/mock v1.6.0 github.com/google/uuid v1.6.0 github.com/mark3labs/mcp-go v0.31.0 @@ -22,7 +22,7 @@ require ( github.com/snyk/go-application-framework v0.0.0-20260202103514-24f6db41a35d github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 - golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f golang.org/x/oauth2 v0.33.0 ) @@ -40,18 +40,18 @@ require ( github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect - github.com/cloudflare/circl v1.6.0 // indirect - github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dprotaso/go-yit v0.0.0-20240618133044-5a0af90af097 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/getkin/kin-openapi v0.133.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/go-git/go-billy/v5 v5.9.0 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/swag v0.23.1 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gofrs/flock v0.12.1 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect @@ -64,6 +64,7 @@ require ( github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/manifoldco/promptui v0.9.0 // indirect @@ -78,7 +79,7 @@ require ( github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect - github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/puzpuzpuz/xsync v1.5.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect @@ -104,7 +105,7 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect - golang.org/x/crypto v0.51.0 // indirect + golang.org/x/crypto v0.52.0 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sync v0.20.0 // indirect diff --git a/go.sum b/go.sum index 0b59a35..16ed5c2 100644 --- a/go.sum +++ b/go.sum @@ -37,11 +37,11 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5O github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= -github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= -github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -71,12 +71,12 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= -github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= +github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60= -github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k= +github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= +github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= @@ -84,8 +84,8 @@ github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDq github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= @@ -145,6 +145,8 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -207,8 +209,8 @@ github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNH github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= -github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= -github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -313,10 +315,10 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= diff --git a/licenses/github.com/cyphar/filepath-securejoin/.github/dependabot.yml b/licenses/github.com/cyphar/filepath-securejoin/.github/dependabot.yml new file mode 100644 index 0000000..15ffaff --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/.github/dependabot.yml @@ -0,0 +1,16 @@ +# 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" diff --git a/licenses/github.com/cyphar/filepath-securejoin/.github/workflows/ci.yml b/licenses/github.com/cyphar/filepath-securejoin/.github/workflows/ci.yml new file mode 100644 index 0000000..bd1fbd9 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/.github/workflows/ci.yml @@ -0,0 +1,365 @@ +# SPDX-License-Identifier: MPL-2.0 + +# Copyright (C) 2023-2025 Aleksa Sarai +# Copyright (C) 2023-2025 SUSE LLC +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +name: ci + +on: + push: + tags: + - "v*" + branches: + - main + - "v*" + pull_request: + schedule: + - cron: "30 10 * * 0" + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + # We need libpathrs so that golangci-lint can typecheck + # "cyphar.com/go-pathrs" (the package needs to be buildable). + - uses: dtolnay/rust-toolchain@stable + - name: find latest libpathrs release + uses: actions/github-script@v8 + id: libpathrs-release-tarball + with: + result-encoding: string + script: |- + const latest_release = await github.rest.repos.getLatestRelease({ + owner: "cyphar", + repo: "libpathrs", + }); + console.log(latest_release); + return latest_release.data.tarball_url; + - name: install libpathrs + run: |- + mkdir -p /tmp/libpathrs + cd /tmp/libpathrs + + wget -O latest.tar.gz "${{ steps.libpathrs-release-tarball.outputs.result }}" + tar xvf latest.tar.gz + + cd *libpathrs-*/ + make release + sudo ./install.sh --libdir=/usr/lib + + - uses: actions/setup-go@v6 + with: + go-version: stable + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.6 + + build: + strategy: + fail-fast: false + matrix: + go-version: + - "1.18" + - "1.19" + - "1.20" + - "1.21" + - "1.22" + - "1.23" + # TODO: add 1.24 here once Go 1.26 is out. + - oldstable + - stable + go-arch: + - amd64 + os: + - windows-latest + - ubuntu-latest + - macos-latest + include: + - go-version: stable + go-arch: "386" + os: ubuntu-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go-version }} + check-latest: true + - name: set GOARCH + run: echo "GOARCH=${{ matrix.go-arch }}" >>"$GITHUB_ENV" + - name: go build check + run: go build ./... + - name: go test build check + run: go test -run none ./... + + windows: + strategy: + fail-fast: false + matrix: + go-version: + - "1.18" + - "1.20" + - "1.21" + - "oldstable" + - "stable" + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go-version }} + check-latest: true + - name: mkdir gocoverdir + # We can only use -test.gocoverdir for Go >= 1.20. + if: ${{ matrix.go-version != '1.18' && matrix.go-version != '1.19' }} + 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: | + if (Test-Path 'env:GOCOVERDIR') { + go test -v -cover -coverpkg=./... ./... -args '-test.gocoverdir' "$env:GOCOVERDIR" + } else { + go test -v -cover -coverpkg=./... -coverprofile codecov-coverage.txt ./... + } + - name: upload coverage artefact + # We can only use -test.gocoverdir for Go >= 1.20. + if: ${{ matrix.go-version != '1.18' && matrix.go-version != '1.19' }} + uses: actions/upload-artifact@v5 + with: + name: coverage-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }} + path: ${{ env.GOCOVERDIR }} + - name: collate coverage data for codecov + if: ${{ env.GOCOVERDIR != '' }} + run: go tool covdata textfmt -i "$env:GOCOVERDIR" -o "codecov-coverage.txt" + - name: upload coverage to codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: cyphar/filepath-securejoin + + unix: + strategy: + fail-fast: false + matrix: + go-version: + - "1.18" + - "1.20" + - "1.21" + - "oldstable" + - "stable" + os: + - ubuntu-latest + - macos-latest + include: + # Make sure we test with a slightly older kernel (sadly we can't use + # really old images like Ubuntu 18.04). Ubuntu 22.04 uses Linux 6.8. + - go-version: "oldstable" + os: ubuntu-22.04 + - go-version: "stable" + os: ubuntu-22.04 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go-version }} + check-latest: true + - name: mkdir gocoverdir + # We can only use -test.gocoverdir for Go >= 1.20. + if: ${{ matrix.go-version != '1.18' && matrix.go-version != '1.19' }} + run: | + GOCOVERDIR="$(mktemp --tmpdir -d gocoverdir.XXXXXXXX)" + echo "GOCOVERDIR=$GOCOVERDIR" >>"$GITHUB_ENV" + - name: go test + run: |- + if [ -n "${GOCOVERDIR:-}" ]; then + go test -v -timeout=30m -cover -coverpkg=./... ./... -args -test.gocoverdir="$GOCOVERDIR" + else + go test -v -timeout=30m -cover -coverpkg=./... -coverprofile codecov-coverage.txt ./... + fi + - name: sudo go test + run: |- + if [ -n "${GOCOVERDIR:-}" ]; then + sudo go test -v -timeout=30m -cover -coverpkg=./... ./... -args -test.gocoverdir="$GOCOVERDIR" + else + sudo go test -v -timeout=30m -cover -coverpkg=./... -coverprofile codecov-coverage-sudo.txt ./... + fi + - name: upload coverage artefact + # We can only use -test.gocoverdir for Go >= 1.20. + if: ${{ matrix.go-version != '1.18' && matrix.go-version != '1.19' }} + uses: actions/upload-artifact@v5 + with: + name: coverage-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }} + path: ${{ env.GOCOVERDIR }} + - name: collate coverage data + if: ${{ env.GOCOVERDIR != '' }} + run: go tool covdata textfmt -i "$GOCOVERDIR" -o "codecov-coverage.txt" + - name: upload coverage to codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: cyphar/filepath-securejoin + + libpathrs: + strategy: + fail-fast: false + matrix: + go-version: + - "1.18" + - "oldstable" + - "stable" + os: + - ubuntu-22.04 + - ubuntu-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v5 + + - uses: dtolnay/rust-toolchain@stable + - name: find latest libpathrs release + uses: actions/github-script@v8 + id: libpathrs-release-tarball + with: + result-encoding: string + script: |- + const latest_release = await github.rest.repos.getLatestRelease({ + owner: "cyphar", + repo: "libpathrs", + }); + console.log(latest_release); + return latest_release.data.tarball_url; + - name: install libpathrs + run: |- + mkdir -p /tmp/libpathrs + cd /tmp/libpathrs + + wget -O latest.tar.gz "${{ steps.libpathrs-release-tarball.outputs.result }}" + tar xvf latest.tar.gz + + cd *libpathrs-*/ + make release + sudo ./install.sh --libdir=/usr/lib + + - uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go-version }} + check-latest: true + - name: mkdir gocoverdir + # We can only use -test.gocoverdir for Go >= 1.20. + if: ${{ matrix.go-version != '1.18' && matrix.go-version != '1.19' }} + run: | + GOCOVERDIR="$(mktemp --tmpdir -d gocoverdir.XXXXXXXX)" + echo "GOCOVERDIR=$GOCOVERDIR" >>"$GITHUB_ENV" + - name: go test + run: |- + pkgs=("./pathrs-lite" "./pathrs-lite/procfs") + if [ -n "${GOCOVERDIR:-}" ]; then + go test -v -timeout=30m -cover -coverpkg=./... "${pkgs[@]}" -args -test.gocoverdir="$GOCOVERDIR" + else + go test -v -timeout=30m -cover -coverpkg=./... -coverprofile codecov-coverage.txt "${pkgs[@]}" + fi + - name: sudo go test + run: |- + pkgs=("./pathrs-lite" "./pathrs-lite/procfs") + if [ -n "${GOCOVERDIR:-}" ]; then + sudo go test -v -timeout=30m -cover -coverpkg=./... "${pkgs[@]}" -args -test.gocoverdir="$GOCOVERDIR" + else + sudo go test -v -timeout=30m -cover -coverpkg=./... -coverprofile codecov-coverage-sudo.txt "${pkgs[@]}" + fi + - name: upload coverage artefact + # We can only use -test.gocoverdir for Go >= 1.20. + if: ${{ matrix.go-version != '1.18' && matrix.go-version != '1.19' }} + uses: actions/upload-artifact@v5 + with: + name: coverage-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }} + path: ${{ env.GOCOVERDIR }} + - name: collate coverage data + if: ${{ env.GOCOVERDIR != '' }} + run: go tool covdata textfmt -i "$GOCOVERDIR" -o "codecov-coverage.txt" + - name: upload coverage to codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: cyphar/filepath-securejoin + + coverage: + runs-on: ubuntu-latest + needs: + - windows + - unix + - libpathrs + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version: "stable" + check-latest: true + - name: download all coverage + uses: actions/download-artifact@v6 + 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@v5 + with: + name: fullcoverage-${{ github.job }} + path: ${{ env.FULLCOVERAGE_FILE }} + - name: upload coverage html + uses: actions/upload-artifact@v5 + with: + name: fullcoverage-${{ github.job }}.html + path: ${{ env.FULLCOVERAGE_FILE }}.html + + codespell: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - run: pip install codespell==v2.4.1 + - run: codespell + + complete: + runs-on: ubuntu-latest + needs: + - lint + - build + - windows + - unix + - libpathrs + - coverage + - codespell + steps: + - run: echo "all done" diff --git a/licenses/github.com/cyphar/filepath-securejoin/.golangci.yml b/licenses/github.com/cyphar/filepath-securejoin/.golangci.yml new file mode 100644 index 0000000..3e8dd99 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/.golangci.yml @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: MPL-2.0 + +# Copyright (C) 2025 Aleksa Sarai +# Copyright (C) 2025 SUSE LLC +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +version: "2" + +run: + build-tags: + - libpathrs + +linters: + enable: + - asasalint + - asciicheck + - containedctx + - contextcheck + - errcheck + - errorlint + - exhaustive + - forcetypeassert + - godot + - goprintffuncname + - govet + - importas + - ineffassign + - makezero + - misspell + - musttag + - nilerr + - nilnesserr + - nilnil + - noctx + - prealloc + - revive + - staticcheck + - testifylint + - unconvert + - unparam + - unused + - usetesting + settings: + govet: + enable: + - nilness + testifylint: + enable-all: true + +formatters: + enable: + - gofumpt + - goimports + settings: + goimports: + local-prefixes: + - github.com/cyphar/filepath-securejoin diff --git a/licenses/github.com/cyphar/filepath-securejoin/CHANGELOG.md b/licenses/github.com/cyphar/filepath-securejoin/CHANGELOG.md new file mode 100644 index 0000000..6d016d0 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/CHANGELOG.md @@ -0,0 +1,453 @@ +# 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.6.1] - 2025-11-19 ## + +> At last up jumped the cunning spider, and fiercely held her fast. + +### Fixed ### +- Our logic for deciding whether to use `openat2(2)` or fallback to an `O_PATH` + resolver would cache the result to avoid doing needless test runs of + `openat2(2)`. However, this causes issues when `pathrs-lite` is being used by + a program that applies new seccomp-bpf filters onto itself -- if the filter + denies `openat2(2)` then we would return that error rather than falling back + to the `O_PATH` resolver. To resolve this issue, we no longer cache the + result if `openat2(2)` was successful, only if there was an error. +- A file descriptor leak in our `openat2` wrapper (when doing the necessary + `dup` for `RESOLVE_IN_ROOT`) has been removed. + +## [0.5.2] - 2025-11-19 ## + +> "Will you walk into my parlour?" said a spider to a fly. + +### Fixed ### +- Our logic for deciding whether to use `openat2(2)` or fallback to an `O_PATH` + resolver would cache the result to avoid doing needless test runs of + `openat2(2)`. However, this causes issues when `pathrs-lite` is being used by + a program that applies new seccomp-bpf filters onto itself -- if the filter + denies `openat2(2)` then we would return that error rather than falling back + to the `O_PATH` resolver. To resolve this issue, we no longer cache the + result if `openat2(2)` was successful, only if there was an error. +- A file descriptor leak in our `openat2` wrapper (when doing the necessary + `dup` for `RESOLVE_IN_ROOT`) has been removed. + +## [0.6.0] - 2025-11-03 ## + +> By the Power of Greyskull! + +### Breaking ### +- The deprecated `MkdirAll`, `MkdirAllHandle`, `OpenInRoot`, `OpenatInRoot` and + `Reopen` wrappers have been removed. Please switch to using `pathrs-lite` + directly. + +### Added ### +- `pathrs-lite` now has support for using libpathrs as a backend. This is + opt-in and can be enabled at build time with the `libpathrs` build tag. The + intention is to allow for downstream libraries and other projects to make use + of the pure-Go `github.com/cyphar/filepath-securejoin/pathrs-lite` package + and distributors can then opt-in to using `libpathrs` for the entire binary + if they wish. + +## [0.5.1] - 2025-10-31 ## + +> Spooky scary skeletons send shivers down your spine! + +### Changed ### +- `openat2` can return `-EAGAIN` if it detects a possible attack in certain + scenarios (namely if there was a rename or mount while walking a path with a + `..` component). While this is necessary to avoid a denial-of-service in the + kernel, it does require retry loops in userspace. + + In previous versions, `pathrs-lite` would retry `openat2` 32 times before + returning an error, but we've received user reports that this limit can be + hit on systems with very heavy load. In some synthetic benchmarks (testing + the worst-case of an attacker doing renames in a tight loop on every core of + a 16-core machine) we managed to get a ~3% failure rate in runc. We have + improved this situation in two ways: + + * We have now increased this limit to 128, which should be good enough for + most use-cases without becoming a denial-of-service vector (the number of + syscalls called by the `O_PATH` resolver in a typical case is within the + same ballpark). The same benchmarks show a failure rate of ~0.12% which + (while not zero) is probably sufficient for most users. + + * In addition, we now return a `unix.EAGAIN` error that is bubbled up and can + be detected by callers. This means that callers with stricter requirements + to avoid spurious errors can choose to do their own infinite `EAGAIN` retry + loop (though we would strongly recommend users use time-based deadlines in + such retry loops to avoid potentially unbounded denials-of-service). + +## [0.5.0] - 2025-09-26 ## + +> Let the past die. Kill it if you have to. + +> **NOTE**: With this release, some parts of +> `github.com/cyphar/filepath-securejoin` are now licensed under the Mozilla +> Public License (version 2). Please see [COPYING.md][] as well as the the +> license header in each file for more details. + +[COPYING.md]: ./COPYING.md + +### Breaking ### +- The new API introduced in the [0.3.0][] release has been moved to a new + subpackage called `pathrs-lite`. This was primarily done to better indicate + the split between the new and old APIs, as well as indicate to users the + purpose of this subpackage (it is a less complete version of [libpathrs][]). + + We have added some wrappers to the top-level package to ease the transition, + but those are deprecated and will be removed in the next minor release of + filepath-securejoin. Users should update their import paths. + + This new subpackage has also been relicensed under the Mozilla Public License + (version 2), please see [COPYING.md][] for more details. + +### Added ### +- Most of the key bits the safe `procfs` API have now been exported and are + available in `github.com/cyphar/filepath-securejoin/pathrs-lite/procfs`. At + the moment this primarily consists of a new `procfs.Handle` API: + + * `OpenProcRoot` returns a new handle to `/proc`, endeavouring to make it + safe if possible (`subset=pid` to protect against mistaken write attacks + and leaks, as well as using `fsopen(2)` to avoid racing mount attacks). + + `OpenUnsafeProcRoot` returns a handle without attempting to create one + with `subset=pid`, which makes it more dangerous to leak. Most users + should use `OpenProcRoot` (even if you need to use `ProcRoot` as the base + of an operation, as filepath-securejoin will internally open a handle when + necessary). + + * The `(*procfs.Handle).Open*` family of methods lets you get a safe + `O_PATH` handle to subpaths within `/proc` for certain subpaths. + + For `OpenThreadSelf`, the returned `ProcThreadSelfCloser` needs to be + called after you completely finish using the handle (this is necessary + because Go is multi-threaded and `ProcThreadSelf` references + `/proc/thread-self` which may disappear if we do not + `runtime.LockOSThread` -- `ProcThreadSelfCloser` is currently equivalent + to `runtime.UnlockOSThread`). + + Note that you cannot open any `procfs` symlinks (most notably magic-links) + using this API. At the moment, filepath-securejoin does not support this + feature (but [libpathrs][] does). + + * `ProcSelfFdReadlink` lets you get the in-kernel path representation of a + file descriptor (think `readlink("/proc/self/fd/...")`), except that we + verify that there aren't any tricky overmounts that could fool the + process. + + Please be aware that the returned string is simply a snapshot at that + particular moment, and an attacker could move the file being pointed to. + In addition, complex namespace configurations could result in non-sensical + or confusing paths to be returned. The value received from this function + should only be used as secondary verification of some security property, + not as proof that a particular handle has a particular path. + + The procfs handle used internally by the API is the same as the rest of + `filepath-securejoin` (for privileged programs this is usually a private + in-process `procfs` instance created with `fsopen(2)`). + + As before, this is intended as a stop-gap before users migrate to + [libpathrs][], which provides a far more extensive safe `procfs` API and is + generally more robust. + +- Previously, the hardened procfs implementation (used internally within + `Reopen` and `Open(at)InRoot`) only protected against overmount attacks on + systems with `openat2(2)` (Linux 5.6) or systems with `fsopen(2)` or + `open_tree(2)` (Linux 5.2) and programs with privileges to use them (with + some caveats about locked mounts that probably affect very few users). For + other users, an attacker with the ability to create malicious mounts (on most + systems, a sysadmin) could trick you into operating on files you didn't + expect. This attack only really makes sense in the context of container + runtime implementations. + + This was considered a reasonable trade-off, as the long-term intention was to + get all users to just switch to [libpathrs][] if they wanted to use the safe + `procfs` API (which had more extensive protections, and is what these new + protections in `filepath-securejoin` are based on). However, as the API + is now being exported it seems unwise to advertise the API as "safe" if we do + not protect against known attacks. + + The procfs API is now more protected against attackers on systems lacking the + aforementioned protections. However, the most comprehensive of these + protections effectively rely on [`statx(STATX_MNT_ID)`][statx.2] (Linux 5.8). + On older kernel versions, there is no effective protection (there is some + minimal protection against non-`procfs` filesystem components but a + sufficiently clever attacker can work around those). In addition, + `STATX_MNT_ID` is vulnerable to mount ID reuse attacks by sufficiently + motivated and privileged attackers -- this problem is mitigated with + `STATX_MNT_ID_UNIQUE` (Linux 6.8) but that raises the minimum kernel version + for more protection. + + The fact that these protections are quite limited despite needing a fair bit + of extra code to handle was one of the primary reasons we did not initially + implement this in `filepath-securejoin` ([libpathrs][] supports all of this, + of course). + +### Fixed ### +- RHEL 8 kernels have backports of `fsopen(2)` but in some testing we've found + that it has very bad (and very difficult to debug) performance issues, and so + we will explicitly refuse to use `fsopen(2)` if the running kernel version is + pre-5.2 and will instead fallback to `open("/proc")`. + +[CVE-2024-21626]: https://github.com/opencontainers/runc/security/advisories/GHSA-xr7r-f8xq-vfvv +[libpathrs]: https://github.com/cyphar/libpathrs +[statx.2]: https://www.man7.org/linux/man-pages/man2/statx.2.html + +## [0.4.1] - 2025-01-28 ## + +### Fixed ### +- The restrictions added for `root` paths passed to `SecureJoin` in 0.4.0 was + found to be too strict and caused some regressions when folks tried to + update, so this restriction has been relaxed to only return an error if the + path contains a `..` component. We still recommend users use `filepath.Clean` + (and even `filepath.EvalSymlinks`) on the `root` path they are using, but at + least you will no longer be punished for "trivial" unclean paths. + +## [0.4.0] - 2025-01-13 ## + +### Breaking #### +- `SecureJoin(VFS)` will now return an error if the provided `root` is not a + `filepath.Clean`'d path. + + While it is ultimately the responsibility of the caller to ensure the root is + a safe path to use, passing a path like `/symlink/..` as a root would result + in the `SecureJoin`'d path being placed in `/` even though `/symlink/..` + might be a different directory, and so we should more strongly discourage + such usage. + + All major users of `securejoin.SecureJoin` already ensure that the paths they + provide are safe (and this is ultimately a question of user error), but + removing this foot-gun is probably a good idea. Of course, this is + necessarily a breaking API change (though we expect no real users to be + affected by it). + + Thanks to [Erik Sjölund](https://github.com/eriksjolund), who initially + reported this issue as a possible security issue. + +- `MkdirAll` and `MkdirHandle` now take an `os.FileMode`-style mode argument + instead of a raw `unix.S_*`-style mode argument, which may cause compile-time + type errors depending on how you use `filepath-securejoin`. For most users, + there will be no change in behaviour aside from the type change (as the + bottom `0o777` bits are the same in both formats, and most users are probably + only using those bits). + + However, if you were using `unix.S_ISVTX` to set the sticky bit with + `MkdirAll(Handle)` you will need to switch to `os.ModeSticky` otherwise you + will get a runtime error with this update. In addition, the error message you + will get from passing `unix.S_ISUID` and `unix.S_ISGID` will be different as + they are treated as invalid bits now (note that previously passing said bits + was also an error). + +## [0.3.6] - 2024-12-17 ## + +### Compatibility ### +- The minimum Go version requirement for `filepath-securejoin` is now Go 1.18 + (we use generics internally). + + For reference, `filepath-securejoin@v0.3.0` somewhat-arbitrarily bumped the + Go version requirement to 1.21. + + While we did make some use of Go 1.21 stdlib features (and in principle Go + versions <= 1.21 are no longer even supported by upstream anymore), some + downstreams have complained that the version bump has meant that they have to + do workarounds when backporting fixes that use the new `filepath-securejoin` + API onto old branches. This is not an ideal situation, but since using this + library is probably better for most downstreams than a hand-rolled + workaround, we now have compatibility shims that allow us to build on older + Go versions. +- Lower minimum version requirement for `golang.org/x/sys` to `v0.18.0` (we + need the wrappers for `fsconfig(2)`), which should also make backporting + patches to older branches easier. + +## [0.3.5] - 2024-12-06 ## + +### Fixed ### +- `MkdirAll` will now no longer return an `EEXIST` error if two racing + processes are creating the same directory. We will still verify that the path + is a directory, but this will avoid spurious errors when multiple threads or + programs are trying to `MkdirAll` the same path. opencontainers/runc#4543 + +## [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/cyphar/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.6.1...HEAD +[0.6.1]: https://github.com/cyphar/filepath-securejoin/compare/v0.6.0...v0.6.1 +[0.6.0]: https://github.com/cyphar/filepath-securejoin/compare/v0.5.0...v0.6.0 +[0.5.2]: https://github.com/cyphar/filepath-securejoin/compare/v0.5.1...v0.5.2 +[0.5.1]: https://github.com/cyphar/filepath-securejoin/compare/v0.5.0...v0.5.1 +[0.5.0]: https://github.com/cyphar/filepath-securejoin/compare/v0.4.1...v0.5.0 +[0.4.1]: https://github.com/cyphar/filepath-securejoin/compare/v0.4.0...v0.4.1 +[0.4.0]: https://github.com/cyphar/filepath-securejoin/compare/v0.3.6...v0.4.0 +[0.3.6]: https://github.com/cyphar/filepath-securejoin/compare/v0.3.5...v0.3.6 +[0.3.5]: https://github.com/cyphar/filepath-securejoin/compare/v0.3.4...v0.3.5 +[0.3.4]: 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 diff --git a/licenses/github.com/cyphar/filepath-securejoin/COPYING.md b/licenses/github.com/cyphar/filepath-securejoin/COPYING.md new file mode 100644 index 0000000..520e822 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/COPYING.md @@ -0,0 +1,447 @@ +## COPYING ## + +`SPDX-License-Identifier: BSD-3-Clause AND MPL-2.0` + +This project is made up of code licensed under different licenses. Which code +you use will have an impact on whether only one or both licenses apply to your +usage of this library. + +Note that **each file** in this project individually has a code comment at the +start describing the license of that particular file -- this is the most +accurate license information of this project; in case there is any conflict +between this document and the comment at the start of a file, the comment shall +take precedence. The only purpose of this document is to work around [a known +technical limitation of pkg.go.dev's license checking tool when dealing with +non-trivial project licenses][go75067]. + +[go75067]: https://go.dev/issue/75067 + +### `BSD-3-Clause` ### + +At time of writing, the following files and directories are licensed under the +BSD-3-Clause license: + + * `doc.go` + * `join*.go` + * `vfs.go` + * `internal/consts/*.go` + * `pathrs-lite/internal/gocompat/*.go` + * `pathrs-lite/internal/kernelversion/*.go` + +The text of the BSD-3-Clause license used by this project is the following (the +text is also available from the [`LICENSE.BSD`](./LICENSE.BSD) file): + +``` +Copyright (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. +``` + +### `MPL-2.0` ### + +All other files (unless otherwise marked) are licensed under the Mozilla Public +License (version 2.0). + +The text of the Mozilla Public License (version 2.0) is the following (the text +is also available from the [`LICENSE.MPL-2.0`](./LICENSE.MPL-2.0) file): + +``` +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. +``` diff --git a/licenses/github.com/cyphar/filepath-securejoin/LICENSE b/licenses/github.com/cyphar/filepath-securejoin/LICENSE.BSD similarity index 100% rename from licenses/github.com/cyphar/filepath-securejoin/LICENSE rename to licenses/github.com/cyphar/filepath-securejoin/LICENSE.BSD diff --git a/licenses/github.com/cyphar/filepath-securejoin/LICENSE.MPL-2.0 b/licenses/github.com/cyphar/filepath-securejoin/LICENSE.MPL-2.0 new file mode 100644 index 0000000..d0a1fa1 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/LICENSE.MPL-2.0 @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/licenses/github.com/cyphar/filepath-securejoin/README.md b/licenses/github.com/cyphar/filepath-securejoin/README.md new file mode 100644 index 0000000..6673abf --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/README.md @@ -0,0 +1,184 @@ +## `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 ### +[#new-api]: #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 ### + +`SPDX-License-Identifier: BSD-3-Clause AND MPL-2.0` + +Some of the code in this project is derived from Go, and is licensed under a +BSD 3-clause license (available in `LICENSE.BSD`). Other files (many of which +are derived from [libpathrs][libpathrs]) are licensed under the Mozilla Public +License version 2.0 (available in `LICENSE.MPL-2.0`). If you are using the +["New API" described above][#new-api], you are probably using code from files +released under this license. + +Every source file in this project has a copyright header describing its +license. Please check the license headers of each file to see what license +applies to it. + +See [COPYING.md](./COPYING.md) for some more details. + +[umoci]: https://github.com/opencontainers/umoci diff --git a/licenses/github.com/cyphar/filepath-securejoin/VERSION b/licenses/github.com/cyphar/filepath-securejoin/VERSION new file mode 100644 index 0000000..ee6cdce --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/VERSION @@ -0,0 +1 @@ +0.6.1 diff --git a/licenses/github.com/cyphar/filepath-securejoin/codecov.yml b/licenses/github.com/cyphar/filepath-securejoin/codecov.yml new file mode 100644 index 0000000..ff284db --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/codecov.yml @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: MPL-2.0 + +# Copyright (C) 2025 Aleksa Sarai +# Copyright (C) 2025 SUSE LLC +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +comment: + layout: "condensed_header, reach, diff, components, condensed_files, condensed_footer" + require_changes: true + branches: + - main + +coverage: + range: 60..100 + status: + project: + default: + target: 85% + threshold: 0% + patch: + default: + target: auto + informational: true + +github_checks: + annotations: false diff --git a/licenses/github.com/cyphar/filepath-securejoin/doc.go b/licenses/github.com/cyphar/filepath-securejoin/doc.go new file mode 100644 index 0000000..1438fc9 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/doc.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: BSD-3-Clause + +// 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 available in the [pathrs-lite] subpackage, and provide +// protections against racing attackers as well as several other key +// protections against attacks often seen by container runtimes. As the name +// suggests, [pathrs-lite] is a stripped down (pure Go) reimplementation of +// [libpathrs]. The main APIs provided are [OpenInRoot], [MkdirAll], and +// [procfs.Handle] -- other APIs are not planned to be ported. The long-term +// goal is for users to migrate to [libpathrs] which is more fully-featured. +// +// 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 (see https://go.dev/issue/20126). Much later, +// [os.Root] was added to the Go stdlib that shares some of the goals of +// filepath-securejoin. However, its design is intended to work like +// openat2(RESOLVE_BENEATH) which does not fit the usecase of container +// runtimes and most system tools. +// +// [pathrs-lite]: https://pkg.go.dev/github.com/cyphar/filepath-securejoin/pathrs-lite +// [libpathrs]: https://github.com/openSUSE/libpathrs +// [OpenInRoot]: https://pkg.go.dev/github.com/cyphar/filepath-securejoin/pathrs-lite#OpenInRoot +// [MkdirAll]: https://pkg.go.dev/github.com/cyphar/filepath-securejoin/pathrs-lite#MkdirAll +// [procfs.Handle]: https://pkg.go.dev/github.com/cyphar/filepath-securejoin/pathrs-lite/procfs#Handle +// [os.Root]: https:///pkg.go.dev/os#Root +package securejoin diff --git a/licenses/github.com/cyphar/filepath-securejoin/go.mod b/licenses/github.com/cyphar/filepath-securejoin/go.mod new file mode 100644 index 0000000..0c04e8d --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/go.mod @@ -0,0 +1,15 @@ +module github.com/cyphar/filepath-securejoin + +go 1.18 + +require ( + cyphar.com/go-pathrs v0.2.1 + github.com/stretchr/testify v1.11.1 + golang.org/x/sys v0.26.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 +) diff --git a/licenses/github.com/cyphar/filepath-securejoin/go.sum b/licenses/github.com/cyphar/filepath-securejoin/go.sum new file mode 100644 index 0000000..c969492 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/go.sum @@ -0,0 +1,14 @@ +cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8= +cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= +github.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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.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= diff --git a/licenses/github.com/cyphar/filepath-securejoin/hack/test.sh b/licenses/github.com/cyphar/filepath-securejoin/hack/test.sh new file mode 100644 index 0000000..ce79ecd --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/hack/test.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# SPDX-License-Identifier: MPL-2.0 + +# Copyright (C) 2024-2025 Aleksa Sarai +# Copyright (C) 2024-2025 SUSE LLC +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +set -Eeuo pipefail + +root="$(readlink -f "$(dirname "${BASH_SOURCE[0]}")/..")" +pushd "$root" + +GO="${GO:-go}" + +silent= +verbose= +long= +libpathrs= +while getopts "svLl" opt; do + case "$opt" in + s) + silent=1 + ;; + v) + verbose=1 + ;; + L) + long=1 + ;; + l) + libpathrs=1 + ;; + *) + echo "$0 [-s(ilent)]" + exit 1 + esac +done + +gocoverdir="$(mktemp --tmpdir -d gocoverdir.XXXXXXXX)" +trap 'rm -rf $gocoverdir' EXIT + +test_args=("-count=1" "-cover" "-coverpkg=./...") +[ -n "$verbose" ] && test_args+=("-v") +[ -z "$long" ] && test_args+=("-short") +[ -n "$libpathrs" ] && test_args+=("-tags" "libpathrs") + +"$GO" test "${test_args[@]}" ./... -args -test.gocoverdir="$gocoverdir" +sudo "$GO" test "${test_args[@]}" ./... -args -test.gocoverdir="$gocoverdir" + +"$GO" tool covdata percent -i "$gocoverdir" +[ -n "$silent" ] || "$GO" tool covdata func -i "$gocoverdir" | sort -k 3gr + +gocoverage="$(mktemp gocoverage.XXXXXXXX)" +trap 'rm $gocoverage' EXIT + +"$GO" tool covdata textfmt -i "$gocoverdir" -o "$gocoverage" +[ -n "$silent" ] || "$GO" tool cover -html="$gocoverage" diff --git a/licenses/github.com/cyphar/filepath-securejoin/internal/consts/consts.go b/licenses/github.com/cyphar/filepath-securejoin/internal/consts/consts.go new file mode 100644 index 0000000..c69c4da --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/internal/consts/consts.go @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: BSD-3-Clause + +// Copyright (C) 2014-2015 Docker Inc & Go Authors. All rights reserved. +// Copyright (C) 2017-2025 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 consts contains the definitions of internal constants used +// throughout filepath-securejoin. +package consts + +// MaxSymlinkLimit is the maximum number of symlinks that can be encountered +// during a single lookup before returning -ELOOP. At time of writing, Linux +// has an internal limit of 40. +const MaxSymlinkLimit = 255 diff --git a/licenses/github.com/cyphar/filepath-securejoin/internal/testutils/iface.go b/licenses/github.com/cyphar/filepath-securejoin/internal/testutils/iface.go new file mode 100644 index 0000000..cb4686d --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/internal/testutils/iface.go @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Package testutils provides some internal helpers for tests. +package testutils + +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestingT is an interface wrapper around *testing.T. +type TestingT interface { + assert.TestingT + require.TestingT + + TempDir() string + Fatalf(format string, args ...any) + Skip(args ...any) +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/internal/testutils/os.go b/licenses/github.com/cyphar/filepath-securejoin/internal/testutils/os.go new file mode 100644 index 0000000..9a3fe42 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/internal/testutils/os.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package testutils + +import ( + "os" + + "github.com/stretchr/testify/require" +) + +// Symlink is a wrapper around os.Symlink. +func Symlink(t TestingT, oldname, newname string) { + err := os.Symlink(oldname, newname) + require.NoError(t, err) +} + +// MkdirAll is a wrapper around os.MkdirAll. +func MkdirAll(t TestingT, path string, mode os.FileMode) { //nolint:unparam // wrapper func + err := os.MkdirAll(path, mode) + require.NoError(t, err) +} + +// WriteFile is a wrapper around os.WriteFile. +func WriteFile(t TestingT, path string, data []byte, mode os.FileMode) { + err := os.WriteFile(path, data, mode) + require.NoError(t, err) +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/join.go b/licenses/github.com/cyphar/filepath-securejoin/join.go new file mode 100644 index 0000000..199c1d8 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/join.go @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: BSD-3-Clause + +// Copyright (C) 2014-2015 Docker Inc & Go Authors. All rights reserved. +// Copyright (C) 2017-2025 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" + + "github.com/cyphar/filepath-securejoin/internal/consts" +) + +// 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) +} + +// errUnsafeRoot is returned if the user provides SecureJoinVFS with a path +// that contains ".." components. +var errUnsafeRoot = errors.New("root path provided to SecureJoin contains '..' components") + +// stripVolume just gets rid of the Windows volume included in a path. Based on +// some godbolt tests, the Go compiler is smart enough to make this a no-op on +// Linux. +func stripVolume(path string) string { + return path[len(filepath.VolumeName(path)):] +} + +// hasDotDot checks if the path contains ".." components in a platform-agnostic +// way. +func hasDotDot(path string) bool { + // If we are on Windows, strip any volume letters. It turns out that + // C:..\foo may (or may not) be a valid pathname and we need to handle that + // leading "..". + path = stripVolume(path) + // Look for "/../" in the path, but we need to handle leading and trailing + // ".."s by adding separators. Doing this with filepath.Separator is ugly + // so just convert to Unix-style "/" first. + path = filepath.ToSlash(path) + return strings.Contains("/"+path+"/", "/../") +} + +// 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" +// +// If the provided root is not [filepath.Clean] then an error will be returned, +// as such root paths are bordering on somewhat unsafe and using such paths is +// not best practice. We also strongly suggest that any root path is first +// fully resolved using [filepath.EvalSymlinks] or otherwise constructed to +// avoid containing symlink components. Of course, the root also *must not* be +// attacker-controlled. +func SecureJoinVFS(root, unsafePath string, vfs VFS) (string, error) { //nolint:revive // name is part of public API + // The root path must not contain ".." components, otherwise when we join + // the subpath we will end up with a weird path. We could work around this + // in other ways but users shouldn't be giving us non-lexical root paths in + // the first place. + if hasDotDot(root) { + return "", errUnsafeRoot + } + + // 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 != "" { + // On Windows, if we managed to end up at a path referencing a volume, + // drop the volume to make sure we don't end up with broken paths or + // escaping the root volume. + remainingPath = stripVolume(remainingPath) + + // 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 > consts.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) +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/join_test.go b/licenses/github.com/cyphar/filepath-securejoin/join_test.go new file mode 100644 index 0000000..bc59153 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/join_test.go @@ -0,0 +1,400 @@ +// SPDX-License-Identifier: BSD-3-Clause + +// Copyright (C) 2017-2025 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" + "runtime" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cyphar/filepath-securejoin/internal/testutils" +) + +// 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. + +type input struct { + root, unsafe string + expected string +} + +func expandedTempDir(t *testing.T) string { + dir := t.TempDir() + dir, err := filepath.EvalSymlinks(dir) + require.NoError(t, err) + return dir +} + +// Test basic handling of symlink expansion. +func TestSymlink(t *testing.T) { + dir := expandedTempDir(t) + + testutils.Symlink(t, "somepath", filepath.Join(dir, "etc")) + testutils.Symlink(t, "../../../../../../../../../../../../../etc", filepath.Join(dir, "etclink")) + testutils.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 := expandedTempDir(t) + + 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 := expandedTempDir(t) + + testutils.MkdirAll(t, filepath.Join(dir, "subdir"), 0o755) + testutils.MkdirAll(t, filepath.Join(dir, "cousinparent", "cousin"), 0o755) + testutils.Symlink(t, "../cousinparent/cousin", filepath.Join(dir, "subdir", "link")) + testutils.Symlink(t, "/../cousinparent/cousin", filepath.Join(dir, "subdir", "link2")) + testutils.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 := expandedTempDir(t) + + testutils.MkdirAll(t, filepath.Join(dir, "subdir"), 0o755) + testutils.Symlink(t, "../../../../../../../../../../../../../../../../path", filepath.Join(dir, "subdir", "link")) + testutils.Symlink(t, "/subdir/link", filepath.Join(dir, "path")) + testutils.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 := expandedTempDir(t) + + testutils.MkdirAll(t, filepath.Join(dir, "subdir"), 0o755) + testutils.WriteFile(t, filepath.Join(dir, "notdir"), []byte("I am not a directory!"), 0o755) + testutils.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 := expandedTempDir(t) + + testutils.MkdirAll(t, filepath.Join(dir, "subdir"), 0o755) + testutils.MkdirAll(t, filepath.Join(dir, "cousinparent", "cousin"), 0o755) + testutils.Symlink(t, "../cousinparent/cousin", filepath.Join(dir, "subdir", "link")) + testutils.Symlink(t, "/../cousinparent/cousin", filepath.Join(dir, "subdir", "link2")) + testutils.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") + ) + + dir := expandedTempDir(t) + + // Make a link. + testutils.Symlink(t, "../../../../../../../../../../../../../../../../path", filepath.Join(dir, "link")) + + // Define some fake mock functions. + lstatFailFn := func(string) (os.FileInfo, error) { return nil, lstatErr } + readlinkFailFn := func(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 errors.Is(err, exp) { + success = true + } + } + if !success { + t.Errorf("SecureJoinVFS.mock%d: expected to get lstatError, got %v", idx, err) + } + } +} + +func TestUncleanRoot(t *testing.T) { + root := t.TempDir() + + for _, test := range []struct { + testName, root string + expectedErr error + }{ + {"trailing-dotdot", "foo/..", errUnsafeRoot}, + {"leading-dotdot", "../foo", errUnsafeRoot}, + {"middle-dotdot", "../foo", errUnsafeRoot}, + {"many-dotdot", "foo/../foo/../a", errUnsafeRoot}, + {"trailing-slash", root + "/foo/bar/", nil}, + {"trailing-slashes", root + "/foo/bar///", nil}, + {"many-slashes", root + "/foo///bar////baz", nil}, + {"plain-dot", root + "/foo/./bar", nil}, + {"many-dot", root + "/foo/./bar/./.", nil}, + {"unclean-safe", root + "/foo///./bar/.///.///", nil}, + {"unclean-unsafe", root + "/foo///./bar/..///.///", errUnsafeRoot}, + } { + test := test // copy iterator + t.Run(test.testName, func(t *testing.T) { + _, err := SecureJoin(test.root, "foo/bar/baz") + if test.expectedErr != nil { + assert.ErrorIsf(t, err, test.expectedErr, "SecureJoin with unsafe root %q", test.root) + } else { + assert.NoErrorf(t, err, "SecureJoin with safe but unclean root %q", test.root) + } + }) + } +} + +func TestHasDotDot(t *testing.T) { + for _, test := range []struct { + testName, path string + expected bool + }{ + {"plain-dotdot", "..", true}, + {"trailing-dotdot", "foo/bar/baz/..", true}, + {"leading-dotdot", "../foo/bar/baz", true}, + {"middle-dotdot", "foo/bar/../baz", true}, + {"dotdot-in-name1", "foo/..bar/baz", false}, + {"dotdot-in-name2", "foo/bar../baz", false}, + {"dotdot-in-name3", "foo/b..r/baz", false}, + {"dotdot-in-name4", "..foo/bar/baz", false}, + {"dotdot-in-name5", "foo/bar/baz..", false}, + {"dot1", "./foo/bar/baz", false}, + {"dot2", "foo/bar/baz/.", false}, + {"dot3", "foo/././bar/baz", false}, + {"unclean", "foo//.//bar/baz////", false}, + } { + test := test // copy iterator + t.Run(test.testName, func(t *testing.T) { + got := hasDotDot(test.path) + assert.Equalf(t, test.expected, got, "unexpected result for hasDotDot(%q)", test.path) + }) + } +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/join_windows_test.go b/licenses/github.com/cyphar/filepath-securejoin/join_windows_test.go new file mode 100644 index 0000000..8f666c3 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/join_windows_test.go @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: BSD-3-Clause + +// Copyright (C) 2017-2025 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 ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +// Windows has very specific behaviour relating to volumes, and we can only +// test it on Windows machines because filepath.* behaviour depends on GOOS. +// +// See +// for more information about the various path formats we need to make sure are +// correctly handled. +func TestHasDotDot_WindowsVolumes(t *testing.T) { + for _, test := range []struct { + testName, path string + expected bool + }{ + {"plain-dotdot", `C:..`, true}, // apparently legal + {"relative-dotdot", `C:..\foo\bar`, true}, // apparently legal + {"trailing-dotdot", `D:\foo\bar\..`, true}, + {"leading-dotdot", `F:\..\foo\bar`, true}, + {"middle-dotdot", `F:\foo\..\bar`, true}, + {"drive-like-path", `\foo\C:..\bar`, false}, // C:.. is a filename here + {"unc-dotdot", `\\gondor\share\call\for\aid\..\help`, true}, + {"dos-dotpath-dotdot1", `\\.\C:\..\foo\bar`, true}, + {"dos-dotpath-dotdot2", `\\.\C:\foo\..\bar`, true}, + {"dos-questionpath-dotdot1", `\\?\C:\..\foo\bar`, true}, + {"dos-questionpath-dotdot2", `\\?\C:\foo\..\bar`, true}, + } { + test := test // copy iterator + t.Run(test.testName, func(t *testing.T) { + got := hasDotDot(test.path) + assert.Equalf(t, test.expected, got, "unexpected result for hasDotDot(`%s`) (VolumeName: %q)", test.path, filepath.VolumeName(test.path)) + }) + } +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/README.md b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/README.md new file mode 100644 index 0000000..bb95b02 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/README.md @@ -0,0 +1,35 @@ +## `pathrs-lite` ## + +`github.com/cyphar/filepath-securejoin/pathrs-lite` provides a minimal **pure +Go** implementation of the core bits of [libpathrs][]. This is not intended to +be a complete replacement for libpathrs, instead it is mainly intended to be +useful as a transition tool for existing Go projects. + +`pathrs-lite` also provides a very easy way to switch to `libpathrs` (even for +downstreams where `pathrs-lite` is being used in a third-party package and is +not interested in using CGo). At build time, if you use the `libpathrs` build +tag then `pathrs-lite` will use `libpathrs` directly instead of the pure Go +implementation. The two backends are functionally equivalent (and we have +integration tests to verify this), so this migration should be very easy with +no user-visible impact. + +[libpathrs]: https://github.com/cyphar/libpathrs + +### License ### + +Most of this subpackage is licensed under the Mozilla Public License (version +2.0). For more information, see the top-level [COPYING.md][] and +[LICENSE.MPL-2.0][] files, as well as the individual license headers for each +file. + +``` +Copyright (C) 2024-2025 Aleksa Sarai +Copyright (C) 2024-2025 SUSE LLC + +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at https://mozilla.org/MPL/2.0/. +``` + +[COPYING.md]: ../COPYING.md +[LICENSE.MPL-2.0]: ../LICENSE.MPL-2.0 diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/doc.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/doc.go new file mode 100644 index 0000000..61411da --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/doc.go @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Package pathrs (pathrs-lite) is a less complete pure Go implementation of +// some of the APIs provided by [libpathrs]. +// +// [libpathrs]: https://github.com/cyphar/libpathrs +package pathrs diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/assert/assert.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/assert/assert.go new file mode 100644 index 0000000..595dfbf --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/assert/assert.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Copyright (C) 2025 Aleksa Sarai +// Copyright (C) 2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Package assert provides some basic assertion helpers for Go. +package assert + +import ( + "fmt" +) + +// Assert panics if the predicate is false with the provided argument. +func Assert(predicate bool, msg any) { + if !predicate { + panic(msg) + } +} + +// Assertf panics if the predicate is false and formats the message using the +// same formatting as [fmt.Printf]. +// +// [fmt.Printf]: https://pkg.go.dev/fmt#Printf +func Assertf(predicate bool, fmtMsg string, args ...any) { + Assert(predicate, fmt.Sprintf(fmtMsg, args...)) +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/assert/assert_test.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/assert/assert_test.go new file mode 100644 index 0000000..5f4503d --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/assert/assert_test.go @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Copyright (C) 2025 Aleksa Sarai +// Copyright (C) 2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package assert_test + +import ( + "errors" + "testing" + + testassert "github.com/stretchr/testify/assert" + + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/assert" +) + +func TestAssertTrue(t *testing.T) { + for _, test := range []struct { + name string + val any + }{ + {"StringVal", "foobar"}, + {"IntVal", 123}, + {"ErrorVal", errors.New("error")}, + {"StructVal", struct{ a int }{1}}, + {"NilVal", nil}, + } { + test := test // copy iterator + t.Run(test.name, func(t *testing.T) { + testassert.NotPanicsf(t, func() { + assert.Assert(true, test.val) + }, "assert(true) with value %v (%T)", test.val, test.val) + }) + } + + t.Run("Assertf", func(t *testing.T) { + fmtMsg := "foo %s %d" + args := []any{"bar %x", 123} + expected := "foo bar %x 123" + + testassert.NotPanicsf(t, func() { + assert.Assertf(true, fmtMsg, args...) + }, "assertf(true) with (%q, %v...) == %q", fmtMsg, args, expected) + }) +} + +func TestAssertFalse(t *testing.T) { + for _, test := range []struct { + name string + val any + }{ + {"StringVal", "foobar"}, + {"IntVal", 123}, + {"ErrorVal", errors.New("error")}, + {"StructVal", struct{ a int }{1}}, + } { + test := test // copy iterator + t.Run(test.name, func(t *testing.T) { + testassert.PanicsWithValuef(t, test.val, func() { + assert.Assert(false, test.val) + }, "assert(false) with value %v (%T)", test.val, test.val) + }) + } + + t.Run("NilVal", func(t *testing.T) { + // testify can detect nil-value panics, but the behaviour of nil panics + // changed in Go 1.21 (and can be modified by GODEBUG=panicnil=1) so we + // can't be sure what value we will get. + testassert.Panics(t, func() { + assert.Assert(false, nil) + }, "assert(false) with nil") + }) + + t.Run("Assertf", func(t *testing.T) { + fmtMsg := "foo %s %d" + args := []any{"bar %x", 123} + expected := "foo bar %x 123" + + testassert.PanicsWithValuef(t, expected, func() { + assert.Assertf(false, fmtMsg, args...) + }, "assertf(true) with (%q, %v...) == %q", fmtMsg, args, expected) + }) +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/errors_linux.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/errors_linux.go new file mode 100644 index 0000000..d0b200f --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/errors_linux.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Package internal contains unexported common code for filepath-securejoin. +package internal + +import ( + "errors" + + "golang.org/x/sys/unix" +) + +type xdevErrorish struct { + description string +} + +func (err xdevErrorish) Error() string { return err.description } +func (err xdevErrorish) Is(target error) bool { return target == unix.EXDEV } + +var ( + // ErrPossibleAttack indicates that some attack was detected. + ErrPossibleAttack error = xdevErrorish{"possible attack detected"} + + // ErrPossibleBreakout indicates that during an operation we ended up in a + // state that could be a breakout but we detected it. + ErrPossibleBreakout error = xdevErrorish{"possible breakout detected"} + + // ErrInvalidDirectory indicates an unlinked directory. + ErrInvalidDirectory = errors.New("wandered into deleted directory") + + // ErrDeletedInode indicates an unlinked file (non-directory). + ErrDeletedInode = errors.New("cannot verify path of deleted inode") +) diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/errors_linux_test.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/errors_linux_test.go new file mode 100644 index 0000000..9077746 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/errors_linux_test.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package internal + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/sys/unix" +) + +func TestErrorXdev(t *testing.T) { + for _, test := range []struct { + name string + err error + }{ + {"ErrPossibleAttack", ErrPossibleAttack}, + {"ErrPossibleBreakout", ErrPossibleBreakout}, + } { + t.Run(test.name, func(t *testing.T) { + assert.ErrorIs(t, test.err, test.err, "errors.Is(err, err) should succeed") //nolint:useless-assert,testifylint // we need to check this + assert.ErrorIs(t, test.err, unix.EXDEV, "errors.Is(err, EXDEV) should succeed") //nolint:useless-assert,testifylint // we need to check this + }) + + t.Run(test.name+"-Wrapped", func(t *testing.T) { + err := fmt.Errorf("wrapped error: %w", test.err) + assert.ErrorIs(t, err, test.err, "errors.Is(err, err) should succeed") //nolint:useless-assert,testifylint // we need to check this + assert.ErrorIs(t, err, unix.EXDEV, "errors.Is(err, EXDEV) should succeed") //nolint:useless-assert,testifylint // we need to check this + }) + } +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd/at_linux.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd/at_linux.go new file mode 100644 index 0000000..0910549 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd/at_linux.go @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package fd + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + + "golang.org/x/sys/unix" + + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat" +) + +// prepareAtWith returns -EBADF (an invalid fd) if dir is nil, otherwise using +// the dir.Fd(). We use -EBADF because in filepath-securejoin we generally +// don't want to allow relative-to-cwd paths. The returned path is an +// *informational* string that describes a reasonable pathname for the given +// *at(2) arguments. You must not use the full path for any actual filesystem +// operations. +func prepareAt(dir Fd, path string) (dirFd int, unsafeUnmaskedPath string) { + dirFd, dirPath := -int(unix.EBADF), "." + if dir != nil { + dirFd, dirPath = int(dir.Fd()), dir.Name() + } + if !filepath.IsAbs(path) { + // only prepend the dirfd path for relative paths + path = dirPath + "/" + path + } + // NOTE: If path is "." or "", the returned path won't be filepath.Clean, + // but that's okay since this path is either used for errors (in which case + // a trailing "/" or "/." is important information) or will be + // filepath.Clean'd later (in the case of fd.Openat). + return dirFd, path +} + +// Openat is an [Fd]-based wrapper around unix.Openat. +func Openat(dir Fd, path string, flags int, mode int) (*os.File, error) { //nolint:unparam // wrapper func + dirFd, fullPath := prepareAt(dir, path) + // Make sure we always set O_CLOEXEC. + flags |= unix.O_CLOEXEC + fd, err := unix.Openat(dirFd, path, flags, uint32(mode)) + if err != nil { + return nil, &os.PathError{Op: "openat", Path: fullPath, Err: err} + } + runtime.KeepAlive(dir) + // openat is only used with lexically-safe paths so we can use + // filepath.Clean here, and also the path itself is not going to be used + // for actual path operations. + fullPath = filepath.Clean(fullPath) + return os.NewFile(uintptr(fd), fullPath), nil +} + +// Fstatat is an [Fd]-based wrapper around unix.Fstatat. +func Fstatat(dir Fd, path string, flags int) (unix.Stat_t, error) { + dirFd, fullPath := prepareAt(dir, path) + var stat unix.Stat_t + if err := unix.Fstatat(dirFd, path, &stat, flags); err != nil { + return stat, &os.PathError{Op: "fstatat", Path: fullPath, Err: err} + } + runtime.KeepAlive(dir) + return stat, nil +} + +// Faccessat is an [Fd]-based wrapper around unix.Faccessat. +func Faccessat(dir Fd, path string, mode uint32, flags int) error { + dirFd, fullPath := prepareAt(dir, path) + err := unix.Faccessat(dirFd, path, mode, flags) + if err != nil { + err = &os.PathError{Op: "faccessat", Path: fullPath, Err: err} + } + runtime.KeepAlive(dir) + return err +} + +// Readlinkat is an [Fd]-based wrapper around unix.Readlinkat. +func Readlinkat(dir Fd, path string) (string, error) { + dirFd, fullPath := prepareAt(dir, path) + size := 4096 + for { + linkBuf := make([]byte, size) + n, err := unix.Readlinkat(dirFd, path, linkBuf) + if err != nil { + return "", &os.PathError{Op: "readlinkat", Path: fullPath, Err: err} + } + runtime.KeepAlive(dir) + if n != size { + return string(linkBuf[:n]), nil + } + // Possible truncation, resize the buffer. + size *= 2 + } +} + +const ( + // STATX_MNT_ID_UNIQUE is provided in golang.org/x/sys@v0.20.0, but in order to + // avoid bumping the requirement for a single constant we can just define it + // ourselves. + _STATX_MNT_ID_UNIQUE = 0x4000 //nolint:revive // unix.* name + + // We don't care which mount ID we get. The kernel will give us the unique + // one if it is supported. If the kernel doesn't support + // STATX_MNT_ID_UNIQUE, the bit is ignored and the returned request mask + // will only contain STATX_MNT_ID (if supported). + wantStatxMntMask = _STATX_MNT_ID_UNIQUE | unix.STATX_MNT_ID +) + +var hasStatxMountID = gocompat.SyncOnceValue(func() bool { + var stx unix.Statx_t + err := unix.Statx(-int(unix.EBADF), "/", 0, wantStatxMntMask, &stx) + return err == nil && stx.Mask&wantStatxMntMask != 0 +}) + +// GetMountID gets the mount identifier associated with the fd and path +// combination. It is effectively a wrapper around fetching +// STATX_MNT_ID{,_UNIQUE} with unix.Statx, but with a fallback to 0 if the +// kernel doesn't support the feature. +func GetMountID(dir Fd, path string) (uint64, error) { + // If we don't have statx(STATX_MNT_ID*) support, we can't do anything. + if !hasStatxMountID() { + return 0, nil + } + + dirFd, fullPath := prepareAt(dir, path) + + var stx unix.Statx_t + err := unix.Statx(dirFd, path, unix.AT_EMPTY_PATH|unix.AT_SYMLINK_NOFOLLOW, wantStatxMntMask, &stx) + if stx.Mask&wantStatxMntMask == 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("could not get mount id: %w", err) + } + if err != nil { + return 0, &os.PathError{Op: "statx(STATX_MNT_ID_...)", Path: fullPath, Err: err} + } + runtime.KeepAlive(dir) + return stx.Mnt_id, nil +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd/fd.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd/fd.go new file mode 100644 index 0000000..d2206a3 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd/fd.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Copyright (C) 2025 Aleksa Sarai +// Copyright (C) 2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Package fd provides a drop-in interface-based replacement of [*os.File] that +// allows for things like noop-Close wrappers to be used. +// +// [*os.File]: https://pkg.go.dev/os#File +package fd + +import ( + "io" + "os" +) + +// Fd is an interface that mirrors most of the API of [*os.File], allowing you +// to create wrappers that can be used in place of [*os.File]. +// +// [*os.File]: https://pkg.go.dev/os#File +type Fd interface { + io.Closer + Name() string + Fd() uintptr +} + +// Compile-time interface checks. +var ( + _ Fd = (*os.File)(nil) + _ Fd = noClose{} +) + +type noClose struct{ inner Fd } + +func (f noClose) Name() string { return f.inner.Name() } +func (f noClose) Fd() uintptr { return f.inner.Fd() } + +func (f noClose) Close() error { return nil } + +// NopCloser returns an [*os.File]-like object where the [Close] method is now +// a no-op. +// +// Note that for [*os.File] and similar objects, the Go garbage collector will +// still call [Close] on the underlying file unless you use +// [runtime.SetFinalizer] to disable this behaviour. This is up to the caller +// to do (if necessary). +// +// [*os.File]: https://pkg.go.dev/os#File +// [Close]: https://pkg.go.dev/io#Closer +// [runtime.SetFinalizer]: https://pkg.go.dev/runtime#SetFinalizer +func NopCloser(f Fd) Fd { return noClose{inner: f} } diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd/fd_linux.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd/fd_linux.go new file mode 100644 index 0000000..e1ec3c0 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd/fd_linux.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package fd + +import ( + "fmt" + "os" + "runtime" + + "golang.org/x/sys/unix" + + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal" +) + +// DupWithName creates a new file descriptor referencing the same underlying +// file, but with the provided name instead of fd.Name(). +func DupWithName(fd Fd, name string) (*os.File, error) { + fd2, err := unix.FcntlInt(fd.Fd(), unix.F_DUPFD_CLOEXEC, 0) + if err != nil { + return nil, os.NewSyscallError("fcntl(F_DUPFD_CLOEXEC)", err) + } + runtime.KeepAlive(fd) + return os.NewFile(uintptr(fd2), name), nil +} + +// Dup creates a new file description referencing the same underlying file. +func Dup(fd Fd) (*os.File, error) { + return DupWithName(fd, fd.Name()) +} + +// Fstat is an [Fd]-based wrapper around unix.Fstat. +func Fstat(fd Fd) (unix.Stat_t, error) { + var stat unix.Stat_t + if err := unix.Fstat(int(fd.Fd()), &stat); err != nil { + return stat, &os.PathError{Op: "fstat", Path: fd.Name(), Err: err} + } + runtime.KeepAlive(fd) + return stat, nil +} + +// Fstatfs is an [Fd]-based wrapper around unix.Fstatfs. +func Fstatfs(fd Fd) (unix.Statfs_t, error) { + var statfs unix.Statfs_t + if err := unix.Fstatfs(int(fd.Fd()), &statfs); err != nil { + return statfs, &os.PathError{Op: "fstatfs", Path: fd.Name(), Err: err} + } + runtime.KeepAlive(fd) + return statfs, nil +} + +// IsDeadInode detects whether the file has been unlinked from a filesystem and +// is thus a "dead inode" from the kernel's perspective. +func IsDeadInode(file Fd) 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 := internal.ErrDeletedInode + if stat.Mode&unix.S_IFMT == unix.S_IFDIR { + err = internal.ErrInvalidDirectory + } + return fmt.Errorf("%w %q", err, file.Name()) + } + return nil +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd/fd_test.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd/fd_test.go new file mode 100644 index 0000000..e3818ec --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd/fd_test.go @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Copyright (C) 2025 Aleksa Sarai +// Copyright (C) 2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package fd_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd" +) + +func TestNopCloser(t *testing.T) { + f, err := os.Open("/") + require.NoError(t, err) + require.NotNil(t, f, "open /") + + actualName := f.Name() + actualFd := f.Fd() + + f2 := fd.NopCloser(f) + require.NotNil(t, f, "wrap f2") + + assert.NoError(t, f2.Close(), "close no-op") //nolint:testifylint // this is an isolated operation so we can continue despite an error + assert.NoError(t, f2.Close(), "close no-op again") //nolint:testifylint // this is an isolated operation so we can continue despite an error + + assert.Equal(t, actualFd, f2.Fd(), "fd should still be valid (file not closed)") + assert.Equal(t, actualName, f2.Name(), "fd should still be valid (file not closed)") + + require.NoError(t, f.Close(), "close underlying file") + + assert.NotEqual(t, actualFd, f2.Fd(), "fd should not be valid (file closed)") +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd/mount_linux.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd/mount_linux.go new file mode 100644 index 0000000..77549c7 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd/mount_linux.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package fd + +import ( + "os" + "runtime" + + "golang.org/x/sys/unix" +) + +// Fsopen is an [Fd]-based wrapper around unix.Fsopen. +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 +} + +// Fsmount is an [Fd]-based wrapper around unix.Fsmount. +func Fsmount(ctx Fd, 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 +} + +// OpenTree is an [Fd]-based wrapper around unix.OpenTree. +func OpenTree(dir Fd, path string, flags uint) (*os.File, error) { + dirFd, fullPath := prepareAt(dir, path) + // 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: fullPath, Err: err} + } + runtime.KeepAlive(dir) + return os.NewFile(uintptr(fd), fullPath), nil +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd/openat2_linux.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd/openat2_linux.go new file mode 100644 index 0000000..6386364 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd/openat2_linux.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package fd + +import ( + "errors" + "os" + "runtime" + + "golang.org/x/sys/unix" +) + +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)) +} + +// This is a fairly arbitrary limit we have just to avoid an attacker being +// able to make us spin in an infinite retry loop -- callers can choose to +// retry on EAGAIN if they prefer. +const scopedLookupMaxRetries = 128 + +// Openat2 is an [Fd]-based wrapper around unix.Openat2, but with some retry +// logic in case of EAGAIN errors. +// +// NOTE: This is a variable so that the lookup tests can force openat2 to fail. +var Openat2 = func(dir Fd, path string, how *unix.OpenHow) (*os.File, error) { + dirFd, fullPath := prepareAt(dir, path) + // Make sure we always set O_CLOEXEC. + how.Flags |= unix.O_CLOEXEC + var tries int + for { + fd, err := unix.Openat2(dirFd, path, how) + if err != nil { + if scopedLookupShouldRetry(how, err) && tries < scopedLookupMaxRetries { + // 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} + } + runtime.KeepAlive(dir) + return os.NewFile(uintptr(fd), fullPath), nil + } +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/README.md b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/README.md new file mode 100644 index 0000000..5dcb6ae --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/README.md @@ -0,0 +1,10 @@ +## gocompat ## + +This directory contains backports of stdlib functions from later Go versions so +the filepath-securejoin can continue to be used by projects that are stuck with +Go 1.18 support. Note that often filepath-securejoin is added in security +patches for old releases, so avoiding the need to bump Go compiler requirements +is a huge plus to downstreams. + +The source code is licensed under the same license as the Go stdlib. See the +source files for the precise license information. diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/doc.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/doc.go new file mode 100644 index 0000000..4b1803f --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/doc.go @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: BSD-3-Clause +//go:build linux && go1.20 + +// Copyright (C) 2025 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 gocompat includes compatibility shims (backported from future Go +// stdlib versions) to permit filepath-securejoin to be used with older Go +// versions (often filepath-securejoin is added in security patches for old +// releases, so avoiding the need to bump Go compiler requirements is a huge +// plus to downstreams). +package gocompat diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/gocompat_atomic_go119.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/gocompat_atomic_go119.go new file mode 100644 index 0000000..ac93cb0 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/gocompat_atomic_go119.go @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux && go1.19 + +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package gocompat + +import ( + "sync/atomic" +) + +// A Bool is an atomic boolean value. +// The zero value is false. +// +// Bool must not be copied after first use. +type Bool = atomic.Bool diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/gocompat_atomic_unsupported.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/gocompat_atomic_unsupported.go new file mode 100644 index 0000000..21b5b29 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/gocompat_atomic_unsupported.go @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux && !go1.19 + +// Copyright (C) 2024-2025 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 gocompat + +import ( + "sync/atomic" +) + +// noCopy may be added to structs which must not be copied +// after the first use. +// +// See https://golang.org/issues/8005#issuecomment-190753527 +// for details. +// +// Note that it must not be embedded, due to the Lock and Unlock methods. +type noCopy struct{} + +// Lock is a no-op used by -copylocks checker from `go vet`. +func (*noCopy) Lock() {} + +// b32 returns a uint32 0 or 1 representing b. +func b32(b bool) uint32 { + if b { + return 1 + } + return 0 +} + +// A Bool is an atomic boolean value. +// The zero value is false. +// +// Bool must not be copied after first use. +type Bool struct { + _ noCopy + v uint32 +} + +// Load atomically loads and returns the value stored in x. +func (x *Bool) Load() bool { return atomic.LoadUint32(&x.v) != 0 } + +// Store atomically stores val into x. +func (x *Bool) Store(val bool) { atomic.StoreUint32(&x.v, b32(val)) } diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/gocompat_errors_go120.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/gocompat_errors_go120.go new file mode 100644 index 0000000..4a114bd --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/gocompat_errors_go120.go @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: BSD-3-Clause +//go:build linux && go1.20 + +// 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 gocompat + +import ( + "fmt" +) + +// WrapBaseError is a helper that is equivalent to fmt.Errorf("%w: %w"), except +// that on pre-1.20 Go versions only errors.Is() works properly (errors.Unwrap) +// is only guaranteed to give you baseErr. +func WrapBaseError(baseErr, extraErr error) error { + return fmt.Errorf("%w: %w", extraErr, baseErr) +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/gocompat_errors_test.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/gocompat_errors_test.go new file mode 100644 index 0000000..a5b1ec3 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/gocompat_errors_test.go @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: BSD-3-Clause + +//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 gocompat + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGoCompatErrorWrap(t *testing.T) { + baseErr := errors.New("base error") + extraErr := errors.New("extra error") + + err := WrapBaseError(baseErr, extraErr) + + require.Error(t, err) + assert.ErrorIs(t, err, baseErr, "wrapped error should contain base error") //nolint:testifylint // we are testing error behaviour directly + assert.ErrorIs(t, err, extraErr, "wrapped error should contain extra error") //nolint:testifylint // we are testing error behaviour directly +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/gocompat_errors_unsupported.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/gocompat_errors_unsupported.go new file mode 100644 index 0000000..3061016 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/gocompat_errors_unsupported.go @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux && !go1.20 + +// 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 gocompat + +import ( + "fmt" +) + +type wrappedError struct { + inner error + isError error +} + +func (err wrappedError) Is(target error) bool { + return err.isError == target +} + +func (err wrappedError) Unwrap() error { + return err.inner +} + +func (err wrappedError) Error() string { + return fmt.Sprintf("%v: %v", err.isError, err.inner) +} + +// WrapBaseError is a helper that is equivalent to fmt.Errorf("%w: %w"), except +// that on pre-1.20 Go versions only errors.Is() works properly (errors.Unwrap) +// is only guaranteed to give you baseErr. +func WrapBaseError(baseErr, extraErr error) error { + return wrappedError{ + inner: baseErr, + isError: extraErr, + } +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/gocompat_generics_go121.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/gocompat_generics_go121.go new file mode 100644 index 0000000..d4a9381 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/gocompat_generics_go121.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux && go1.21 + +// Copyright (C) 2024-2025 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 gocompat + +import ( + "cmp" + "slices" + "sync" +) + +// SlicesDeleteFunc is equivalent to Go 1.21's slices.DeleteFunc. +func SlicesDeleteFunc[S ~[]E, E any](slice S, delFn func(E) bool) S { + return slices.DeleteFunc(slice, delFn) +} + +// SlicesContains is equivalent to Go 1.21's slices.Contains. +func SlicesContains[S ~[]E, E comparable](slice S, val E) bool { + return slices.Contains(slice, val) +} + +// SlicesClone is equivalent to Go 1.21's slices.Clone. +func SlicesClone[S ~[]E, E any](slice S) S { + return slices.Clone(slice) +} + +// SyncOnceValue is equivalent to Go 1.21's sync.OnceValue. +func SyncOnceValue[T any](f func() T) func() T { + return sync.OnceValue(f) +} + +// SyncOnceValues is equivalent to Go 1.21's sync.OnceValues. +func SyncOnceValues[T1, T2 any](f func() (T1, T2)) func() (T1, T2) { + return sync.OnceValues(f) +} + +// CmpOrdered is equivalent to Go 1.21's cmp.Ordered generic type definition. +type CmpOrdered = cmp.Ordered + +// CmpCompare is equivalent to Go 1.21's cmp.Compare. +func CmpCompare[T CmpOrdered](x, y T) int { + return cmp.Compare(x, y) +} + +// Max2 is equivalent to Go 1.21's max builtin (but only for two parameters). +func Max2[T CmpOrdered](x, y T) T { + return max(x, y) +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/gocompat_generics_unsupported.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/gocompat_generics_unsupported.go new file mode 100644 index 0000000..0ea6218 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat/gocompat_generics_unsupported.go @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux && !go1.21 + +// Copyright (C) 2021, 2022 The Go Authors. All rights reserved. +// Copyright (C) 2024-2025 SUSE LLC. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE.BSD file. + +package gocompat + +import ( + "sync" +) + +// These are very minimal implementations of functions that appear in Go 1.21's +// stdlib, included so that we can build on older Go versions. Most are +// borrowed directly from the stdlib, and a few are modified to be "obviously +// correct" without needing to copy too many other helpers. + +// clearSlice is equivalent to Go 1.21's builtin clear. +// Copied from the Go 1.24 stdlib implementation. +func clearSlice[S ~[]E, E any](slice S) { + var zero E + for i := range slice { + slice[i] = zero + } +} + +// slicesIndexFunc is equivalent to Go 1.21's slices.IndexFunc. +// Copied from the Go 1.24 stdlib implementation. +func slicesIndexFunc[S ~[]E, E any](s S, f func(E) bool) int { + for i := range s { + if f(s[i]) { + return i + } + } + return -1 +} + +// SlicesDeleteFunc is equivalent to Go 1.21's slices.DeleteFunc. +// Copied from the Go 1.24 stdlib implementation. +func SlicesDeleteFunc[S ~[]E, E any](s S, del func(E) bool) S { + i := slicesIndexFunc(s, del) + if i == -1 { + return s + } + // Don't start copying elements until we find one to delete. + for j := i + 1; j < len(s); j++ { + if v := s[j]; !del(v) { + s[i] = v + i++ + } + } + clearSlice(s[i:]) // zero/nil out the obsolete elements, for GC + return s[:i] +} + +// SlicesContains is equivalent to Go 1.21's slices.Contains. +// Similar to the stdlib slices.Contains, except that we don't have +// slices.Index so we need to use slices.IndexFunc for this non-Func helper. +func SlicesContains[S ~[]E, E comparable](s S, v E) bool { + return slicesIndexFunc(s, func(e E) bool { return e == v }) >= 0 +} + +// SlicesClone is equivalent to Go 1.21's slices.Clone. +// Copied from the Go 1.24 stdlib implementation. +func SlicesClone[S ~[]E, E any](s S) S { + // Preserve nil in case it matters. + if s == nil { + return nil + } + return append(S([]E{}), s...) +} + +// SyncOnceValue is equivalent to Go 1.21's sync.OnceValue. +// Copied from the Go 1.25 stdlib implementation. +func SyncOnceValue[T any](f func() T) func() T { + // Use a struct so that there's a single heap allocation. + d := struct { + f func() T + once sync.Once + valid bool + p any + result T + }{ + f: f, + } + return func() T { + d.once.Do(func() { + defer func() { + d.f = nil + d.p = recover() + if !d.valid { + panic(d.p) + } + }() + d.result = d.f() + d.valid = true + }) + if !d.valid { + panic(d.p) + } + return d.result + } +} + +// SyncOnceValues is equivalent to Go 1.21's sync.OnceValues. +// Copied from the Go 1.25 stdlib implementation. +func SyncOnceValues[T1, T2 any](f func() (T1, T2)) func() (T1, T2) { + // Use a struct so that there's a single heap allocation. + d := struct { + f func() (T1, T2) + once sync.Once + valid bool + p any + r1 T1 + r2 T2 + }{ + f: f, + } + return func() (T1, T2) { + d.once.Do(func() { + defer func() { + d.f = nil + d.p = recover() + if !d.valid { + panic(d.p) + } + }() + d.r1, d.r2 = d.f() + d.valid = true + }) + if !d.valid { + panic(d.p) + } + return d.r1, d.r2 + } +} + +// CmpOrdered is equivalent to Go 1.21's cmp.Ordered generic type definition. +// Copied from the Go 1.25 stdlib implementation. +type CmpOrdered interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | + ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr | + ~float32 | ~float64 | + ~string +} + +// isNaN reports whether x is a NaN without requiring the math package. +// This will always return false if T is not floating-point. +// Copied from the Go 1.25 stdlib implementation. +func isNaN[T CmpOrdered](x T) bool { + return x != x +} + +// CmpCompare is equivalent to Go 1.21's cmp.Compare. +// Copied from the Go 1.25 stdlib implementation. +func CmpCompare[T CmpOrdered](x, y T) int { + xNaN := isNaN(x) + yNaN := isNaN(y) + if xNaN { + if yNaN { + return 0 + } + return -1 + } + if yNaN { + return +1 + } + if x < y { + return -1 + } + if x > y { + return +1 + } + return 0 +} + +// Max2 is equivalent to Go 1.21's max builtin for two parameters. +func Max2[T CmpOrdered](x, y T) T { + m := x + if y > m { + m = y + } + return m +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs/doc.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs/doc.go new file mode 100644 index 0000000..2ddb71e --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs/doc.go @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Package gopathrs is a less complete pure Go implementation of some of the +// APIs provided by [libpathrs]. +// +// [libpathrs]: https://github.com/cyphar/libpathrs +package gopathrs diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs/lookup_linux.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs/lookup_linux.go new file mode 100644 index 0000000..ad233f1 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs/lookup_linux.go @@ -0,0 +1,404 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package gopathrs + +import ( + "errors" + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" + + "github.com/cyphar/filepath-securejoin/internal/consts" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/linux" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs" +) + +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 := gocompat.SlicesDeleteFunc( + strings.Split(linkTarget, "/"), + func(part string) bool { return part == "" || part == "." }) + + // Copy the directory so the caller doesn't close our copy. + dirCopy, err := fd.Dup(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 fd.Fd, unsafePath string) (*os.File, string, error) { + return lookupInRoot(root, unsafePath, true) +} + +func completeLookupInRoot(root fd.Fd, 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 fd.Fd, 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. + // + // NOTE: If openat2(2) works normally but fails for this lookup, it is + // probably not a good idea to fall-back to the O_PATH resolver. An + // attacker could find a bug in the O_PATH resolver and uncontionally + // falling back to the O_PATH resolver would form a downgrade attack. + if handle, remainingPath, err := lookupOpenat2(root, unsafePath, partial); err == nil || linux.HasOpenat2() { + return handle, remainingPath, err + } + + // 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 procfs.CheckProcSelfFdPath that we are using the + // correct root path. + logicalRootPath, err := procfs.ProcSelfFdReadlink(root) + if err != nil { + return nil, "", fmt.Errorf("get real root path: %w", err) + } + + currentDir, err := fd.Dup(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 := fd.Dup(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 := fd.Openat(currentDir, part, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + switch err { + case 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 { //nolint:exhaustive // just a glorified if statement + 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 := fd.Readlinkat(nextDir, "") + // We don't need the handle anymore. + _ = nextDir.Close() + if err != nil { + return nil, "", err + } + + linksWalked++ + if linksWalked > consts.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 := fd.Dup(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 := procfs.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 := procfs.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 := fd.Openat(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 +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs/lookup_linux_test.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs/lookup_linux_test.go new file mode 100644 index 0000000..47bafc0 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs/lookup_linux_test.go @@ -0,0 +1,925 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package gopathrs + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" + + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/testutils" +) + +type partialLookupFunc func(root fd.Fd, unsafePath string) (*os.File, string, error) + +type lookupResult struct { + handlePath, remainingPath string + err error + fileType uint32 +} + +func checkPartialLookup(t *testing.T, partialLookupFn partialLookupFunc, rootDir fd.Fd, unsafePath string, expected lookupResult) { + handle, remainingPath, err := partialLookupFn(rootDir, unsafePath) + if handle != nil { + defer handle.Close() //nolint:errcheck // test code + } + if expected.err != nil { + if assert.Error(t, err) { + assert.ErrorIs(t, err, expected.err) + } + if expected.handlePath == "" { + require.Nil(t, handle, "expected to not get a handle") + return + } + } else { + if expected.remainingPath != "" { + t.Errorf("we expect a remaining path, but no error? %q", expected.remainingPath) + } + require.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 := procfs.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 := fd.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 := testutils.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() //nolint:errcheck // test code + + 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 tRunWrapper(t *testing.T) testutils.TRunFunc { + return func(name string, doFn testutils.TDoFunc) { + t.Run(name, func(t *testing.T) { + doFn(t) + }) + } +} + +func TestPartialLookupInRoot(t *testing.T) { + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + testPartialLookup(t, PartialLookupInRoot) + }) +} + +func TestPartialOpenat2(t *testing.T) { + testPartialLookup(t, partialLookupOpenat2) +} + +func TestPartialLookupInRoot_BadInode(t *testing.T) { + testutils.RequireRoot(t) // mknod + + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + 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 := testutils.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() //nolint:errcheck // test code + + 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 fd.Fd, 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 = procfs.ProcSelfFdReadlink(handle) + <-m.pauseCh + require.NoError(t, err, "get real path of returned handle") + + unixStat, err = fd.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)++ +} + +// doRenameExchangeLoop runs in a loop swapping two paths, intended to be run +// in a goroutine during a test. +func doRenameExchangeLoop(pauseCh chan struct{}, exitCh <-chan struct{}, dir fd.Fd, 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) + } +} + +func TestPartialLookup_RacingRename(t *testing.T) { + if testing.Short() { + t.Skip("skipping race tests in short mode") + } + testutils.RequireRenameExchange(t) + + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + 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, gocompat.SlicesClone(defaultExpected)}, + "swap-dir-link2-basic": {"a/b/c", "c-link", "a/b/c/d/e", nil, gocompat.SlicesClone(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, gocompat.SlicesClone(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, gocompat.SlicesClone(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, gocompat.SlicesClone(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. + gocompat.SlicesClone(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. + gocompat.SlicesClone(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}, gocompat.SlicesClone(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}, gocompat.SlicesClone(defaultExpected)}, + // Swap the root. + "swap-root-basic": {".", "../outsideroot", "a/b/c/d/e", nil, gocompat.SlicesClone(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, gocompat.SlicesClone(defaultExpected)}, + "swap-root-dotdot-extra": {".", "../outsideroot", "a/" + strings.Repeat("b/c/d/../../../", 10) + "b/c/d/e", nil, gocompat.SlicesClone(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. + gocompat.SlicesClone(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. + gocompat.SlicesClone(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, unix.EAGAIN, unix.EXDEV) + t.Run(name, func(t *testing.T) { + root := testutils.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() //nolint:errcheck // test code + + // 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 = 3000 + minPassCount = 10 + ) + m := newRacingLookupMeta(pauseCh) + doneRuns := 0 + for ; doneRuns < testRuns || m.passOkCount < minPassCount; doneRuns++ { + m.checkPartialLookup(t, rootDir, test.unsafePath, test.skipErrs, test.allowedResults) + // Make sure we don't infinite loop here. + if doneRuns >= 50*testRuns { + break + } + } + + pct := func(count int) string { + return fmt.Sprintf("%d(%.3f%%)", count, 100.0*float64(count)/float64(doneRuns)) + } + + // No passing runs is a bit unfortunate, but some of our tests + // can do that and failing here would just lead to flaky tests. + if m.passOkCount == 0 { + t.Logf("WARNING: NO PASSING RUNS!") + } + + // 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 + doneRuns, 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(_ *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) { //nolint:testifylint + 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() //nolint:errcheck // test code + + 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() //nolint:errcheck // test code +} + +type expectedStackEntry struct { + expectedDirName string + expectedUnwalked []string +} + +func testStackContents(t *testing.T, msg string, ss symlinkStack, expected ...expectedStackEntry) { + if len(expected) > 0 { + require.Lenf(t, 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, 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, entry.expectedDirName, ss[idx].dir.Name(), "%s: stack entry %d name mismatch", msg, idx) + if len(entry.expectedUnwalked) > 0 { + assert.Equalf(t, entry.expectedUnwalked, ss[idx].linkUnwalked, "%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() //nolint:errcheck // test code + + // 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() //nolint:errcheck // test code + + // Basic expected contents. + initialState := []expectedStackEntry{ + // Top entry is not a tail-chain. + {"A", []string{"subdir1"}}, + // The first tail-chain should have no unwalked links. + {"B", nil}, + {"C", nil}, + {"D", nil}, + // Final entry in the first tail-chain. + {"E", []string{"subdir2"}}, + // The second tail-chain should have no unwalked links. + {"F", nil}, + {"G", nil}, + {"H", nil}, + {"I", nil}, + {"J", nil}, + // Final entry in the second tail-chain. + {"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") +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs/mkdir_linux.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs/mkdir_linux.go new file mode 100644 index 0000000..21a5593 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs/mkdir_linux.go @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package gopathrs + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" + + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/linux" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs" +) + +// ErrInvalidMode is returned from [MkdirAll] when the requested mode is +// invalid. +var ErrInvalidMode = errors.New("invalid permission mode") + +// modePermExt is like os.ModePerm except that it also includes the set[ug]id +// and sticky bits. +const modePermExt = os.ModePerm | os.ModeSetuid | os.ModeSetgid | os.ModeSticky + +//nolint:cyclop // this function needs to handle a lot of cases +func toUnixMode(mode os.FileMode) (uint32, error) { + sysMode := uint32(mode.Perm()) + if mode&os.ModeSetuid != 0 { + sysMode |= unix.S_ISUID + } + if mode&os.ModeSetgid != 0 { + sysMode |= unix.S_ISGID + } + if mode&os.ModeSticky != 0 { + sysMode |= unix.S_ISVTX + } + // We don't allow file type bits. + if mode&os.ModeType != 0 { + return 0, fmt.Errorf("%w %+.3o (%s): type bits not permitted", ErrInvalidMode, mode, mode) + } + // We don't allow other unknown modes. + if mode&^modePermExt != 0 || sysMode&unix.S_IFMT != 0 { + return 0, fmt.Errorf("%w %+.3o (%s): unknown mode bits", ErrInvalidMode, mode, mode) + } + return sysMode, nil +} + +// 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. +// +// [SecureJoin]: https://pkg.go.dev/github.com/cyphar/filepath-securejoin#SecureJoin +func MkdirAllHandle(root *os.File, unsafePath string, mode os.FileMode) (_ *os.File, Err error) { + unixMode, err := toUnixMode(mode) + if err != nil { + return nil, err + } + // 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 unixMode&^0o1777 != 0 { + return nil, fmt.Errorf("%w for mkdir %+.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 := fd.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 := procfs.ReopenFd(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 { //nolint:revive // indent-error-flow lint doesn't make sense here + _ = currentDir.Close() + currentDir = reopenDir + } + + remainingParts := strings.Split(remainingPath, string(filepath.Separator)) + if gocompat.SlicesContains(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) + } + + // 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 we get -EEXIST, it's possible that another program created the + // directory at the same time as us. In that case, just continue on as + // if we created it (if the created inode is not a directory, the + // following open call will fail). + if err := unix.Mkdirat(int(currentDir.Fd()), part, unixMode); err != nil && !errors.Is(err, unix.EEXIST) { + err = &os.PathError{Op: "mkdirat", Path: currentDir.Name() + "/" + part, Err: err} + // Make the error a bit nicer if the directory is dead. + if deadErr := fd.IsDeadInode(currentDir); deadErr != nil { + // TODO: Once we bump the minimum Go version to 1.20, we can use + // multiple %w verbs for this wrapping. For now we need to use a + // compatibility shim for older Go versions. + // err = fmt.Errorf("%w (%w)", err, deadErr) + err = gocompat.WrapBaseError(err, deadErr) + } + 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 linux.HasOpenat2() { + nextDir, err = openat2(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 = fd.Openat(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 +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs/mkdir_linux_test.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs/mkdir_linux_test.go new file mode 100644 index 0000000..4abdcbb --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs/mkdir_linux_test.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package gopathrs_test + +import ( + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" + + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs" +) + +func TestMkdirAllHandle_InvalidMode(t *testing.T) { //nolint:revive // underscores are more readable for test helpers + for _, test := range []struct { + mode os.FileMode + expectedErr error + }{ + // unix.S_IS* bits are invalid. + {unix.S_ISUID | 0o777, gopathrs.ErrInvalidMode}, + {unix.S_ISGID | 0o777, gopathrs.ErrInvalidMode}, + {unix.S_ISVTX | 0o777, gopathrs.ErrInvalidMode}, + {unix.S_ISUID | unix.S_ISGID | unix.S_ISVTX | 0o777, gopathrs.ErrInvalidMode}, + // unix.S_IFMT bits are also invalid. + {unix.S_IFDIR | 0o777, gopathrs.ErrInvalidMode}, + {unix.S_IFREG | 0o777, gopathrs.ErrInvalidMode}, + {unix.S_IFIFO | 0o777, gopathrs.ErrInvalidMode}, + // os.FileType bits are also invalid. + {os.ModeDir | 0o777, gopathrs.ErrInvalidMode}, + {os.ModeNamedPipe | 0o777, gopathrs.ErrInvalidMode}, + {os.ModeIrregular | 0o777, gopathrs.ErrInvalidMode}, + // suid/sgid bits are silently ignored by mkdirat and so we return an + // error explicitly. + {os.ModeSetuid | 0o777, gopathrs.ErrInvalidMode}, + {os.ModeSetgid | 0o777, gopathrs.ErrInvalidMode}, + {os.ModeSetuid | os.ModeSetgid | os.ModeSticky | 0o777, gopathrs.ErrInvalidMode}, + // Proper sticky bit should work. + {os.ModeSticky | 0o777, nil}, + // Regular mode bits. + {0o777, nil}, + {0o711, nil}, + } { + test := test // copy iterator + t.Run(fmt.Sprintf("%s.%.3o", test.mode, test.mode), func(t *testing.T) { + root := t.TempDir() + rootDir, err := os.OpenFile(root, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) + require.NoError(t, err, "open root") + defer rootDir.Close() //nolint:errcheck // test code + + handle, err := gopathrs.MkdirAllHandle(rootDir, "a/b/c", test.mode) + require.ErrorIsf(t, err, test.expectedErr, "mkdirall %.3o (%s)", test.mode, test.mode) + if test.expectedErr == nil { + assert.NotNil(t, handle, "returned handle should be non-nil") + _ = handle.Close() + } else { + assert.Nil(t, handle, "returned handle should be nil") + } + }) + } +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs/open_linux.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs/open_linux.go new file mode 100644 index 0000000..cd9632a --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs/open_linux.go @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package gopathrs + +import ( + "os" +) + +// 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 +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs/openat2_linux.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs/openat2_linux.go new file mode 100644 index 0000000..9c5c268 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs/openat2_linux.go @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package gopathrs + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" + + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd" + "github.com/cyphar/filepath-securejoin/pathrs-lite/procfs" +) + +func openat2(dir fd.Fd, path string, how *unix.OpenHow) (*os.File, error) { + file, err := fd.Openat2(dir, path, how) + if err != nil { + return nil, err + } + // If we are using RESOLVE_IN_ROOT, the name we generated may be wrong. + if how.Resolve&unix.RESOLVE_IN_ROOT == unix.RESOLVE_IN_ROOT { + if actualPath, err := procfs.ProcSelfFdReadlink(file); err == nil { + // TODO: Ideally we would not need to dup the fd, but you cannot + // easily just swap an *os.File with one from the same fd + // (the GC will close the old one, and you cannot clear the + // finaliser easily because it is associated with an internal + // field of *os.File not *os.File itself). + newFile, err := fd.DupWithName(file, actualPath) + if err != nil { + return nil, err + } + _ = file.Close() + file = newFile + } + } + return file, nil +} + +func lookupOpenat2(root fd.Fd, unsafePath string, partial bool) (*os.File, string, error) { + if !partial { + file, err := openat2(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 fd.Fd, 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 := openat2(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++ + } + // 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 := fd.Dup(root) + if err != nil { + return nil, "", err + } + return rootClone, unsafePath, lastError +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/kernelversion/kernel_linux.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/kernelversion/kernel_linux.go new file mode 100644 index 0000000..cb6de41 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/kernelversion/kernel_linux.go @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: BSD-3-Clause + +// Copyright (C) 2022 The Go Authors. All rights reserved. +// Copyright (C) 2025 SUSE LLC. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE.BSD file. + +// The parsing logic is very loosely based on the Go stdlib's +// src/internal/syscall/unix/kernel_version_linux.go but with an API that looks +// a bit like runc's libcontainer/system/kernelversion. +// +// TODO(cyphar): This API has been copied around to a lot of different projects +// (Docker, containerd, runc, and now filepath-securejoin) -- maybe we should +// put it in a separate project? + +// Package kernelversion provides a simple mechanism for checking whether the +// running kernel is at least as new as some baseline kernel version. This is +// often useful when checking for features that would be too complicated to +// test support for (or in cases where we know that some kernel features in +// backport-heavy kernels are broken and need to be avoided). +package kernelversion + +import ( + "bytes" + "errors" + "fmt" + "strconv" + "strings" + + "golang.org/x/sys/unix" + + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat" +) + +// KernelVersion is a numeric representation of the key numerical elements of a +// kernel version (for instance, "4.1.2-default-1" would be represented as +// KernelVersion{4, 1, 2}). +type KernelVersion []uint64 + +func (kver KernelVersion) String() string { + var str strings.Builder + for idx, elem := range kver { + if idx != 0 { + _, _ = str.WriteRune('.') + } + _, _ = str.WriteString(strconv.FormatUint(elem, 10)) + } + return str.String() +} + +var errInvalidKernelVersion = errors.New("invalid kernel version") + +// parseKernelVersion parses a string and creates a KernelVersion based on it. +func parseKernelVersion(kverStr string) (KernelVersion, error) { + kver := make(KernelVersion, 1, 3) + for idx, ch := range kverStr { + if '0' <= ch && ch <= '9' { + v := &kver[len(kver)-1] + *v = (*v * 10) + uint64(ch-'0') + } else { + if idx == 0 || kverStr[idx-1] < '0' || '9' < kverStr[idx-1] { + // "." must be preceded by a digit while in version section + return nil, fmt.Errorf("%w %q: kernel version has dot(s) followed by non-digit in version section", errInvalidKernelVersion, kverStr) + } + if ch != '.' { + break + } + kver = append(kver, 0) + } + } + if len(kver) < 2 { + return nil, fmt.Errorf("%w %q: kernel versions must contain at least two components", errInvalidKernelVersion, kverStr) + } + return kver, nil +} + +// getKernelVersion gets the current kernel version. +var getKernelVersion = gocompat.SyncOnceValues(func() (KernelVersion, error) { + var uts unix.Utsname + if err := unix.Uname(&uts); err != nil { + return nil, err + } + // Remove the \x00 from the release. + release := uts.Release[:] + return parseKernelVersion(string(release[:bytes.IndexByte(release, 0)])) +}) + +// GreaterEqualThan returns true if the the host kernel version is greater than +// or equal to the provided [KernelVersion]. When doing this comparison, any +// non-numerical suffixes of the host kernel version are ignored. +// +// If the number of components provided is not equal to the number of numerical +// components of the host kernel version, any missing components are treated as +// 0. This means that GreaterEqualThan(KernelVersion{4}) will be treated the +// same as GreaterEqualThan(KernelVersion{4, 0, 0, ..., 0, 0}), and that if the +// host kernel version is "4" then GreaterEqualThan(KernelVersion{4, 1}) will +// return false (because the host version will be treated as "4.0"). +func GreaterEqualThan(wantKver KernelVersion) (bool, error) { + hostKver, err := getKernelVersion() + if err != nil { + return false, err + } + + // Pad out the kernel version lengths to match one another. + cmpLen := gocompat.Max2(len(hostKver), len(wantKver)) + hostKver = append(hostKver, make(KernelVersion, cmpLen-len(hostKver))...) + wantKver = append(wantKver, make(KernelVersion, cmpLen-len(wantKver))...) + + for i := 0; i < cmpLen; i++ { + switch gocompat.CmpCompare(hostKver[i], wantKver[i]) { + case -1: + // host < want + return false, nil + case +1: + // host > want + return true, nil + case 0: + continue + } + } + // equal version values + return true, nil +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/kernelversion/kernel_linux_test.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/kernelversion/kernel_linux_test.go new file mode 100644 index 0000000..694e2e7 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/kernelversion/kernel_linux_test.go @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: BSD-3-Clause + +// Copyright (C) 2025 SUSE LLC. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE.BSD file. + +package kernelversion + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetKernelVersion(t *testing.T) { + version, err := getKernelVersion() + require.NoError(t, err) + assert.GreaterOrEqualf(t, len(version), 2, "KernelVersion %#v must have at least 2 elements", version) +} + +func TestParseKernelVersion(t *testing.T) { + for _, test := range []struct { + kverStr string + expected KernelVersion + expectedErr error + }{ + // <2 components + {"", nil, errInvalidKernelVersion}, + {"dummy", nil, errInvalidKernelVersion}, + {"1", nil, errInvalidKernelVersion}, + {"420", nil, errInvalidKernelVersion}, + // >=2 components + {"3.7", KernelVersion{3, 7}, nil}, + {"3.8", KernelVersion{3, 8}, nil}, + {"3.8.0", KernelVersion{3, 8, 0}, nil}, + {"3.8.12", KernelVersion{3, 8, 12}, nil}, + {"3.8.12.10.0.2", KernelVersion{3, 8, 12, 10, 0, 2}, nil}, + {"42.12.1000", KernelVersion{42, 12, 1000}, nil}, + // suffix + {"2.6.16foobar", KernelVersion{2, 6, 16}, nil}, + {"2.6.16f00b4r", KernelVersion{2, 6, 16}, nil}, + {"3.8.16-generic", KernelVersion{3, 8, 16}, nil}, + {"6.12.0-1-default", KernelVersion{6, 12, 0}, nil}, + {"4.9.27-default-foo.12.23", KernelVersion{4, 9, 27}, nil}, + // invalid version section + {"-1.2", nil, errInvalidKernelVersion}, + {"3a", nil, errInvalidKernelVersion}, + {"3.a", nil, errInvalidKernelVersion}, + {"3.4.a", nil, errInvalidKernelVersion}, + {"a", nil, errInvalidKernelVersion}, + {"aa", nil, errInvalidKernelVersion}, + {"a.a", nil, errInvalidKernelVersion}, + {"a.a.a", nil, errInvalidKernelVersion}, + {"-3.1", nil, errInvalidKernelVersion}, + {"-3.", nil, errInvalidKernelVersion}, + {"1.-foo", nil, errInvalidKernelVersion}, + {".1", nil, errInvalidKernelVersion}, + {".1.2", nil, errInvalidKernelVersion}, + } { + test := test // copy iterator + t.Run(test.kverStr, func(t *testing.T) { + kver, err := parseKernelVersion(test.kverStr) + if test.expectedErr != nil { + require.Errorf(t, err, "parseKernelVersion(%q)", test.kverStr) + require.ErrorIsf(t, err, test.expectedErr, "parseKernelVersion(%q)", test.kverStr) + assert.Nilf(t, kver, "parseKernelVersion(%q) returned kver", test.kverStr) + } else { + require.NoErrorf(t, err, "parseKernelVersion(%q)", test.kverStr) + assert.Equal(t, test.expected, kver, "parseKernelVersion(%q) return kver", test.kverStr) + } + }) + } +} + +func TestGreaterEqualThan(t *testing.T) { + hostKver, err := getKernelVersion() + require.NoError(t, err) + + for _, test := range []struct { + name string + wantKver KernelVersion + expected bool + }{ + {"HostVersion", hostKver[:], true}, + {"OlderMajor", KernelVersion{hostKver[0] - 1, hostKver[1]}, true}, + {"OlderMinor", KernelVersion{hostKver[0], hostKver[1] - 1}, true}, + {"NewerMajor", KernelVersion{hostKver[0] + 1, hostKver[1]}, false}, + {"NewerMinor", KernelVersion{hostKver[0], hostKver[1] + 1}, false}, + {"ExtraDot", append(hostKver, 1), false}, + {"ExtraZeros", append(hostKver, make(KernelVersion, 10)...), true}, + } { + test := test // copy iterator + t.Run(fmt.Sprintf("%s:%s", test.name, test.wantKver), func(t *testing.T) { + got, err := GreaterEqualThan(test.wantKver) + require.NoErrorf(t, err, "GreaterEqualThan(%s)", test.wantKver) + assert.Equalf(t, test.expected, got, "GreaterEqualThan(%s)", test.wantKver) + }) + } +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/linux/doc.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/linux/doc.go new file mode 100644 index 0000000..4635714 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/linux/doc.go @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Package linux returns information about what features are supported on the +// running kernel. +package linux diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/linux/mount_linux.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/linux/mount_linux.go new file mode 100644 index 0000000..b29905b --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/linux/mount_linux.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package linux + +import ( + "golang.org/x/sys/unix" + + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/kernelversion" +) + +// HasNewMountAPI returns whether the new fsopen(2) mount API is supported on +// the running kernel. +var HasNewMountAPI = gocompat.SyncOnceValue(func() bool { + // All of the pieces of the new mount API we use (fsopen, fsconfig, + // fsmount, open_tree) were added together in Linux 5.2[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) + + // RHEL 8 has a backport of fsopen(2) that appears to have some very + // difficult to debug performance pathology. As such, it seems prudent to + // simply reject pre-5.2 kernels. + isNotBackport, _ := kernelversion.GreaterEqualThan(kernelversion.KernelVersion{5, 2}) + return isNotBackport +}) diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/linux/openat2_linux.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/linux/openat2_linux.go new file mode 100644 index 0000000..dc5f65c --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/linux/openat2_linux.go @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package linux + +import ( + "golang.org/x/sys/unix" + + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat" +) + +// sawOpenat2Error stores whether we have seen an error from HasOpenat2. This +// is a one-way toggle, so as soon as we see an error we "lock" into that mode. +// We cannot use sync.OnceValue to store the success/fail state once because it +// is possible for the program we are running in to apply a seccomp-bpf filter +// and thus disable openat2 during execution. +var sawOpenat2Error gocompat.Bool + +// HasOpenat2 returns whether openat2(2) is supported on the running kernel. +var HasOpenat2 = func() bool { + if sawOpenat2Error.Load() { + return false + } + + 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 { + sawOpenat2Error.Store(true) // doesn't matter if we race here + return false + } + _ = unix.Close(fd) + return true +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs/procfs_linux.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs/procfs_linux.go new file mode 100644 index 0000000..21e0a62 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs/procfs_linux.go @@ -0,0 +1,544 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Package procfs provides a safe API for operating on /proc on Linux. Note +// that this is the *internal* procfs API, mainy needed due to Go's +// restrictions on cyclic dependencies and its incredibly minimal visibility +// system without making a separate internal/ package. +package procfs + +import ( + "errors" + "fmt" + "io" + "os" + "runtime" + "strconv" + + "golang.org/x/sys/unix" + + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/assert" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/linux" +) + +// 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 +) + +// verifyProcHandle checks that the handle is from a procfs filesystem. +// Contrast this to [verifyProcRoot], which also verifies that the handle is +// the root of a procfs mount. +func verifyProcHandle(procHandle fd.Fd) error { + if statfs, err := fd.Fstatfs(procHandle); err != nil { + return err + } else if statfs.Type != procSuperMagic { + return fmt.Errorf("%w: incorrect procfs root filesystem type 0x%x", errUnsafeProcfs, statfs.Type) + } + return nil +} + +// verifyProcRoot verifies that the handle is the root of a procfs filesystem. +// Contrast this to [verifyProcHandle], which only verifies if the handle is +// some file on procfs (regardless of what file it is). +func verifyProcRoot(procRoot fd.Fd) error { + if err := verifyProcHandle(procRoot); err != nil { + return err + } + if stat, err := fd.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 +} + +type procfsFeatures struct { + // hasSubsetPid was added in Linux 5.8, along with hidepid=ptraceable (and + // string-based hidepid= values). Before this patchset, it was not really + // safe to try to modify procfs superblock flags because the superblock was + // shared -- so if this feature is not available, **you should not set any + // superblock flags**. + // + // 6814ef2d992a ("proc: add option to mount only a pids subset") + // fa10fed30f25 ("proc: allow to mount many instances of proc in one pid namespace") + // 24a71ce5c47f ("proc: instantiate only pids that we can ptrace on 'hidepid=4' mount option") + // 1c6c4d112e81 ("proc: use human-readable values for hidepid") + // 9ff7258575d5 ("Merge branch 'proc-linus' of git://git.kernel.org/pub/scm/linux/kernel/git/ebiederm/user-namespace") + hasSubsetPid bool +} + +var getProcfsFeatures = gocompat.SyncOnceValue(func() procfsFeatures { + if !linux.HasNewMountAPI() { + return procfsFeatures{} + } + procfsCtx, err := fd.Fsopen("proc", unix.FSOPEN_CLOEXEC) + if err != nil { + return procfsFeatures{} + } + defer procfsCtx.Close() //nolint:errcheck // close failures aren't critical here + + return procfsFeatures{ + hasSubsetPid: unix.FsconfigSetString(int(procfsCtx.Fd()), "subset", "pid") == nil, + } +}) + +func newPrivateProcMount(subset bool) (_ *Handle, Err error) { + procfsCtx, err := fd.Fsopen("proc", unix.FSOPEN_CLOEXEC) + if err != nil { + return nil, err + } + defer procfsCtx.Close() //nolint:errcheck // close failures aren't critical here + + if subset && getProcfsFeatures().hasSubsetPid { + // 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) + } + // TODO: Output any information from the fscontext log to debug logs. + procRoot, err := fd.Fsmount(procfsCtx, unix.FSMOUNT_CLOEXEC, unix.MS_NODEV|unix.MS_NOEXEC|unix.MS_NOSUID) + if err != nil { + return nil, err + } + defer func() { + if Err != nil { + _ = procRoot.Close() + } + }() + return newHandle(procRoot) +} + +func clonePrivateProcMount() (_ *Handle, 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. + procRoot, err := fd.OpenTree(nil, "/proc", unix.OPEN_TREE_CLONE) + if err != nil || hookForcePrivateProcRootOpenTreeAtRecursive(procRoot) { + procRoot, err = fd.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 { + _ = procRoot.Close() + } + }() + return newHandle(procRoot) +} + +func privateProcRoot(subset bool) (*Handle, error) { + if !linux.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(subset) + if err != nil || hookForcePrivateProcRootOpenTree(procRoot) { + // Try to clone /proc then... + procRoot, err = clonePrivateProcMount() + } + return procRoot, err +} + +func unsafeHostProcRoot() (_ *Handle, 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() + } + }() + return newHandle(procRoot) +} + +// Handle is a wrapper around an *os.File handle to "/proc", which can be used +// to do further procfs-related operations in a safe way. +type Handle struct { + Inner fd.Fd + // Does this handle have subset=pid set? + isSubset bool +} + +func newHandle(procRoot fd.Fd) (*Handle, error) { + if err := verifyProcRoot(procRoot); err != nil { + // This is only used in methods that + _ = procRoot.Close() + return nil, err + } + proc := &Handle{Inner: procRoot} + // With subset=pid we can be sure that /proc/uptime will not exist. + if err := fd.Faccessat(proc.Inner, "uptime", unix.F_OK, unix.AT_SYMLINK_NOFOLLOW); err != nil { + proc.isSubset = errors.Is(err, os.ErrNotExist) + } + return proc, nil +} + +// Close closes the underlying file for the Handle. +func (proc *Handle) Close() error { return proc.Inner.Close() } + +var getCachedProcRoot = gocompat.SyncOnceValue(func() *Handle { + procRoot, err := getProcRoot(true) + if err != nil { + return nil // just don't cache if we see an error + } + if !procRoot.isSubset { + return nil // we only cache verified subset=pid handles + } + + // Disarm (*Handle).Close() to stop someone from accidentally closing + // the global handle. + procRoot.Inner = fd.NopCloser(procRoot.Inner) + return procRoot +}) + +// OpenProcRoot tries to open a "safer" handle to "/proc". +func OpenProcRoot() (*Handle, error) { + if proc := getCachedProcRoot(); proc != nil { + return proc, nil + } + return getProcRoot(true) +} + +// OpenUnsafeProcRoot opens a handle to "/proc" without any overmounts or +// masked paths (but also without "subset=pid"). +func OpenUnsafeProcRoot() (*Handle, error) { return getProcRoot(false) } + +func getProcRoot(subset bool) (*Handle, error) { + proc, err := privateProcRoot(subset) + 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. + proc, err = unsafeHostProcRoot() + } + return proc, err +} + +var hasProcThreadSelf = gocompat.SyncOnceValue(func() bool { + return unix.Access("/proc/thread-self/", unix.F_OK) == nil +}) + +var errUnsafeProcfs = errors.New("unsafe procfs detected") + +// lookup is a very minimal wrapper around [procfsLookupInRoot] which is +// intended to be called from the external API. +func (proc *Handle) lookup(subpath string) (*os.File, error) { + handle, err := procfsLookupInRoot(proc.Inner, subpath) + if err != nil { + return nil, err + } + return handle, nil +} + +// procfsBase is an enum indicating the prefix of a subpath in operations +// involving [Handle]s. +type procfsBase string + +const ( + // ProcRoot refers to the root of the procfs (i.e., "/proc/"). + ProcRoot procfsBase = "/proc" + // ProcSelf refers to the current process' subdirectory (i.e., + // "/proc/self/"). + ProcSelf procfsBase = "/proc/self" + // ProcThreadSelf refers to the current thread's subdirectory (i.e., + // "/proc/thread-self/"). In multi-threaded programs (i.e., all Go + // programs) where one thread has a different CLONE_FS, it is possible for + // "/proc/self" to point the wrong thread and so "/proc/thread-self" may be + // necessary. Note that on pre-3.17 kernels, "/proc/thread-self" doesn't + // exist and so a fallback will be used in that case. + ProcThreadSelf procfsBase = "/proc/thread-self" + // TODO: Switch to an interface setup so we can have a more type-safe + // version of ProcPid and remove the need to worry about invalid string + // values. +) + +// prefix returns a prefix that can be used with the given [Handle]. +func (base procfsBase) prefix(proc *Handle) (string, error) { + switch base { + case ProcRoot: + return ".", nil + case ProcSelf: + return "self", nil + case ProcThreadSelf: + 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 := fd.Faccessat(proc.Inner, threadSelf, unix.F_OK, 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" + } + } + return threadSelf, nil + } + return "", fmt.Errorf("invalid procfs base %q", base) +} + +// ProcThreadSelfCloser is a callback that needs to be called when you are done +// operating on an [os.File] fetched using [ProcThreadSelf]. +// +// [os.File]: https://pkg.go.dev/os#File +type ProcThreadSelfCloser func() + +// open is the core lookup operation for [Handle]. It returns a handle to +// "/proc//". If the returned [ProcThreadSelfCloser] is non-nil, +// you should call it after you are done interacting with the returned handle. +// +// In general you should use prefer to use the other helpers, as they remove +// the need to interact with [procfsBase] and do not return a nil +// [ProcThreadSelfCloser] for [procfsBase] values other than [ProcThreadSelf] +// where it is necessary. +func (proc *Handle) open(base procfsBase, subpath string) (_ *os.File, closer ProcThreadSelfCloser, Err error) { + prefix, err := base.prefix(proc) + if err != nil { + return nil, nil, err + } + subpath = prefix + "/" + subpath + + switch base { + case ProcRoot: + file, err := proc.lookup(subpath) + if errors.Is(err, os.ErrNotExist) { + // The Handle handle in use might be a subset=pid one, which will + // result in spurious errors. In this case, just open a temporary + // unmasked procfs handle for this operation. + proc, err2 := OpenUnsafeProcRoot() // !subset=pid + if err2 != nil { + return nil, nil, err + } + defer proc.Close() //nolint:errcheck // close failures aren't critical here + + file, err = proc.lookup(subpath) + } + return file, nil, err + + case ProcSelf: + file, err := proc.lookup(subpath) + return file, nil, err + + case ProcThreadSelf: + // 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() + closer = nil + } + }() + + file, err := proc.lookup(subpath) + return file, runtime.UnlockOSThread, err + } + // should never be reached + return nil, nil, fmt.Errorf("[internal error] invalid procfs base %q", base) +} + +// OpenThreadSelf 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. +func (proc *Handle) OpenThreadSelf(subpath string) (_ *os.File, _ ProcThreadSelfCloser, Err error) { + return proc.open(ProcThreadSelf, subpath) +} + +// OpenSelf returns a handle to /proc/self/. +func (proc *Handle) OpenSelf(subpath string) (*os.File, error) { + file, closer, err := proc.open(ProcSelf, subpath) + assert.Assert(closer == nil, "closer for ProcSelf must be nil") + return file, err +} + +// OpenRoot returns a handle to /proc/. +func (proc *Handle) OpenRoot(subpath string) (*os.File, error) { + file, closer, err := proc.open(ProcRoot, subpath) + assert.Assert(closer == nil, "closer for ProcRoot must be nil") + return file, err +} + +// OpenPid returns a handle to /proc/$pid/ (pid can be a pid or tid). +// This is mainly intended for usage when operating on other processes. +func (proc *Handle) OpenPid(pid int, subpath string) (*os.File, error) { + return proc.OpenRoot(strconv.Itoa(pid) + "/" + subpath) +} + +// checkSubpathOvermount checks if the dirfd and path combination is on the +// same mount as the given root. +func checkSubpathOvermount(root, dir fd.Fd, path string) error { + // Get the mntID of our procfs handle. + expectedMountID, err := fd.GetMountID(root, "") + if err != nil { + return fmt.Errorf("get root mount id: %w", err) + } + // Get the mntID of the target magic-link. + gotMountID, err := fd.GetMountID(dir, path) + if err != nil { + return fmt.Errorf("get subpath mount id: %w", 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: subpath %s/%s has an overmount obscuring the real path (mount ids do not match %d != %d)", + errUnsafeProcfs, dir.Name(), path, expectedMountID, gotMountID) + } + return nil +} + +// Readlink performs a readlink operation on "/proc//" in a way +// that should be free from race attacks. This is most commonly used to get the +// real path of a file by looking at "/proc/self/fd/$n", with the same safety +// protections as [Open] (as well as some additional checks against +// overmounts). +func (proc *Handle) Readlink(base procfsBase, subpath string) (string, error) { + link, closer, err := proc.open(base, subpath) + if closer != nil { + defer closer() + } + if err != nil { + return "", fmt.Errorf("get safe %s/%s handle: %w", base, subpath, err) + } + defer link.Close() //nolint:errcheck // close failures aren't critical here + + // Try to detect if there is a mount on top of the magic-link. 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 := checkSubpathOvermount(proc.Inner, link, ""); err != nil { + return "", fmt.Errorf("check safety of %s/%s magiclink: %w", base, subpath, err) + } + + // readlinkat implies AT_EMPTY_PATH since Linux 2.6.39. See Linux commit + // 65cfc6722361 ("readlinkat(), fchownat() and fstatat() with empty + // relative pathnames"). + return fd.Readlinkat(link, "") +} + +// ProcSelfFdReadlink gets the real path of the given file by looking at +// readlink(/proc/thread-self/fd/$n). +// +// This is just a wrapper around [Handle.Readlink]. +func ProcSelfFdReadlink(fd fd.Fd) (string, error) { + procRoot, err := OpenProcRoot() // subset=pid + if err != nil { + return "", err + } + defer procRoot.Close() //nolint:errcheck // close failures aren't critical here + + fdPath := "fd/" + strconv.Itoa(int(fd.Fd())) + return procRoot.Readlink(ProcThreadSelf, fdPath) +} + +// CheckProcSelfFdPath returns whether the given file handle matches the +// expected path. (This is inherently racy.) +func CheckProcSelfFdPath(path string, file fd.Fd) error { + if err := fd.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", internal.ErrPossibleBreakout, actualPath, path) + } + return nil +} + +// ReopenFd takes an existing file descriptor and "re-opens" it through +// /proc/thread-self/fd/. This allows for O_PATH file descriptors to be +// upgraded to regular file descriptors, as well as changing the open mode of a +// regular file descriptor. Some filesystems have unique handling of open(2) +// which make this incredibly useful (such as /dev/ptmx). +func ReopenFd(handle fd.Fd, flags int) (*os.File, error) { + procRoot, err := OpenProcRoot() // subset=pid + if err != nil { + return nil, err + } + defer procRoot.Close() //nolint:errcheck // close failures aren't critical here + + // 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 := procRoot.OpenThreadSelf("fd/") + if err != nil { + return nil, fmt.Errorf("get safe /proc/thread-self/fd handle: %w", err) + } + defer procFdDir.Close() //nolint:errcheck // close failures aren't critical here + 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 := checkSubpathOvermount(procRoot.Inner, 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 fd.Openat, 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 +} + +// 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(_ io.Closer) bool { return false } diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs/procfs_linux_test.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs/procfs_linux_test.go new file mode 100644 index 0000000..b46a0e3 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs/procfs_linux_test.go @@ -0,0 +1,758 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package procfs + +import ( + "errors" + "fmt" + "os" + "path" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" + + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/linux" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/testutils" +) + +func newPrivateProcMountSubset() (*Handle, error) { return newPrivateProcMount(true) } +func newPrivateProcMountUnmasked() (*Handle, error) { return newPrivateProcMount(false) } + +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() //nolint:errcheck // test code + 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() //nolint:errcheck // test code + 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) { + testutils.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 *Handle, subpath string, expectErr bool) { + handle, closer, err := procRoot.OpenThreadSelf(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() (*Handle, 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 + // checkSubpathOvermount. + {"/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() //nolint:errcheck // test code + + // For both tmpfs and procfs overmounts, we should catch them (with or + // without openat2, thanks to procfsLookupInRoot). + testProcThreadSelf(t, procRoot, "fdinfo", expectOvermounts) + testProcThreadSelf(t, procRoot, "attr/current", expectOvermounts) + + // For magic-links we expect to detect overmounts if there are any. + symlinkOvermountErr := errUnsafeProcfs + if !expectOvermounts { + symlinkOvermountErr = nil + } + + procSelf, closer, err := procRoot.OpenThreadSelf(".") + require.NoError(t, err) + defer procSelf.Close() //nolint:errcheck // test code + defer closer() + + // Open these paths directly to emulate a non-openat2 handle that + // didn't detect a bind-mount to check that checkSubpathOvermount works + // properly for AT_EMPTY_PATH checks as well. + procCwd, err := fd.Openat(procSelf, "cwd", unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + require.NoError(t, err) + defer procCwd.Close() //nolint:errcheck // test code + procExe, err := fd.Openat(procSelf, "exe", unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + require.NoError(t, err) + defer procExe.Close() //nolint:errcheck // test code + + // no overmount + err = checkSubpathOvermount(procRoot.Inner, procCwd, "") + assert.NoError(t, err, "checking /proc/self/cwd with no overmount should succeed") //nolint:testifylint // this is an isolated operation so we can continue despite an error + err = checkSubpathOvermount(procRoot.Inner, procSelf, "cwd") + assert.NoError(t, err, "checking /proc/self/cwd with no overmount should succeed") //nolint:testifylint // this is an isolated operation so we can continue despite an error + // basic overmount + err = checkSubpathOvermount(procRoot.Inner, procExe, "") + assert.ErrorIs(t, err, symlinkOvermountErr, "unexpected /proc/self/exe overmount result") //nolint:testifylint // this is an isolated operation so we can continue despite an error + err = checkSubpathOvermount(procRoot.Inner, procSelf, "exe") + assert.ErrorIs(t, err, symlinkOvermountErr, "unexpected /proc/self/exe overmount result") //nolint:testifylint // this is an isolated operation so we can continue despite an error + + // fd no overmount + _, err = procRoot.Readlink(ProcThreadSelf, "fd/1") + assert.NoError(t, err, "checking /proc/self/fd/1 with no overmount should succeed") //nolint:testifylint // this is an isolated operation so we can continue despite an error + // fd overmount + link, err := procRoot.Readlink(ProcThreadSelf, "fd/0") + assert.ErrorIs(t, err, symlinkOvermountErr, "unexpected /proc/self/fd/0 overmount result: got link %q", link) //nolint:testifylint // this is an isolated operation so we can continue despite an error + }) +} + +func tRunWrapper(t *testing.T) testutils.TRunFunc { + return func(name string, doFn testutils.TDoFunc) { + t.Run(name, func(t *testing.T) { + doFn(t) + }) + } +} + +func TestProcOvermountSubdir_unsafeHostProcRoot(t *testing.T) { + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + // If we use the host /proc directly, we should see overmounts. + testProcOvermountSubdir(t, unsafeHostProcRoot, true) + }) +} + +func TestProcOvermountSubdir_newPrivateProcMountSubset(t *testing.T) { + if !linux.HasNewMountAPI() { + t.Skip("test requires fsopen/open_tree support") + } + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + // If we create our own procfs, the overmounts shouldn't appear. + testProcOvermountSubdir(t, newPrivateProcMountSubset, false) + }) +} + +func TestProcOvermountSubdir_newPrivateProcMountUnmasked(t *testing.T) { + if !linux.HasNewMountAPI() { + t.Skip("test requires fsopen/open_tree support") + } + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + // If we create our own procfs, the overmounts shouldn't appear. + testProcOvermountSubdir(t, newPrivateProcMountUnmasked, false) + }) +} + +func TestProcOvermountSubdir_clonePrivateProcMount(t *testing.T) { + if !linux.HasNewMountAPI() { + t.Skip("test requires fsopen/open_tree support") + } + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + // 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_OpenProcRoot(t *testing.T) { + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + // 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... + procRootFn := func() (*Handle, error) { return getProcRoot(true) } + testProcOvermountSubdir(t, procRootFn, !linux.HasNewMountAPI()) + }) +} + +func TestProcOvermountSubdir_OpenUnsafeProcRoot(t *testing.T) { + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + // 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, OpenUnsafeProcRoot, !linux.HasNewMountAPI()) + }) +} + +func TestProcOvermountSubdir_getProcRootSubset_Mocked(t *testing.T) { + if !linux.HasNewMountAPI() { + t.Skip("test requires fsopen/open_tree support") + } + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + testForceGetProcRoot(t, func(t *testing.T, expectOvermounts bool) { + procRootFn := func() (*Handle, error) { return getProcRoot(true) } + testProcOvermountSubdir(t, procRootFn, expectOvermounts) + }) + }) +} + +// isFsopenRoot returns whether the internal procfs handle is an fsopen root. +func isFsopenRoot(t *testing.T) bool { + procRoot, err := OpenUnsafeProcRoot() // !subset=pid + require.NoError(t, err) + return procRoot.Inner.Name() == "fsmount:fscontext:proc" +} + +// Because of the introduction of protections against /proc overmounts, +// ProcThreadSelf will not be called in actual tests unless we have a basic +// test here. +func TestProcThreadSelf(t *testing.T) { + proc, err := OpenProcRoot() + require.NoError(t, err) + + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + t.Run("stat", func(t *testing.T) { + handle, closer, err := proc.OpenThreadSelf("stat") + require.NoError(t, err, "ProcThreadSelf(stat)") + require.NotNil(t, handle, "ProcThreadSelf(stat) handle") + require.NotNil(t, closer, "ProcThreadSelf(stat) closer") + defer closer() + defer handle.Close() //nolint:errcheck // test code + + realPath, err := ProcSelfFdReadlink(handle) + require.NoError(t, err) + wantPath := fmt.Sprintf("/%d/task/%d/stat", os.Getpid(), unix.Gettid()) + if !isFsopenRoot(t) { + // The /proc prefix is only present when not using fsopen. + wantPath = "/proc" + wantPath + } + assert.Equal(t, wantPath, realPath, "final handle path") + }) + + t.Run("abspath", func(t *testing.T) { + handle, closer, err := proc.OpenThreadSelf("/stat") + require.NoError(t, err, "ProcThreadSelf(/stat)") + require.NotNil(t, handle, "ProcThreadSelf(/stat) handle") + require.NotNil(t, closer, "ProcThreadSelf(/stat) closer") + defer closer() + defer handle.Close() //nolint:errcheck // test code + + realPath, err := ProcSelfFdReadlink(handle) + require.NoError(t, err) + wantPath := fmt.Sprintf("/%d/task/%d/stat", os.Getpid(), unix.Gettid()) + if !isFsopenRoot(t) { + // The /proc prefix is only present when not using fsopen. + wantPath = "/proc" + wantPath + } + assert.Equal(t, wantPath, realPath, "final handle path") + }) + + t.Run("wacky-abspath", func(t *testing.T) { + handle, closer, err := proc.OpenThreadSelf("////./////stat") + require.NoError(t, err, "ProcThreadSelf(////./////stat)") + require.NotNil(t, handle, "ProcThreadSelf(////./////stat) handle") + require.NotNil(t, closer, "ProcThreadSelf(////./////stat) closer") + defer closer() + defer handle.Close() //nolint:errcheck // test code + + realPath, err := ProcSelfFdReadlink(handle) + require.NoError(t, err) + wantPath := fmt.Sprintf("/%d/task/%d/stat", os.Getpid(), unix.Gettid()) + if !isFsopenRoot(t) { + // The /proc prefix is only present when not using fsopen. + wantPath = "/proc" + wantPath + } + assert.Equal(t, wantPath, realPath, "final handle path") + }) + + t.Run("dotdot", func(t *testing.T) { + handle, closer, err := proc.OpenThreadSelf("../../../../../../../../..") + require.Error(t, err, "ProcThreadSelf(../...)") + require.Nil(t, handle, "ProcThreadSelf(../...) handle") + require.Nil(t, closer, "ProcThreadSelf(../...) closer") + }) + + t.Run("wacky-dotdot", func(t *testing.T) { + handle, closer, err := proc.OpenThreadSelf("/../../../../../../../../..") + require.Error(t, err, "ProcThreadSelf(/../...)") + require.Nil(t, handle, "ProcThreadSelf(/../...) handle") + require.Nil(t, closer, "ProcThreadSelf(/../...) closer") + }) + }) +} + +func TestProcSelf(t *testing.T) { + proc, err := OpenProcRoot() + require.NoError(t, err) + + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + t.Run("stat", func(t *testing.T) { + handle, err := proc.OpenSelf("stat") + require.NoError(t, err, "ProcSelf(stat)") + require.NotNil(t, handle, "ProcSelf(stat) handle") + defer handle.Close() //nolint:errcheck // test code + + realPath, err := ProcSelfFdReadlink(handle) + require.NoError(t, err) + wantPath := fmt.Sprintf("/%d/stat", os.Getpid()) + if !isFsopenRoot(t) { + // The /proc prefix is only present when not using fsopen. + wantPath = "/proc" + wantPath + } + assert.Equal(t, wantPath, realPath, "final handle path") + }) + + t.Run("abspath", func(t *testing.T) { + handle, err := proc.OpenSelf("/stat") + require.NoError(t, err, "ProcSelf(/stat)") + require.NotNil(t, handle, "ProcSelf(/stat) handle") + defer handle.Close() //nolint:errcheck // test code + + realPath, err := ProcSelfFdReadlink(handle) + require.NoError(t, err) + wantPath := fmt.Sprintf("/%d/stat", os.Getpid()) + if !isFsopenRoot(t) { + // The /proc prefix is only present when not using fsopen. + wantPath = "/proc" + wantPath + } + assert.Equal(t, wantPath, realPath, "final handle path") + }) + + t.Run("wacky-abspath", func(t *testing.T) { + handle, err := proc.OpenSelf("////./////stat") + require.NoError(t, err, "ProcSelf(////./////stat)") + require.NotNil(t, handle, "ProcSelf(////./////stat) handle") + defer handle.Close() //nolint:errcheck // test code + + realPath, err := ProcSelfFdReadlink(handle) + require.NoError(t, err) + wantPath := fmt.Sprintf("/%d/stat", os.Getpid()) + if !isFsopenRoot(t) { + // The /proc prefix is only present when not using fsopen. + wantPath = "/proc" + wantPath + } + assert.Equal(t, wantPath, realPath, "final handle path") + }) + + t.Run("dotdot", func(t *testing.T) { + handle, err := proc.OpenSelf("../../../../../../../../..") + require.Error(t, err, "ProcSelf(../...)") + require.Nil(t, handle, "ProcSelf(../...) handle") + }) + + t.Run("wacky-dotdot", func(t *testing.T) { + handle, err := proc.OpenSelf("/../../../../../../../../..") + require.Error(t, err, "ProcSelf(/../...)") + require.Nil(t, handle, "ProcSelf(/../...) handle") + }) + }) +} + +func TestProcPid(t *testing.T) { + proc, err := OpenProcRoot() + require.NoError(t, err) + + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + t.Run("pid1-stat", func(t *testing.T) { + handle, err := proc.OpenPid(1, "stat") + require.NoError(t, err, "ProcPid(1, stat)") + require.NotNil(t, handle, "ProcPid(1, stat) handle") + + realPath, err := ProcSelfFdReadlink(handle) + require.NoError(t, err) + wantPath := "/1/stat" + if !isFsopenRoot(t) { + // The /proc prefix is only present when not using fsopen. + wantPath = "/proc" + wantPath + } + assert.Equal(t, wantPath, realPath, "final handle path") + }) + + t.Run("pid1-stat-abspath", func(t *testing.T) { + handle, err := proc.OpenPid(1, "/stat") + require.NoError(t, err, "ProcPid(1, /stat)") + require.NotNil(t, handle, "ProcPid(1, /stat) handle") + + realPath, err := ProcSelfFdReadlink(handle) + require.NoError(t, err) + wantPath := "/1/stat" + if !isFsopenRoot(t) { + // The /proc prefix is only present when not using fsopen. + wantPath = "/proc" + wantPath + } + assert.Equal(t, wantPath, realPath, "final handle path") + }) + + t.Run("pid1-stat-wacky-abspath", func(t *testing.T) { + handle, err := proc.OpenPid(1, "////.////stat") + require.NoError(t, err, "ProcPid(1, ////.////stat)") + require.NotNil(t, handle, "ProcPid(1, ////.////stat) handle") + + realPath, err := ProcSelfFdReadlink(handle) + require.NoError(t, err) + wantPath := "/1/stat" + if !isFsopenRoot(t) { + // The /proc prefix is only present when not using fsopen. + wantPath = "/proc" + wantPath + } + assert.Equal(t, wantPath, realPath, "final handle path") + }) + + t.Run("dotdot", func(t *testing.T) { + handle, err := proc.OpenPid(1, "../../../../../../../../..") + require.Error(t, err, "ProcPid(1, ../...)") + require.Nil(t, handle, "ProcPid(1, ../...) handle") + }) + + t.Run("wacky-dotdot", func(t *testing.T) { + handle, err := proc.OpenPid(1, "/../../../../../../../../..") + require.Error(t, err, "ProcPid(1, /../...)") + require.Nil(t, handle, "ProcPid(1, /../...) handle") + }) + }) +} + +func TestProcRoot(t *testing.T) { + for _, test := range []struct { + name string + procRootFn procRootFunc + }{ + {"OpenProcRoot", OpenProcRoot}, + {"OpenUnsafeProcRoot", OpenUnsafeProcRoot}, + } { + test := test // copy iterator + t.Run(test.name, func(t *testing.T) { + proc, err := test.procRootFn() + require.NoError(t, err) + + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + t.Run("sysctl", func(t *testing.T) { + handle, err := proc.OpenRoot("sys/kernel/version") + require.NoError(t, err, "ProcRoot(sys/kernel/version)") + require.NotNil(t, handle, "ProcPid(sys/kernel/version) handle") + + realPath, err := ProcSelfFdReadlink(handle) + require.NoError(t, err) + wantPath := "/sys/kernel/version" + if !isFsopenRoot(t) { + // The /proc prefix is only present when not using fsopen. + wantPath = "/proc" + wantPath + } + assert.Equal(t, wantPath, realPath, "final handle path") + }) + }) + }) + } +} + +func canFsOpen() bool { + f, err := fd.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() //nolint:errcheck // test code + } + if privateProcMount { + assert.NoError(t, err, "get proc handle should succeed") //nolint:testifylint + assert.NoError(t, verifyProcRoot(procRoot.Inner), "verify private proc mount should succeed") //nolint:testifylint + } else { + if !assert.ErrorIs(t, err, errUnsafeProcfs, "get proc handle should fail") { //nolint:testifylint + t.Logf("procRootFn() = %v, %v", procRoot, err) + } + } + }) + } + }) +} + +func TestProcOvermount_unsafeHostProcRoot(t *testing.T) { + testProcOvermount(t, unsafeHostProcRoot, false) +} + +func TestProcOvermount_clonePrivateProcMount(t *testing.T) { + if !linux.HasNewMountAPI() { + t.Skip("test requires open_tree support") + } + testProcOvermount(t, clonePrivateProcMount, false) +} + +func TestProcOvermount_newPrivateProcMountSubset(t *testing.T) { + if !linux.HasNewMountAPI() || !canFsOpen() { + t.Skip("test requires fsopen support") + } + testProcOvermount(t, newPrivateProcMountSubset, true) +} + +func TestProcOvermount_newPrivateProcMountUnmasked(t *testing.T) { + if !linux.HasNewMountAPI() || !canFsOpen() { + t.Skip("test requires fsopen support") + } + testProcOvermount(t, newPrivateProcMountUnmasked, true) +} + +func TestProcOvermount_OpenProcRoot(t *testing.T) { + privateProcMount := canFsOpen() && !testingForcePrivateProcRootOpenTree(nil) + procRootFn := func() (*Handle, error) { return getProcRoot(true) } + testProcOvermount(t, procRootFn, privateProcMount) +} + +func TestProcOvermount_OpenProcRoot_Mocked(t *testing.T) { + if !linux.HasNewMountAPI() { + t.Skip("test requires fsopen/open_tree support") + } + testForceGetProcRoot(t, func(t *testing.T, _ bool) { + privateProcMount := canFsOpen() && !testingForcePrivateProcRootOpenTree(nil) + procRootFn := func() (*Handle, error) { return getProcRoot(true) } + testProcOvermount(t, procRootFn, 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) + require.NoError(t, err) + defer handle.Close() //nolint:errcheck // test code + + // The check should fail if we expect the symlink path. + err = CheckProcSelfFdPath(symPath, handle) + assert.ErrorIs(t, err, internal.ErrPossibleBreakout, "CheckProcSelfFdPath should fail for wrong path") //nolint:testifylint // this is an isolated operation so we can continue despite an error + + // The check should fail if we expect the symlink path. + err = CheckProcSelfFdPath(filePath, handle) + assert.NoError(t, err) //nolint:testifylint // this is an isolated operation so we can continue despite an error + }) +} + +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() //nolint:errcheck // test code + + // The path still exists. + err = CheckProcSelfFdPath(fullPath, handle) + assert.NoError(t, err, "CheckProcSelfFdPath should succeed with regular file") //nolint:testifylint // this is an isolated operation so we can continue despite an error + + // Delete the path. + err = os.Remove(fullPath) + require.NoError(t, err) + + // The check should fail now. + err = CheckProcSelfFdPath(fullPath, handle) + assert.ErrorIs(t, err, internal.ErrDeletedInode, "CheckProcSelfFdPath should fail after deletion") //nolint:testifylint // this is an isolated operation so we can continue despite an error + + // The check should fail even if the expected path ends with " (deleted)". + err = CheckProcSelfFdPath(fullPath+" (deleted)", handle) + assert.ErrorIs(t, err, internal.ErrDeletedInode, "CheckProcSelfFdPath should fail after deletion even with (deleted) suffix") //nolint:testifylint // this is an isolated operation so we can continue despite an error + }) +} + +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() //nolint:errcheck // test code + + // The path still exists. + err = CheckProcSelfFdPath(fullPath, handle) + assert.NoError(t, err, "CheckProcSelfFdPath should succeed with regular directory") //nolint:testifylint // this is an isolated operation so we can continue despite an error + + // Delete the path. + err = os.Remove(fullPath) + require.NoError(t, err) + + // The check should fail now. + err = CheckProcSelfFdPath(fullPath, handle) + assert.ErrorIs(t, err, internal.ErrInvalidDirectory, "CheckProcSelfFdPath should fail after deletion") //nolint:testifylint // this is an isolated operation so we can continue despite an error + + // The check should fail even if the expected path ends with " (deleted)". + err = CheckProcSelfFdPath(fullPath+" (deleted)", handle) + assert.ErrorIs(t, err, internal.ErrInvalidDirectory, "CheckProcSelfFdPath should fail after deletion even with (deleted) suffix") //nolint:testifylint // this is an isolated operation so we can continue despite an error + }) +} + +func testVerifyProcRoot(t *testing.T, procRoot string, expectedHandleErr, expectedRootErr error, errString string) { + fakeProcRoot, err := os.OpenFile(procRoot, unix.O_PATH|unix.O_CLOEXEC, 0) + require.NoError(t, err) + defer fakeProcRoot.Close() //nolint:errcheck // test code + + err = verifyProcRoot(fakeProcRoot) + require.ErrorIsf(t, err, expectedRootErr, "verifyProcRoot(%s)", procRoot) + if expectedRootErr != nil { + require.ErrorContainsf(t, err, errString, "verifyProcRoot(%s)", procRoot) + } + + err = verifyProcHandle(fakeProcRoot) + require.ErrorIsf(t, err, expectedHandleErr, "verifyProcHandle(%s)", procRoot) + if expectedHandleErr != nil { + require.ErrorContainsf(t, err, errString, "verifyProcHandle(%s)", procRoot) + } +} + +func TestVerifyProcRoot_Regular(t *testing.T) { + testForceProcThreadSelf(t, func(t *testing.T) { + testVerifyProcRoot(t, "/proc", nil, nil, "") + }) +} + +func TestVerifyProcRoot_ProcNonRoot(t *testing.T) { + testForceProcThreadSelf(t, func(t *testing.T) { + testVerifyProcRoot(t, "/proc/self", nil, errUnsafeProcfs, "incorrect procfs root inode number") + testVerifyProcRoot(t, "/proc/mounts", nil, errUnsafeProcfs, "incorrect procfs root inode number") + testVerifyProcRoot(t, "/proc/stat", nil, errUnsafeProcfs, "incorrect procfs root inode number") + }) +} + +func TestVerifyProcRoot_NotProc(t *testing.T) { + testForceProcThreadSelf(t, func(t *testing.T) { + testVerifyProcRoot(t, "/", errUnsafeProcfs, errUnsafeProcfs, "incorrect procfs root filesystem type") + testVerifyProcRoot(t, ".", errUnsafeProcfs, errUnsafeProcfs, "incorrect procfs root filesystem type") + testVerifyProcRoot(t, t.TempDir(), errUnsafeProcfs, 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") +} + +func TestCachedProcRoot_Close(t *testing.T) { + proc := getCachedProcRoot() + if proc == nil { + t.Skip("cannot get proc handle") + } + + f, err := proc.OpenSelf(".") + require.NoError(t, err) + _ = f.Close() + + for i := 0; i < 4; i++ { + require.NoError(t, proc.Close(), "closing cached Handle") + } + + f2, err := proc.OpenSelf(".") + require.NoError(t, err) + _ = f2.Close() +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs/procfs_lookup_linux.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs/procfs_lookup_linux.go new file mode 100644 index 0000000..1ad1f18 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs/procfs_lookup_linux.go @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// This code is adapted to be a minimal version of the libpathrs proc resolver +// . +// As we only need O_PATH|O_NOFOLLOW support, this is not too much to port. + +package procfs + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" + + "github.com/cyphar/filepath-securejoin/internal/consts" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gocompat" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/linux" +) + +// procfsLookupInRoot is a stripped down version of completeLookupInRoot, +// entirely designed to support the very small set of features necessary to +// make procfs handling work. Unlike completeLookupInRoot, we always have +// O_PATH|O_NOFOLLOW behaviour for trailing symlinks. +// +// The main restrictions are: +// +// - ".." is not supported (as it requires either os.Root-style replays, +// which is more bug-prone; or procfs verification, which is not possible +// due to re-entrancy issues). +// - Absolute symlinks for the same reason (and all absolute symlinks in +// procfs are magic-links, which we want to skip anyway). +// - If statx is supported (checkSymlinkOvermount), any mount-point crossings +// (which is the main attack of concern against /proc). +// - Partial lookups are not supported, so the symlink stack is not needed. +// - Trailing slash special handling is not necessary in most cases (if we +// operating on procfs, it's usually with programmer-controlled strings +// that will then be re-opened), so we skip it since whatever re-opens it +// can deal with it. It's a creature comfort anyway. +// +// If the system supports openat2(), this is implemented using equivalent flags +// (RESOLVE_BENEATH | RESOLVE_NO_XDEV | RESOLVE_NO_MAGICLINKS). +func procfsLookupInRoot(procRoot fd.Fd, unsafePath string) (Handle *os.File, _ error) { + unsafePath = filepath.ToSlash(unsafePath) // noop + + // Make sure that an empty unsafe path still returns something sane, even + // with openat2 (which doesn't have AT_EMPTY_PATH semantics yet). + if unsafePath == "" { + unsafePath = "." + } + + // This is already checked by getProcRoot, but make sure here since the + // core security of this lookup is based on this assumption. + if err := verifyProcRoot(procRoot); err != nil { + return nil, err + } + + if linux.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. + // + // TODO: It would be nice to have RESOLVE_NO_DOTDOT, purely for + // self-consistency with the backup O_PATH resolver. + handle, err := fd.Openat2(procRoot, unsafePath, &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 { + // TODO: Once we bump the minimum Go version to 1.20, we can use + // multiple %w verbs for this wrapping. For now we need to use a + // compatibility shim for older Go versions. + // err = fmt.Errorf("%w: %w", errUnsafeProcfs, err) + return nil, gocompat.WrapBaseError(err, errUnsafeProcfs) + } + return handle, nil + } + + // To mirror openat2(RESOLVE_BENEATH), we need to return an error if the + // path is absolute. + if path.IsAbs(unsafePath) { + return nil, fmt.Errorf("%w: cannot resolve absolute paths in procfs resolver", internal.ErrPossibleBreakout) + } + + currentDir, err := fd.Dup(procRoot) + 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() + } + }() + + var ( + linksWalked int + currentPath string + remainingPath = unsafePath + ) + for 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 part == "" { + // no-op component, but treat it the same as "." + part = "." + } + if part == ".." { + // not permitted + return nil, fmt.Errorf("%w: cannot walk into '..' in procfs resolver", internal.ErrPossibleBreakout) + } + + // 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. (Not to mention that ".." isn't allowed.) + 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 == "/" { + // Jump to root. + rootClone, err := fd.Dup(procRoot) + 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 := fd.Openat(currentDir, part, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + + // Make sure we are still on procfs and haven't crossed mounts. + if err := verifyProcHandle(nextDir); err != nil { + _ = nextDir.Close() + return nil, fmt.Errorf("check %q component is on procfs: %w", part, err) + } + if err := checkSubpathOvermount(procRoot, nextDir, ""); err != nil { + _ = nextDir.Close() + return nil, fmt.Errorf("check %q component is not overmounted: %w", part, err) + } + + // We are emulating O_PATH|O_NOFOLLOW, so we only need to traverse into + // trailing symlinks if we are not the final component. Otherwise we + // can just return the currentDir. + if remainingPath != "" { + st, err := nextDir.Stat() + if err != nil { + _ = nextDir.Close() + return nil, fmt.Errorf("stat component %q: %w", part, err) + } + + if st.Mode()&os.ModeType == 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 := fd.Readlinkat(nextDir, "") + // We don't need the handle anymore. + _ = nextDir.Close() + if err != nil { + return nil, err + } + + linksWalked++ + if linksWalked > consts.MaxSymlinkLimit { + return nil, &os.PathError{Op: "securejoin.procfsLookupInRoot", Path: "/proc/" + unsafePath, Err: unix.ELOOP} + } + + // Update our logical remaining path. + remainingPath = linkDest + "/" + remainingPath + // Absolute symlinks are probably magiclinks, we reject them. + if path.IsAbs(linkDest) { + return nil, fmt.Errorf("%w: cannot jump to / in procfs resolver -- possible magiclink", internal.ErrPossibleBreakout) + } + continue + } + } + + // Walk into the next component. + _ = currentDir.Close() + currentDir = nextDir + currentPath = nextPath + } + + // One final sanity-check. + if err := verifyProcHandle(currentDir); err != nil { + return nil, fmt.Errorf("check final handle is on procfs: %w", err) + } + if err := checkSubpathOvermount(procRoot, currentDir, ""); err != nil { + return nil, fmt.Errorf("check final handle is not overmounted: %w", err) + } + return currentDir, nil +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs/procfs_lookup_linux_test.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs/procfs_lookup_linux_test.go new file mode 100644 index 0000000..2a58eca --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs/procfs_lookup_linux_test.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package procfs + +import ( + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/linux" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/testutils" +) + +func TestProcfsLookupInRoot(t *testing.T) { + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + // NOTE: We don't actually need root for unsafeHostProcRoot, but we + // can't test for that because Go doesn't let you compare function + // pointers... + testutils.RequireRoot(t) + + // The openat2 and non-openat2 backends return different error + // messages for the breakout case (".." and suspected magic-links). + // The main issue is that openat2 just returns -EXDEV and returning + // errUnsafeProcfs in all cases of the fallback resolver (for + // consistency) doesn't make much sense. + breakoutErr := internal.ErrPossibleBreakout + if linux.HasOpenat2() { + breakoutErr = errUnsafeProcfs + } + + for _, test := range []struct { + name string + root, subpath string + expectedPath string + expectedErr error + }{ + {"nonproc-xdev", "/", "proc", "", errUnsafeProcfs}, + {"proc-nonroot", "/proc/tty", ".", "", errUnsafeProcfs}, + {"proc-emptypath", "/proc", "", "/proc", nil}, + {"proc-root-dotdot", "/proc", "1/../..", "", breakoutErr}, + {"proc-root-dotdot-top", "/proc", "..", "", breakoutErr}, + {"proc-abs-slash", "/proc", "/", "", breakoutErr}, + {"proc-abs-path", "/proc", "/etc/passwd", "", breakoutErr}, + // {"dotdot", "1/..", breakoutErr}, // only errors out for fallback resolver + {"proc-uptime", "/proc", "uptime", "/proc/uptime", nil}, + {"proc-sys-kernel-arch", "/proc", "sys/kernel/arch", "/proc/sys/kernel/arch", nil}, + {"proc-symlink-nofollow", "/proc", "self", "/proc/self", nil}, + {"proc-symlink-follow", "/proc", "self/.", fmt.Sprintf("/proc/%d", os.Getpid()), nil}, + {"proc-self-attr", "/proc", "self/attr/apparmor/exec", fmt.Sprintf("/proc/%d/attr/apparmor/exec", os.Getpid()), nil}, + {"proc-magiclink-nofollow", "/proc", "self/exe", fmt.Sprintf("/proc/%d/exe", os.Getpid()), nil}, + {"proc-magiclink-follow", "/proc", "self/cwd/.", "", breakoutErr}, + } { + test := test // copy iterator + t.Run(test.name, func(t *testing.T) { + root, err := os.Open(test.root) + require.NoError(t, err, "open procfs resolver root") + + handle, err := procfsLookupInRoot(root, test.subpath) + assert.ErrorIsf(t, err, test.expectedErr, "procfsLookupInRoot(%q)", test.subpath) //nolint:testifylint // this is an isolated operation so we can continue despite an error + if handle != nil { + handlePath, err := ProcSelfFdReadlink(handle) + require.NoError(t, err, "ProcSelfFdReadlink handle") + assert.Equal(t, test.expectedPath, handlePath, "ProcSelfFdReadlink of handle") + _ = handle.Close() + } + }) + } + }) +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs/testing_mocks_linux_test.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs/testing_mocks_linux_test.go new file mode 100644 index 0000000..1cca841 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs/testing_mocks_linux_test.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package procfs + +import ( + "io" +) + +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 io.Closer) bool { + if check { + if f != nil { + _ = f.Close() + } + return true + } + return false +} + +func testingForcePrivateProcRootOpenTree(f io.Closer) bool { + return testingForceGetProcRoot != nil && + testingCheckClose(*testingForceGetProcRoot >= forceGetProcRootOpenTree, f) +} + +func testingForcePrivateProcRootOpenTreeAtRecursive(f io.Closer) bool { + return testingForceGetProcRoot != nil && + testingCheckClose(*testingForceGetProcRoot >= forceGetProcRootOpenTreeAtRecursive, f) +} + +func testingForceGetProcRootUnsafe() bool { + return testingForceGetProcRoot != nil && + *testingForceGetProcRoot >= forceGetProcRootUnsafe +} + +type forceProcThreadSelfLevel int + +const ( + forceProcThreadSelfDefault forceProcThreadSelfLevel = iota + forceProcSelfTask + forceProcSelf +) + +var testingForceProcThreadSelf *forceProcThreadSelfLevel + +func testingForceProcSelfTask() bool { + return testingForceProcThreadSelf != nil && + *testingForceProcThreadSelf >= forceProcSelfTask +} + +func testingForceProcSelf() bool { + return testingForceProcThreadSelf != nil && + *testingForceProcThreadSelf >= forceProcSelf +} + +func init() { + hookForceGetProcRootUnsafe = testingForceGetProcRootUnsafe + hookForcePrivateProcRootOpenTree = testingForcePrivateProcRootOpenTree + hookForcePrivateProcRootOpenTreeAtRecursive = testingForcePrivateProcRootOpenTreeAtRecursive + + hookForceProcSelf = testingForceProcSelf + hookForceProcSelfTask = testingForceProcSelfTask +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs/util_linux_test.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs/util_linux_test.go new file mode 100644 index 0000000..fdfd077 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs/util_linux_test.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package procfs + +import ( + "testing" +) + +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) + }) + } +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/testutils/iface.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/testutils/iface.go new file mode 100644 index 0000000..ac448a1 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/testutils/iface.go @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Package testutils provides some internal helpers for tests. +package testutils + +import ( + "github.com/cyphar/filepath-securejoin/internal/testutils" +) + +// TestingT is an interface wrapper around *testing.T. +type TestingT = testutils.TestingT diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/testutils/linux.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/testutils/linux.go new file mode 100644 index 0000000..5289dab --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/testutils/linux.go @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package testutils + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" + + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/linux" +) + +// RequireRoot skips the current test if we are not root. +func RequireRoot(t TestingT) { + if os.Geteuid() != 0 { + t.Skip("test requires root") + } +} + +// RequireRenameExchange skips the current test if renameat2(2) is not +// supported on the running system. +func RequireRenameExchange(t TestingT) { + err := unix.Renameat2(unix.AT_FDCWD, ".", unix.AT_FDCWD, ".", unix.RENAME_EXCHANGE) + if errors.Is(err, unix.ENOSYS) { + t.Skip("test requires RENAME_EXCHANGE support") + } +} + +// TDoFunc is effectively a func(t *testing.T) function but using the +// [TestingT] interface to allow us to write testutils with non-test code. The +// argument is virtually guaranteed to be a *testing.T instance so you can just +// do a type assertion in the body of the closure. +type TDoFunc func(ti TestingT) + +// TRunFunc is a wrapper around t.Run but done with an interface that can be +// used in non-testing code. To use this, you should just define a wrapper +// function like this: +// +// func tRunWrapper(t *testing.T) testutils.TRunFunc { +// return func(name string, doFn testutils.TDoFunc) { +// t.Run(name, func(t *testing.T) { +// doFn(t) +// }) +// } +// } +// +// and then use it with [WithWithoutOpenat2] like so: +// +// testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { +// t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code +// /* test code */ +// }) +type TRunFunc func(name string, doFn TDoFunc) + +// WithWithoutOpenat2 runs a given test with and without openat2 (by forcefully +// disabling its usage). +func WithWithoutOpenat2(doAuto bool, tRunFn TRunFunc, doFn TDoFunc) { + if doAuto { + tRunFn("openat2=auto", doFn) + } + for _, useOpenat2 := range []bool{true, false} { + useOpenat2 := useOpenat2 // copy iterator + tRunFn(fmt.Sprintf("openat2=%v", useOpenat2), func(t TestingT) { + if useOpenat2 && !linux.HasOpenat2() { + t.Skip("no openat2 support") + } + + origHasOpenat2 := linux.HasOpenat2 + linux.HasOpenat2 = func() bool { return useOpenat2 } + defer func() { linux.HasOpenat2 = origHasOpenat2 }() + + if !useOpenat2 { + origOpenat2 := fd.Openat2 + fd.Openat2 = func(_ fd.Fd, _ string, _ *unix.OpenHow) (*os.File, error) { + return nil, fmt.Errorf("INTERNAL ERROR THAT SHOULD NEVER BE SEEN: %w", unix.ENOSYS) + } + defer func() { fd.Openat2 = origOpenat2 }() + } + + doFn(t) + }) + } +} + +// CreateInTree creates a given inode inside the root directory. +// +// Format: +// +// dir +// file +// symlink +// char +// block +// fifo +// sock +func CreateInTree(t TestingT, 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] + } + MkdirAll(t, fullPath, 0o755) + case "file": + var contents []byte + if len(f) >= 1 { + contents = []byte(f[0]) + } + if len(f) >= 2 { + setOwnerMode = &f[1] + } + WriteFile(t, fullPath, contents, 0o644) + case "symlink": + if len(f) < 1 { + t.Fatalf("invalid spec %q", spec) + } + target := f[0] + Symlink(t, target, fullPath) + 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) + } + } +} + +// CreateTree creates a rootfs tree using spec entries (as documented in +// [CreateInTree]). The returned path is the path to the root of the new tree. +func CreateTree(t TestingT, specs ...string) string { + root := t.TempDir() + + // Put the root in a subdir. + treeRoot := filepath.Join(root, "tree") + MkdirAll(t, treeRoot, 0o755) + + for _, spec := range specs { + CreateInTree(t, treeRoot, spec) + } + return treeRoot +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/testutils/os.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/testutils/os.go new file mode 100644 index 0000000..c6e0759 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/internal/testutils/os.go @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package testutils + +import ( + "github.com/cyphar/filepath-securejoin/internal/testutils" +) + +// Symlink is a wrapper around os.Symlink. +var Symlink = testutils.Symlink + +// MkdirAll is a wrapper around os.MkdirAll. +var MkdirAll = testutils.MkdirAll + +// WriteFile is a wrapper around os.WriteFile. +var WriteFile = testutils.WriteFile diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/mkdir.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/mkdir.go new file mode 100644 index 0000000..b431695 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/mkdir.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package pathrs + +import ( + "os" + + "golang.org/x/sys/unix" +) + +// 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]. +// +// [SecureJoin]: https://pkg.go.dev/github.com/cyphar/filepath-securejoin#SecureJoin +func MkdirAll(root, unsafePath string, mode os.FileMode) error { + rootDir, err := os.OpenFile(root, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) + if err != nil { + return err + } + defer rootDir.Close() //nolint:errcheck // close failures aren't critical here + + f, err := MkdirAllHandle(rootDir, unsafePath, mode) + if err != nil { + return err + } + _ = f.Close() + return nil +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/mkdir_libpathrs.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/mkdir_libpathrs.go new file mode 100644 index 0000000..f864dbc --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/mkdir_libpathrs.go @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build libpathrs + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package pathrs + +import ( + "os" + + "cyphar.com/go-pathrs" +) + +// 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. +// +// [SecureJoin]: https://pkg.go.dev/github.com/cyphar/filepath-securejoin#SecureJoin +func MkdirAllHandle(root *os.File, unsafePath string, mode os.FileMode) (*os.File, error) { + rootRef, err := pathrs.RootFromFile(root) + if err != nil { + return nil, err + } + defer rootRef.Close() //nolint:errcheck // close failures aren't critical here + + handle, err := rootRef.MkdirAll(unsafePath, mode) + if err != nil { + return nil, err + } + return handle.IntoFile(), nil +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/mkdir_linux_test.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/mkdir_linux_test.go new file mode 100644 index 0000000..408e42e --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/mkdir_linux_test.go @@ -0,0 +1,576 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package pathrs_test + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" + + pathrs "github.com/cyphar/filepath-securejoin/pathrs-lite" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/testutils" +) + +type mkdirAllFunc func(t *testing.T, root, unsafePath string, mode os.FileMode) error + +var mkdirAll_MkdirAll mkdirAllFunc = func(_ *testing.T, root, unsafePath string, mode os.FileMode) error { //nolint:revive // underscores are more readable for test helpers + // We can't check expectedPath here. + return pathrs.MkdirAll(root, unsafePath, mode) +} + +var mkdirAll_MkdirAllHandle mkdirAllFunc = func(t *testing.T, root, unsafePath string, mode os.FileMode) error { //nolint:revive // underscores are more readable for test helpers + // 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() //nolint:errcheck // test code + handle, err := pathrs.MkdirAllHandle(rootDir, unsafePath, mode) + if err != nil { + return err + } + defer handle.Close() //nolint:errcheck // test code + + // We can lookup the expected path again to get the full path. This will + // give a reasonable result because we aren't being attacked in this + // particular test. + handle2, err := pathrs.OpenatInRoot(rootDir, unsafePath) + require.NoError(t, err) + expectedPath, err := procfs.ProcSelfFdReadlink(handle2) + require.NoError(t, err) + + // Now double-check that the handle is correct. + gotPath, err := procfs.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 os.FileMode, 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() //nolint:errcheck // test code + + // Before trying to make the tree, figure out what components don't exist + // yet so we can check them later. + handle, remainingPath, err := gopathrs.PartialLookupInRoot(rootDir, unsafePath) + handleName := "" + if handle != nil { + handleName = handle.Name() + defer handle.Close() //nolint:errcheck // test code + } + 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) + require.ErrorIsf(t, err, expectedErr, "MkdirAll(%q, %q)", root, unsafePath) + + remainingPath = filepath.Join("/", remainingPath) + for remainingPath != filepath.Dir(remainingPath) { + stat, err := fd.Fstatat(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) { //nolint:revive // underscores are more readable for test helpers + // 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", + } + + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + 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.ENOTDIR}, + "dangling1-basic": {unsafePath: "a-fake1/foo", expectedErr: unix.ENOTDIR}, + "dangling1-dotdot": {unsafePath: "a-fake1/../bar/baz", expectedErr: unix.ENOENT}, + "dangling2-trailing": {unsafePath: "a-fake2", expectedErr: unix.ENOTDIR}, + "dangling2-basic": {unsafePath: "a-fake2/foo", expectedErr: unix.ENOTDIR}, + "dangling2-dotdot": {unsafePath: "a-fake2/../bar/baz", expectedErr: unix.ENOENT}, + "dangling3-trailing": {unsafePath: "a-fake3", expectedErr: unix.ENOTDIR}, + "dangling3-basic": {unsafePath: "a-fake3/foo", expectedErr: unix.ENOTDIR}, + "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.ENOTDIR}, + "dangling-tricky1-basic": {unsafePath: "link3/deep_dangling1/foo", expectedErr: unix.ENOTDIR}, + "dangling-tricky1-dotdot": {unsafePath: "link3/deep_dangling1/../bar", expectedErr: unix.ENOENT}, + "dangling-tricky2-trailing": {unsafePath: "link3/deep_dangling2", expectedErr: unix.ENOTDIR}, + "dangling-tricky2-basic": {unsafePath: "link3/deep_dangling2/foo", expectedErr: unix.ENOTDIR}, + "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 := testutils.CreateTree(t, tree...) + const mode = 0o711 + checkMkdirAll(t, mkdirAll, root, test.unsafePath, mode, test.expectedModeBits|mode, test.expectedErr) + }) + } + }) +} + +func TestMkdirAll_Basic(t *testing.T) { //nolint:revive // underscores are more readable for test helpers + testMkdirAll_Basic(t, mkdirAll_MkdirAll) +} + +func TestMkdirAllHandle_Basic(t *testing.T) { //nolint:revive // underscores are more readable for test helpers + testMkdirAll_Basic(t, mkdirAll_MkdirAllHandle) +} + +func TestMkdirAll_BadRoot(t *testing.T) { //nolint:revive // underscores are more readable for test helpers + t.Run("MkdirAll", func(t *testing.T) { + root := filepath.Join(t.TempDir(), "does/not/exist") + + err := pathrs.MkdirAll(root, "foo/bar", 0o755) + require.ErrorIs(t, err, os.ErrNotExist, "MkdirAll with bad root") + }) + // TODO: Should we add checks for nil *os.File? +} + +func testMkdirAll_AsRoot(t *testing.T, mkdirAll mkdirAllFunc) { //nolint:revive // underscores are more readable for test helpers + testutils.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", + } + + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + 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 := testutils.CreateTree(t, tree...) + const mode = 0o711 + checkMkdirAll(t, mkdirAll, root, test.unsafePath, mode, test.expectedModeBits|mode, test.expectedErr) + }) + } + }) +} + +func TestMkdirAll_AsRoot(t *testing.T) { //nolint:revive // underscores are more readable for test helpers + testMkdirAll_AsRoot(t, mkdirAll_MkdirAll) +} + +func TestMkdirAllHandle_AsRoot(t *testing.T) { //nolint:revive // underscores are more readable for test helpers + testMkdirAll_AsRoot(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 os.FileMode, allowedErrs []error) { //nolint:revive // underscores are more readable for test helpers + rootDir, err := os.OpenFile(root, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) + if !assert.NoError(t, err, "open root") { //nolint:testifylint // cannot use require.* in goroutines + m.failCount++ + return + } + defer rootDir.Close() //nolint:errcheck // test code + + handle, err := pathrs.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() //nolint:errcheck // test code + + // 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) { //nolint:revive // underscores are more readable for test helpers + if testing.Short() { + t.Skip("skipping race tests in short mode") + } + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + 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.ENOTDIR}}, + {"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 = 800 + minPassCount = 10 + ) + m := newRacingMkdirMeta() + doneRuns := 0 + for ; doneRuns < testRuns || m.passOkCount < minPassCount; doneRuns++ { + root := testutils.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) + + // Make sure we don't infinite loop here. + if doneRuns >= 50*testRuns { + break + } + } + + pct := func(count int) string { + return fmt.Sprintf("%d(%.3f%%)", count, 100.0*float64(count)/float64(doneRuns)) + } + + // No passing runs is a bit unfortunate, but some of our tests + // can do that and failing here would just lead to flaky tests. + if m.passOkCount == 0 { + t.Logf("WARNING: NO PASSING RUNS!") + } + + // Output some stats. + t.Logf("after %d runs: passOk=%s passErr=%s fail=%s", + doneRuns, 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) { //nolint:revive // underscores are more readable for test helpers + if testing.Short() { + t.Skip("skipping race tests in short mode") + } + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + 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{internal.ErrInvalidDirectory, unix.ENOENT}}, + {"rm-existing", "target/a/b/c", "target/a/b/c/d/e/f/g/h/i/j/k", []error{internal.ErrInvalidDirectory, unix.ENOENT}}, + {"rm-nonexisting", "target/a/b/c/d/e", "target/a/b/c/d/e/f/g/h/i/j/k", []error{internal.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 = 800 + minPassCount = 10 + ) + m := newRacingMkdirMeta() + doneRuns := 0 + for ; doneRuns < testRuns; doneRuns++ { + root := testutils.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 + "/..") + + // Make sure we don't infinite loop here. + if doneRuns >= 50*testRuns { + break + } + } + + pct := func(count int) string { + return fmt.Sprintf("%d(%.3f%%)", count, 100.0*float64(count)/float64(doneRuns)) + } + + // No passing runs is a bit unfortunate, but some of our tests + // can do that and failing here would just lead to flaky tests. + if m.passOkCount == 0 { + t.Logf("WARNING: NO PASSING RUNS!") + } + + // Output some stats. + t.Logf("after %d runs: passOk=%s passErr=%s fail=%s", + doneRuns, 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) + } + } + }) + } + }) +} + +// Regression test for . +func TestMkdirAllHandle_RacingCreate(t *testing.T) { //nolint:revive // underscores are more readable for test helpers + if testing.Short() { + t.Skip("skipping race tests in short mode") + } + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + threadRanges := []int{2, 4, 8, 16, 32, 64, 128, 512, 1024} + for _, numThreads := range threadRanges { + numThreads := numThreads + t.Run(fmt.Sprintf("threads=%d", numThreads), func(t *testing.T) { + // Do several runs to try to catch bugs. + const testRuns = 500 + m := newRacingMkdirMeta() + for i := 0; i < testRuns; i++ { + root := t.TempDir() + unsafePath := "a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/x/y/z" + + // Spawn many threads that will race against each other to + // create the same directory. + startCh := make(chan struct{}) + var finishedWg sync.WaitGroup + for i := 0; i < numThreads; i++ { + finishedWg.Add(1) + go func() { + <-startCh + m.checkMkdirAllHandle_Racing(t, root, unsafePath, 0o711, nil) + finishedWg.Done() + }() + } + + // Start all of the threads at the same time. + close(startCh) + + // Wait for all of the racing threads to finish. + finishedWg.Wait() + + // Clean up the root after each run so we don't exhaust all + // space in the tmpfs. + _ = os.RemoveAll(root) + } + }) + } + }) +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/mkdir_purego.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/mkdir_purego.go new file mode 100644 index 0000000..0369dfe --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/mkdir_purego.go @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux && !libpathrs + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package pathrs + +import ( + "os" + + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs" +) + +// 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. +// +// [SecureJoin]: https://pkg.go.dev/github.com/cyphar/filepath-securejoin#SecureJoin +func MkdirAllHandle(root *os.File, unsafePath string, mode os.FileMode) (*os.File, error) { + return gopathrs.MkdirAllHandle(root, unsafePath, mode) +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/open.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/open.go new file mode 100644 index 0000000..41b6289 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/open.go @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package pathrs + +import ( + "os" + + "golang.org/x/sys/unix" +) + +// 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]. +// +// [SecureJoin]: https://pkg.go.dev/github.com/cyphar/filepath-securejoin#SecureJoin +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() //nolint:errcheck // close failures aren't critical here + return OpenatInRoot(rootDir, unsafePath) +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/open_libpathrs.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/open_libpathrs.go new file mode 100644 index 0000000..5335200 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/open_libpathrs.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build libpathrs + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package pathrs + +import ( + "os" + + "cyphar.com/go-pathrs" +) + +// 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) { + rootRef, err := pathrs.RootFromFile(root) + if err != nil { + return nil, err + } + defer rootRef.Close() //nolint:errcheck // close failures aren't critical here + + handle, err := rootRef.Resolve(unsafePath) + if err != nil { + return nil, err + } + return handle.IntoFile(), nil +} + +// 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(file *os.File, flags int) (*os.File, error) { + handle, err := pathrs.HandleFromFile(file) + if err != nil { + return nil, err + } + defer handle.Close() //nolint:errcheck // close failures aren't critical here + + return handle.OpenFile(flags) +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/open_linux_test.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/open_linux_test.go new file mode 100644 index 0000000..f37df01 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/open_linux_test.go @@ -0,0 +1,449 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package pathrs_test + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" + + pathrs "github.com/cyphar/filepath-securejoin/pathrs-lite" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/testutils" +) + +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 //nolint:revive // unix.* name + +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 tRunWrapper(t *testing.T) testutils.TRunFunc { + return func(name string, doFn testutils.TDoFunc) { + t.Run(name, func(t *testing.T) { + doFn(t) + }) + } +} + +func checkReopen(t *testing.T, handle *os.File, flags int, expectedErr error) { + newHandle, err := pathrs.Reopen(handle, flags) + if newHandle != nil { + defer newHandle.Close() //nolint:errcheck // test code + } + if expectedErr != nil { + if assert.Error(t, err) { + require.ErrorIs(t, err, expectedErr) + } else { + t.Errorf("unexpected handle %q", handle.Name()) + } + return + } + require.NoError(t, err) + + // Get the original handle path. + handlePath, err := procfs.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 := procfs.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() //nolint:errcheck // test code + } + if expected.err != nil { + if assert.Error(t, err) { + require.ErrorIs(t, err, expected.err) + } else { + t.Errorf("unexpected handle %q", handle.Name()) + } + return + } + require.NoError(t, err) + + // Check the handle path. + gotPath, err := procfs.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 := fd.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 := testutils.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) { + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + testOpenInRoot(t, pathrs.OpenInRoot) + }) +} + +func TestOpenInRootHandle(t *testing.T) { + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + 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() //nolint:errcheck // test code + + return pathrs.OpenatInRoot(rootDir, unsafePath) + }) + }) +} + +func TestOpenInRoot_BadRoot(t *testing.T) { //nolint:revive // underscores are more readable for test helpers + t.Run("OpenInRoot", func(t *testing.T) { + root := filepath.Join(t.TempDir(), "does/not/exist") + + handle, err := pathrs.OpenInRoot(root, ".") + require.ErrorIs(t, err, os.ErrNotExist, "OpenInRoot with bad root") + assert.Nil(t, handle, "OpenInRoot with bad root should not return handle") + }) + // TODO: Should we add checks for nil *os.File? +} + +func TestOpenInRoot_BadInode(t *testing.T) { //nolint:revive // underscores are more readable for test helpers + testutils.RequireRoot(t) // mknod + + testutils.WithWithoutOpenat2(true, tRunWrapper(t), func(ti testutils.TestingT) { + t := ti.(*testing.T) //nolint:forcetypeassert // guaranteed to be true and in test code + tree := []string{ + // Make sure we don't open "bad" inodes. + "dir foo", + "char foo/whiteout 0 0", + "block foo/whiteout-blk 0 0", + } + + root := testutils.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() //nolint:errcheck // test code + + 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, pathrs.OpenInRoot, root, test.unsafePath, test.expected) + }) + } + }) +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/open_purego.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/open_purego.go new file mode 100644 index 0000000..6d1be12 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/open_purego.go @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux && !libpathrs + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package pathrs + +import ( + "os" + + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/gopathrs" + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs" +) + +// 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) { + return gopathrs.OpenatInRoot(root, 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) { + return procfs.ReopenFd(handle, flags) +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/procfs/procfs_libpathrs.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/procfs/procfs_libpathrs.go new file mode 100644 index 0000000..6c4df37 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/procfs/procfs_libpathrs.go @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build libpathrs + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Package procfs provides a safe API for operating on /proc on Linux. +package procfs + +import ( + "os" + "strconv" + + "cyphar.com/go-pathrs/procfs" + "golang.org/x/sys/unix" +) + +// ProcThreadSelfCloser is a callback that needs to be called when you are done +// operating on an [os.File] fetched using [Handle.OpenThreadSelf]. +// +// [os.File]: https://pkg.go.dev/os#File +type ProcThreadSelfCloser = procfs.ThreadCloser + +// Handle is a wrapper around an *os.File handle to "/proc", which can be used +// to do further procfs-related operations in a safe way. +type Handle struct { + inner *procfs.Handle +} + +// Close close the resources associated with this [Handle]. Note that if this +// [Handle] was created with [OpenProcRoot], on some kernels the underlying +// procfs handle is cached and so this Close operation may be a no-op. However, +// you should always call Close on [Handle]s once you are done with them. +func (proc *Handle) Close() error { return proc.inner.Close() } + +// OpenProcRoot tries to open a "safer" handle to "/proc" (i.e., one with the +// "subset=pid" mount option applied, available from Linux 5.8). Unless you +// plan to do many [Handle.OpenRoot] operations, users should prefer to use +// this over [OpenUnsafeProcRoot] which is far more dangerous to keep open. +// +// If a safe handle cannot be opened, OpenProcRoot will fall back to opening a +// regular "/proc" handle. +// +// Note that using [Handle.OpenRoot] will still work with handles returned by +// this function. If a subpath cannot be operated on with a safe "/proc" +// handle, then [OpenUnsafeProcRoot] will be called internally and a temporary +// unsafe handle will be used. +func OpenProcRoot() (*Handle, error) { + proc, err := procfs.Open() + if err != nil { + return nil, err + } + return &Handle{inner: proc}, nil +} + +// OpenUnsafeProcRoot opens a handle to "/proc" without any overmounts or +// masked paths. You must be extremely careful to make sure this handle is +// never leaked to a container and that you program cannot be tricked into +// writing to arbitrary paths within it. +// +// This is not necessary if you just wish to use [Handle.OpenRoot], as handles +// returned by [OpenProcRoot] will fall back to using a *temporary* unsafe +// handle in that case. You should only really use this if you need to do many +// operations with [Handle.OpenRoot] and the performance overhead of making +// many procfs handles is an issue. If you do use OpenUnsafeProcRoot, you +// should make sure to close the handle as soon as possible to avoid +// known-fd-number attacks. +func OpenUnsafeProcRoot() (*Handle, error) { + proc, err := procfs.Open(procfs.UnmaskedProcRoot) + if err != nil { + return nil, err + } + return &Handle{inner: proc}, nil +} + +// OpenThreadSelf 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. +// +// [runtime.UnlockOSThread]: https://pkg.go.dev/runtime#UnlockOSThread +func (proc *Handle) OpenThreadSelf(subpath string) (*os.File, ProcThreadSelfCloser, error) { + return proc.inner.OpenThreadSelf(subpath, unix.O_PATH|unix.O_NOFOLLOW) +} + +// OpenSelf returns a handle to /proc/self/. +// +// Note that in Go programs with non-homogenous threads, this may result in +// spurious errors. If you are monkeying around with APIs that are +// thread-specific, you probably want to use [Handle.OpenThreadSelf] instead +// which will guarantee that the handle refers to the same thread as the caller +// is executing on. +func (proc *Handle) OpenSelf(subpath string) (*os.File, error) { + return proc.inner.OpenSelf(subpath, unix.O_PATH|unix.O_NOFOLLOW) +} + +// OpenRoot returns a handle to /proc/. +// +// You should only use this when you need to operate on global procfs files +// (such as sysctls in /proc/sys). Unlike [Handle.OpenThreadSelf], +// [Handle.OpenSelf], and [Handle.OpenPid], the procfs handle used internally +// for this operation will never use "subset=pid", which makes it a more juicy +// target for [CVE-2024-21626]-style attacks (and doing something like opening +// a directory with OpenRoot effectively leaks [OpenUnsafeProcRoot] as long as +// the file descriptor is open). +// +// [CVE-2024-21626]: https://github.com/opencontainers/runc/security/advisories/GHSA-xr7r-f8xq-vfvv +func (proc *Handle) OpenRoot(subpath string) (*os.File, error) { + return proc.inner.OpenRoot(subpath, unix.O_PATH|unix.O_NOFOLLOW) +} + +// OpenPid returns a handle to /proc/$pid/ (pid can be a pid or tid). +// This is mainly intended for usage when operating on other processes. +// +// You should not use this for the current thread, as special handling is +// needed for /proc/thread-self (or /proc/self/task/) when dealing with +// goroutine scheduling -- use [Handle.OpenThreadSelf] instead. +// +// To refer to the current thread-group, you should use prefer +// [Handle.OpenSelf] to passing os.Getpid as the pid argument. +func (proc *Handle) OpenPid(pid int, subpath string) (*os.File, error) { + return proc.inner.OpenPid(pid, subpath, unix.O_PATH|unix.O_NOFOLLOW) +} + +// ProcSelfFdReadlink gets the real path of the given file by looking at +// /proc/self/fd/ with [readlink]. It is effectively just shorthand for +// something along the lines of: +// +// proc, err := procfs.OpenProcRoot() +// if err != nil { +// return err +// } +// link, err := proc.OpenThreadSelf(fmt.Sprintf("fd/%d", f.Fd())) +// if err != nil { +// return err +// } +// defer link.Close() +// var buf [4096]byte +// n, err := unix.Readlinkat(int(link.Fd()), "", buf[:]) +// if err != nil { +// return err +// } +// pathname := buf[:n] +// +// [readlink]: https://pkg.go.dev/golang.org/x/sys/unix#Readlinkat +func ProcSelfFdReadlink(f *os.File) (string, error) { + proc, err := procfs.Open() + if err != nil { + return "", err + } + defer proc.Close() //nolint:errcheck // close failures aren't critical here + + fdPath := "fd/" + strconv.Itoa(int(f.Fd())) + return proc.Readlink(procfs.ProcThreadSelf, fdPath) +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/procfs/procfs_linux_test.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/procfs/procfs_linux_test.go new file mode 100644 index 0000000..f6f9d68 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/procfs/procfs_linux_test.go @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package procfs_test + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" + + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/fd" + "github.com/cyphar/filepath-securejoin/pathrs-lite/procfs" +) + +// This code is all actually tested in internal/procfs, this is mainly +// necessary to make sure our one-line wrappers are correct. + +func TestOpenProcRoot(t *testing.T) { + t.Run("OpenProcRoot", func(t *testing.T) { + proc, err := procfs.OpenProcRoot() + require.NoError(t, err, "OpenProcRoot") + assert.NotNil(t, proc, "procfs *Handle") + assert.NoError(t, proc.Close(), "close handle") + }) + + t.Run("OpenUnsafeProcRoot", func(t *testing.T) { + proc, err := procfs.OpenUnsafeProcRoot() + require.NoError(t, err, "OpenUnsafeProcRoot") + assert.NotNil(t, proc, "procfs *Handle") + defer proc.Close() //nolint:errcheck // test code + + // Make sure the handle actually is !subset=pid. + f, err := proc.OpenRoot(".") + require.NoError(t, err, "open root .") + err = fd.Faccessat(f, "uptime", unix.F_OK, unix.AT_SYMLINK_NOFOLLOW) + assert.NoError(t, err, "/proc/uptime should exist") //nolint:testifylint // this is an isolated operation so we can continue despite an error + + assert.NoError(t, proc.Close(), "close handle") + }) +} + +type procRootFunc func() (*procfs.Handle, error) + +func TestProcRoot(t *testing.T) { + for _, test := range []struct { + name string + procRootFn procRootFunc + }{ + {"OpenProcRoot", procfs.OpenProcRoot}, + {"OpenUnsafeProcRoot", procfs.OpenUnsafeProcRoot}, + } { + test := test // copy iterator + t.Run(test.name, func(t *testing.T) { + proc, err := test.procRootFn() + require.NoError(t, err) + defer proc.Close() //nolint:errcheck // test code + + t.Run("OpenThreadSelf", func(t *testing.T) { + // Make sure our tid checks below are correct. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + stat, closer, err := proc.OpenThreadSelf("stat") + require.NoError(t, err, "open /proc/thread-self/stat") + if assert.NotNil(t, closer, "closer should be non-nil for /proc/thread-self") { + defer closer() + } + require.NotNil(t, stat, "open /proc/thread-self/stat") + defer stat.Close() //nolint:errcheck // test code + + statData, err := os.ReadFile(fmt.Sprintf("/proc/self/fd/%d", stat.Fd())) + runtime.KeepAlive(stat) + require.NoError(t, err) + assert.Regexp(t, fmt.Sprintf("^%d ", unix.Gettid()), string(statData), "/proc/thread-self/stat should have tid prefix") + + // Confirm that this is /proc/$pid/task/$tid, not /proc/$pid. + f, closer, err := proc.OpenThreadSelf("task") + require.ErrorIs(t, err, os.ErrNotExist, "/proc/thread-self should not have a 'task' dir") + if !assert.Nil(t, closer, "returned closer on error") { + defer closer() + } + if !assert.Nil(t, f, "returned *os.File on error") { + _ = f.Close() + } + }) + + t.Run("OpenSelf", func(t *testing.T) { + stat, err := proc.OpenSelf("stat") + require.NoError(t, err, "open /proc/self/stat") + require.NotNil(t, stat, "open /proc/self/stat") + defer stat.Close() //nolint:errcheck // test code + + statData, err := os.ReadFile(fmt.Sprintf("/proc/self/fd/%d", stat.Fd())) + runtime.KeepAlive(stat) + require.NoError(t, err) + assert.Regexp(t, fmt.Sprintf("^%d ", os.Getpid()), string(statData), "/proc/self/stat should have pid prefix") + + // Confirm that this is /proc/$pid, not /proc/$pid/task/$tid. + f, err := proc.OpenSelf("task") + require.NoError(t, err, "/proc/self has a 'task' dir") + require.NotNil(t, f, "open /proc/self/task") + _ = f.Close() + }) + + t.Run("OpenPid", func(t *testing.T) { + stat, err := proc.OpenPid(1, "stat") + require.NoError(t, err, "open /proc/1/stat") + require.NotNil(t, stat, "open /proc/1/stat") + defer stat.Close() //nolint:errcheck // test code + + statData, err := os.ReadFile(fmt.Sprintf("/proc/self/fd/%d", stat.Fd())) + runtime.KeepAlive(stat) + require.NoError(t, err) + assert.Regexp(t, "^1 ", string(statData), "/proc/1/stat should have pid1 prefix") + + // Confirm that this is /proc/$pid, not /proc/$pid/task/$tid. + f, err := proc.OpenPid(1, "task") + require.NoError(t, err, "/proc/1 has a 'task' dir") + require.NotNil(t, f, "open /proc/1/task") + _ = f.Close() + }) + + t.Run("OpenRoot", func(t *testing.T) { + uptime, err := proc.OpenRoot("uptime") + require.NoError(t, err, "open /proc/uptime") + require.NotNil(t, uptime, "open /proc/uptime") + defer uptime.Close() //nolint:errcheck // test code + }) + }) + } +} + +func TestProcSelfFdReadlink(t *testing.T) { + root, err := os.Open(".") + require.NoError(t, err) + + fullPath, err := procfs.ProcSelfFdReadlink(root) + require.NoError(t, err, "ProcSelfFdReadlink") + + cwd, err := os.Getwd() + require.NoError(t, err, "getwd") + cwd, err = filepath.EvalSymlinks(cwd) + require.NoError(t, err, "expand symlinks getwd") + + assert.Equal(t, cwd, fullPath, "ProcSelfFdReadlink('.')") +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/procfs/procfs_purego.go b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/procfs/procfs_purego.go new file mode 100644 index 0000000..9383002 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/pathrs-lite/procfs/procfs_purego.go @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: MPL-2.0 + +//go:build linux && !libpathrs + +// Copyright (C) 2024-2025 Aleksa Sarai +// Copyright (C) 2024-2025 SUSE LLC +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Package procfs provides a safe API for operating on /proc on Linux. +package procfs + +import ( + "os" + + "github.com/cyphar/filepath-securejoin/pathrs-lite/internal/procfs" +) + +// This package mostly just wraps internal/procfs APIs. This is necessary +// because we are forced to export some things from internal/procfs in order to +// avoid some dependency cycle issues, but we don't want users to see or use +// them. + +// ProcThreadSelfCloser is a callback that needs to be called when you are done +// operating on an [os.File] fetched using [Handle.OpenThreadSelf]. +// +// [os.File]: https://pkg.go.dev/os#File +type ProcThreadSelfCloser = procfs.ProcThreadSelfCloser + +// Handle is a wrapper around an *os.File handle to "/proc", which can be used +// to do further procfs-related operations in a safe way. +type Handle struct { + inner *procfs.Handle +} + +// Close close the resources associated with this [Handle]. Note that if this +// [Handle] was created with [OpenProcRoot], on some kernels the underlying +// procfs handle is cached and so this Close operation may be a no-op. However, +// you should always call Close on [Handle]s once you are done with them. +func (proc *Handle) Close() error { return proc.inner.Close() } + +// OpenProcRoot tries to open a "safer" handle to "/proc" (i.e., one with the +// "subset=pid" mount option applied, available from Linux 5.8). Unless you +// plan to do many [Handle.OpenRoot] operations, users should prefer to use +// this over [OpenUnsafeProcRoot] which is far more dangerous to keep open. +// +// If a safe handle cannot be opened, OpenProcRoot will fall back to opening a +// regular "/proc" handle. +// +// Note that using [Handle.OpenRoot] will still work with handles returned by +// this function. If a subpath cannot be operated on with a safe "/proc" +// handle, then [OpenUnsafeProcRoot] will be called internally and a temporary +// unsafe handle will be used. +func OpenProcRoot() (*Handle, error) { + proc, err := procfs.OpenProcRoot() + if err != nil { + return nil, err + } + return &Handle{inner: proc}, nil +} + +// OpenUnsafeProcRoot opens a handle to "/proc" without any overmounts or +// masked paths. You must be extremely careful to make sure this handle is +// never leaked to a container and that you program cannot be tricked into +// writing to arbitrary paths within it. +// +// This is not necessary if you just wish to use [Handle.OpenRoot], as handles +// returned by [OpenProcRoot] will fall back to using a *temporary* unsafe +// handle in that case. You should only really use this if you need to do many +// operations with [Handle.OpenRoot] and the performance overhead of making +// many procfs handles is an issue. If you do use OpenUnsafeProcRoot, you +// should make sure to close the handle as soon as possible to avoid +// known-fd-number attacks. +func OpenUnsafeProcRoot() (*Handle, error) { + proc, err := procfs.OpenUnsafeProcRoot() + if err != nil { + return nil, err + } + return &Handle{inner: proc}, nil +} + +// OpenThreadSelf 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. +// +// [runtime.UnlockOSThread]: https://pkg.go.dev/runtime#UnlockOSThread +func (proc *Handle) OpenThreadSelf(subpath string) (*os.File, ProcThreadSelfCloser, error) { + return proc.inner.OpenThreadSelf(subpath) +} + +// OpenSelf returns a handle to /proc/self/. +// +// Note that in Go programs with non-homogenous threads, this may result in +// spurious errors. If you are monkeying around with APIs that are +// thread-specific, you probably want to use [Handle.OpenThreadSelf] instead +// which will guarantee that the handle refers to the same thread as the caller +// is executing on. +func (proc *Handle) OpenSelf(subpath string) (*os.File, error) { + return proc.inner.OpenSelf(subpath) +} + +// OpenRoot returns a handle to /proc/. +// +// You should only use this when you need to operate on global procfs files +// (such as sysctls in /proc/sys). Unlike [Handle.OpenThreadSelf], +// [Handle.OpenSelf], and [Handle.OpenPid], the procfs handle used internally +// for this operation will never use "subset=pid", which makes it a more juicy +// target for [CVE-2024-21626]-style attacks (and doing something like opening +// a directory with OpenRoot effectively leaks [OpenUnsafeProcRoot] as long as +// the file descriptor is open). +// +// [CVE-2024-21626]: https://github.com/opencontainers/runc/security/advisories/GHSA-xr7r-f8xq-vfvv +func (proc *Handle) OpenRoot(subpath string) (*os.File, error) { + return proc.inner.OpenRoot(subpath) +} + +// OpenPid returns a handle to /proc/$pid/ (pid can be a pid or tid). +// This is mainly intended for usage when operating on other processes. +// +// You should not use this for the current thread, as special handling is +// needed for /proc/thread-self (or /proc/self/task/) when dealing with +// goroutine scheduling -- use [Handle.OpenThreadSelf] instead. +// +// To refer to the current thread-group, you should use prefer +// [Handle.OpenSelf] to passing os.Getpid as the pid argument. +func (proc *Handle) OpenPid(pid int, subpath string) (*os.File, error) { + return proc.inner.OpenPid(pid, subpath) +} + +// ProcSelfFdReadlink gets the real path of the given file by looking at +// /proc/self/fd/ with [readlink]. It is effectively just shorthand for +// something along the lines of: +// +// proc, err := procfs.OpenProcRoot() +// if err != nil { +// return err +// } +// link, err := proc.OpenThreadSelf(fmt.Sprintf("fd/%d", f.Fd())) +// if err != nil { +// return err +// } +// defer link.Close() +// var buf [4096]byte +// n, err := unix.Readlinkat(int(link.Fd()), "", buf[:]) +// if err != nil { +// return err +// } +// pathname := buf[:n] +// +// [readlink]: https://pkg.go.dev/golang.org/x/sys/unix#Readlinkat +func ProcSelfFdReadlink(f *os.File) (string, error) { + return procfs.ProcSelfFdReadlink(f) +} diff --git a/licenses/github.com/cyphar/filepath-securejoin/vfs.go b/licenses/github.com/cyphar/filepath-securejoin/vfs.go new file mode 100644 index 0000000..4d89a48 --- /dev/null +++ b/licenses/github.com/cyphar/filepath-securejoin/vfs.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: BSD-3-Clause + +// 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) } diff --git a/licenses/github.com/klauspost/cpuid/v2/LICENSE b/licenses/github.com/klauspost/cpuid/v2/LICENSE new file mode 100644 index 0000000..5cec7ee --- /dev/null +++ b/licenses/github.com/klauspost/cpuid/v2/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015 Klaus Post + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +