helm: introduce customized chart loaders
This introduces our own `secureloader` package, with a directory loader that's capable of following symlinks while validating they stay within a certain root boundary. Signed-off-by: Hidde Beydals <hello@hidde.co>
This commit is contained in:
parent
5ae30cb4aa
commit
6fc066b1b6
|
@ -0,0 +1,208 @@
|
||||||
|
/*
|
||||||
|
Copyright The Helm Authors.
|
||||||
|
Copyright 2022 The Flux authors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
This file has been derived from
|
||||||
|
https://github.com/helm/helm/blob/v3.8.1/pkg/chart/loader/directory.go.
|
||||||
|
|
||||||
|
It has been modified to not blindly accept any resolved symlink path, but
|
||||||
|
instead check it against the configured root before allowing it to be included.
|
||||||
|
It also allows for capping the size of any file loaded into the chart.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package secureloader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
securejoin "github.com/cyphar/filepath-securejoin"
|
||||||
|
"helm.sh/helm/v3/pkg/chart"
|
||||||
|
"helm.sh/helm/v3/pkg/chart/loader"
|
||||||
|
|
||||||
|
"github.com/fluxcd/source-controller/internal/helm/chart/secureloader/ignore"
|
||||||
|
"github.com/fluxcd/source-controller/internal/helm/chart/secureloader/sympath"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// DefaultMaxFileSize is the default maximum file size of any chart file
|
||||||
|
// loaded.
|
||||||
|
DefaultMaxFileSize = 16 << 20 // 16MiB
|
||||||
|
|
||||||
|
utf8bom = []byte{0xEF, 0xBB, 0xBF}
|
||||||
|
)
|
||||||
|
|
||||||
|
// SecureDirLoader securely loads a chart from a directory while resolving
|
||||||
|
// symlinks without including files outside root.
|
||||||
|
type SecureDirLoader struct {
|
||||||
|
root string
|
||||||
|
dir string
|
||||||
|
maxSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSecureDirLoader returns a new SecureDirLoader, configured to the scope of the
|
||||||
|
// root and provided dir. Max size configures the maximum size a file must not
|
||||||
|
// exceed to be loaded. If 0 it defaults to defaultMaxFileSize, it can be
|
||||||
|
// disabled using a negative integer.
|
||||||
|
func NewSecureDirLoader(root string, dir string, maxSize int) SecureDirLoader {
|
||||||
|
if maxSize == 0 {
|
||||||
|
maxSize = DefaultMaxFileSize
|
||||||
|
}
|
||||||
|
return SecureDirLoader{
|
||||||
|
root: root,
|
||||||
|
dir: dir,
|
||||||
|
maxSize: maxSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load loads and returns the chart.Chart, or an error.
|
||||||
|
func (l SecureDirLoader) Load() (*chart.Chart, error) {
|
||||||
|
return SecureLoadDir(l.root, l.dir, l.maxSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecureLoadDir securely loads from a directory, without going outside root.
|
||||||
|
func SecureLoadDir(root, dir string, maxSize int) (*chart.Chart, error) {
|
||||||
|
root, err := filepath.Abs(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
topDir, err := filepath.Abs(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm topDir is actually relative to root
|
||||||
|
if _, err = isSecureSymlinkPath(root, topDir); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot load chart from dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just used for errors
|
||||||
|
c := &chart.Chart{}
|
||||||
|
|
||||||
|
// Get the absolute location of the .helmignore file
|
||||||
|
relDirPath, err := filepath.Rel(root, topDir)
|
||||||
|
if err != nil {
|
||||||
|
// We are not expected to be returning this error, as the above call to
|
||||||
|
// isSecureSymlinkPath already does the same. However, especially
|
||||||
|
// because we are dealing with security aspects here, we check it
|
||||||
|
// anyway in case this assumption changes.
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
iFile, err := securejoin.SecureJoin(root, filepath.Join(relDirPath, ignore.HelmIgnore))
|
||||||
|
|
||||||
|
// Load the .helmignore rules
|
||||||
|
rules := ignore.Empty()
|
||||||
|
if _, err = os.Stat(iFile); err == nil {
|
||||||
|
r, err := ignore.ParseFile(iFile)
|
||||||
|
if err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
rules = r
|
||||||
|
}
|
||||||
|
rules.AddDefaults()
|
||||||
|
|
||||||
|
var files []*loader.BufferedFile
|
||||||
|
topDir += string(filepath.Separator)
|
||||||
|
|
||||||
|
walk := func(name, absoluteName string, fi os.FileInfo, err error) error {
|
||||||
|
n := strings.TrimPrefix(name, topDir)
|
||||||
|
if n == "" {
|
||||||
|
// No need to process top level. Avoid bug with helmignore .* matching
|
||||||
|
// empty names. See issue 1779.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize to / since it will also work on Windows
|
||||||
|
n = filepath.ToSlash(n)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if fi.IsDir() {
|
||||||
|
// Directory-based ignore rules should involve skipping the entire
|
||||||
|
// contents of that directory.
|
||||||
|
if rules.Ignore(n, fi) {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
// Check after excluding ignores to provide the user with an option
|
||||||
|
// to opt-out from including certain paths.
|
||||||
|
if _, err := isSecureSymlinkPath(root, absoluteName); err != nil {
|
||||||
|
return fmt.Errorf("cannot load '%s' directory: %w", n, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a .helmignore file matches, skip this file.
|
||||||
|
if rules.Ignore(n, fi) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check after excluding ignores to provide the user with an option
|
||||||
|
// to opt-out from including certain paths.
|
||||||
|
if _, err := isSecureSymlinkPath(root, absoluteName); err != nil {
|
||||||
|
return fmt.Errorf("cannot load '%s' file: %w", n, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Irregular files include devices, sockets, and other uses of files that
|
||||||
|
// are not regular files. In Go they have a file mode type bit set.
|
||||||
|
// See https://golang.org/pkg/os/#FileMode for examples.
|
||||||
|
if !fi.Mode().IsRegular() {
|
||||||
|
return fmt.Errorf("cannot load irregular file %s as it has file mode type bits set", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileSize := fi.Size(); maxSize > 0 && fileSize > int64(maxSize) {
|
||||||
|
return fmt.Errorf("cannot load file %s as file size (%d) exceeds limit (%d)", n, fileSize, maxSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading %s: %w", n, err)
|
||||||
|
}
|
||||||
|
data = bytes.TrimPrefix(data, utf8bom)
|
||||||
|
|
||||||
|
files = append(files, &loader.BufferedFile{Name: n, Data: data})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err = sympath.Walk(topDir, walk); err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
return loader.LoadFiles(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSecureSymlinkPath attempts to make the given absolute path relative to
|
||||||
|
// root and securely joins this with root. If the result equals absolute path,
|
||||||
|
// it is safe to use.
|
||||||
|
func isSecureSymlinkPath(root, absPath string) (bool, error) {
|
||||||
|
root, absPath = filepath.Clean(root), filepath.Clean(absPath)
|
||||||
|
if root == "/" {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
unsafePath, err := filepath.Rel(root, absPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("cannot calculate path relative to root for resolved symlink")
|
||||||
|
}
|
||||||
|
safePath, err := securejoin.SecureJoin(root, unsafePath)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("cannot securely join root with resolved relative symlink path")
|
||||||
|
}
|
||||||
|
if safePath != absPath {
|
||||||
|
return false, fmt.Errorf("symlink traverses outside root boundary: relative path to root %s", unsafePath)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
|
@ -0,0 +1,82 @@
|
||||||
|
/*
|
||||||
|
Copyright 2022 The Flux authors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package secureloader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_isSecureSymlinkPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
root string
|
||||||
|
absPath string
|
||||||
|
safe bool
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "absolute path in root",
|
||||||
|
root: "/",
|
||||||
|
absPath: "/bar/",
|
||||||
|
safe: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "abs path not relative to root",
|
||||||
|
root: "/working/dir",
|
||||||
|
absPath: "/working/in/another/dir",
|
||||||
|
safe: false,
|
||||||
|
wantErr: "symlink traverses outside root boundary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "abs path relative to root",
|
||||||
|
root: "/working/dir/",
|
||||||
|
absPath: "/working/dir/path",
|
||||||
|
safe: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "illegal abs path",
|
||||||
|
root: "/working/dir",
|
||||||
|
absPath: "/working/dir/../but/not/really",
|
||||||
|
safe: false,
|
||||||
|
wantErr: "symlink traverses outside root boundary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "illegal root",
|
||||||
|
root: "working/dir/",
|
||||||
|
absPath: "/working/dir",
|
||||||
|
safe: false,
|
||||||
|
wantErr: "cannot calculate path relative to root for resolved symlink",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
g := NewWithT(t)
|
||||||
|
|
||||||
|
got, err := isSecureSymlinkPath(tt.root, tt.absPath)
|
||||||
|
g.Expect(got).To(Equal(tt.safe))
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
g.Expect(err).To(HaveOccurred())
|
||||||
|
g.Expect(err.Error()).To(ContainSubstring(tt.wantErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
/*
|
||||||
|
Copyright The Helm Authors.
|
||||||
|
Copyright 2022 The Flux authors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package secureloader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"helm.sh/helm/v3/pkg/chart"
|
||||||
|
"helm.sh/helm/v3/pkg/chart/loader"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileLoader is equal to Helm's.
|
||||||
|
// Redeclared to avoid having to deal with multiple package imports,
|
||||||
|
// possibly resulting in using the non-secure directory loader.
|
||||||
|
type FileLoader = loader.FileLoader
|
||||||
|
|
||||||
|
// LoadFile loads from an archive file.
|
||||||
|
func LoadFile(name string) (*chart.Chart, error) {
|
||||||
|
return loader.LoadFile(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadArchiveFiles reads in files out of an archive into memory. This function
|
||||||
|
// performs important path security checks and should always be used before
|
||||||
|
// expanding a tarball
|
||||||
|
func LoadArchiveFiles(in io.Reader) ([]*loader.BufferedFile, error) {
|
||||||
|
return loader.LoadArchiveFiles(in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadArchive loads from a reader containing a compressed tar archive.
|
||||||
|
func LoadArchive(in io.Reader) (*chart.Chart, error) {
|
||||||
|
return loader.LoadArchive(in)
|
||||||
|
}
|
|
@ -0,0 +1,76 @@
|
||||||
|
/*
|
||||||
|
Copyright The Helm Authors.
|
||||||
|
Copyright 2022 The Flux authors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package secureloader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
securejoin "github.com/cyphar/filepath-securejoin"
|
||||||
|
"helm.sh/helm/v3/pkg/chart"
|
||||||
|
"helm.sh/helm/v3/pkg/chart/loader"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Loader returns a new loader.ChartLoader appropriate for the given chart
|
||||||
|
// name. That being, SecureDirLoader when name is a directory, and
|
||||||
|
// FileLoader when it's a file.
|
||||||
|
// Name can be an absolute or relative path, but always has to be inside
|
||||||
|
// root.
|
||||||
|
func Loader(root, name string) (loader.ChartLoader, error) {
|
||||||
|
root, name = filepath.Clean(root), filepath.Clean(name)
|
||||||
|
relName := name
|
||||||
|
if filepath.IsAbs(relName) {
|
||||||
|
var err error
|
||||||
|
if relName, err = filepath.Rel(root, name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
secureName, err := securejoin.SecureJoin(root, relName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fi, err := os.Lstat(secureName)
|
||||||
|
if err != nil {
|
||||||
|
if pathErr := new(fs.PathError); errors.As(err, &pathErr) {
|
||||||
|
return nil, &fs.PathError{Op: pathErr.Op, Path: name, Err: pathErr.Err}
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if fi.IsDir() {
|
||||||
|
return NewSecureDirLoader(root, secureName, 0), nil
|
||||||
|
}
|
||||||
|
return FileLoader(secureName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load takes a string root and name, tries to resolve it to a file or directory,
|
||||||
|
// and then loads it securely without traversing outside of root.
|
||||||
|
//
|
||||||
|
// This is the preferred way to load a chart. It will discover the chart encoding
|
||||||
|
// and hand off to the appropriate chart reader.
|
||||||
|
//
|
||||||
|
// If a .helmignore file is present, the directory loader will skip loading any files
|
||||||
|
// matching it. But .helmignore is not evaluated when reading out of an archive.
|
||||||
|
func Load(root, name string) (*chart.Chart, error) {
|
||||||
|
l, err := Loader(root, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return l.Load()
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
Copyright 2022 The Flux authors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package secureloader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
"helm.sh/helm/v3/pkg/chart/loader"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoader(t *testing.T) {
|
||||||
|
g := NewWithT(t)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
fakeChart := filepath.Join(tmpDir, "fake.tgz")
|
||||||
|
g.Expect(os.WriteFile(fakeChart, []byte(""), 0o644)).To(Succeed())
|
||||||
|
|
||||||
|
got, err := Loader(tmpDir, fakeChart)
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
g.Expect(got).To(Equal(loader.FileLoader(fakeChart)))
|
||||||
|
|
||||||
|
fakeChartPath := filepath.Join(tmpDir, "fake")
|
||||||
|
g.Expect(os.Mkdir(fakeChartPath, 0o700)).To(Succeed())
|
||||||
|
got, err = Loader(tmpDir, "fake")
|
||||||
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
|
g.Expect(got).To(Equal(SecureDirLoader{root: tmpDir, dir: fakeChartPath, maxSize: DefaultMaxFileSize}))
|
||||||
|
|
||||||
|
symlinkRoot := filepath.Join(tmpDir, "symlink")
|
||||||
|
g.Expect(os.Mkdir(symlinkRoot, 0o700)).To(Succeed())
|
||||||
|
symlinkPath := filepath.Join(symlinkRoot, "fake.tgz")
|
||||||
|
g.Expect(os.Symlink(fakeChart, symlinkPath))
|
||||||
|
got, err = Loader(symlinkRoot, symlinkPath)
|
||||||
|
g.Expect(err).To(HaveOccurred())
|
||||||
|
g.Expect(err).To(BeAssignableToTypeOf(&fs.PathError{}))
|
||||||
|
g.Expect(got).To(BeNil())
|
||||||
|
}
|
Loading…
Reference in New Issue