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