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:
Hidde Beydals 2022-04-08 11:33:52 +02:00
parent 5ae30cb4aa
commit 6fc066b1b6
23 changed files with 467 additions and 0 deletions

View File

@ -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
}

View File

@ -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())
})
}
}

View File

@ -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)
}

View File

@ -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()
}

View File

@ -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())
}