Skip to content

Commit 6fc066b

Browse files
committed
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 <[email protected]>
1 parent 5ae30cb commit 6fc066b

File tree

23 files changed

+467
-0
lines changed

23 files changed

+467
-0
lines changed
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/*
2+
Copyright The Helm Authors.
3+
Copyright 2022 The Flux authors
4+
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
17+
This file has been derived from
18+
https://github.com/helm/helm/blob/v3.8.1/pkg/chart/loader/directory.go.
19+
20+
It has been modified to not blindly accept any resolved symlink path, but
21+
instead check it against the configured root before allowing it to be included.
22+
It also allows for capping the size of any file loaded into the chart.
23+
*/
24+
25+
package secureloader
26+
27+
import (
28+
"bytes"
29+
"fmt"
30+
"os"
31+
"path/filepath"
32+
"strings"
33+
34+
securejoin "github.com/cyphar/filepath-securejoin"
35+
"helm.sh/helm/v3/pkg/chart"
36+
"helm.sh/helm/v3/pkg/chart/loader"
37+
38+
"github.com/fluxcd/source-controller/internal/helm/chart/secureloader/ignore"
39+
"github.com/fluxcd/source-controller/internal/helm/chart/secureloader/sympath"
40+
)
41+
42+
var (
43+
// DefaultMaxFileSize is the default maximum file size of any chart file
44+
// loaded.
45+
DefaultMaxFileSize = 16 << 20 // 16MiB
46+
47+
utf8bom = []byte{0xEF, 0xBB, 0xBF}
48+
)
49+
50+
// SecureDirLoader securely loads a chart from a directory while resolving
51+
// symlinks without including files outside root.
52+
type SecureDirLoader struct {
53+
root string
54+
dir string
55+
maxSize int
56+
}
57+
58+
// NewSecureDirLoader returns a new SecureDirLoader, configured to the scope of the
59+
// root and provided dir. Max size configures the maximum size a file must not
60+
// exceed to be loaded. If 0 it defaults to defaultMaxFileSize, it can be
61+
// disabled using a negative integer.
62+
func NewSecureDirLoader(root string, dir string, maxSize int) SecureDirLoader {
63+
if maxSize == 0 {
64+
maxSize = DefaultMaxFileSize
65+
}
66+
return SecureDirLoader{
67+
root: root,
68+
dir: dir,
69+
maxSize: maxSize,
70+
}
71+
}
72+
73+
// Load loads and returns the chart.Chart, or an error.
74+
func (l SecureDirLoader) Load() (*chart.Chart, error) {
75+
return SecureLoadDir(l.root, l.dir, l.maxSize)
76+
}
77+
78+
// SecureLoadDir securely loads from a directory, without going outside root.
79+
func SecureLoadDir(root, dir string, maxSize int) (*chart.Chart, error) {
80+
root, err := filepath.Abs(root)
81+
if err != nil {
82+
return nil, err
83+
}
84+
85+
topDir, err := filepath.Abs(dir)
86+
if err != nil {
87+
return nil, err
88+
}
89+
90+
// Confirm topDir is actually relative to root
91+
if _, err = isSecureSymlinkPath(root, topDir); err != nil {
92+
return nil, fmt.Errorf("cannot load chart from dir: %w", err)
93+
}
94+
95+
// Just used for errors
96+
c := &chart.Chart{}
97+
98+
// Get the absolute location of the .helmignore file
99+
relDirPath, err := filepath.Rel(root, topDir)
100+
if err != nil {
101+
// We are not expected to be returning this error, as the above call to
102+
// isSecureSymlinkPath already does the same. However, especially
103+
// because we are dealing with security aspects here, we check it
104+
// anyway in case this assumption changes.
105+
return nil, err
106+
}
107+
iFile, err := securejoin.SecureJoin(root, filepath.Join(relDirPath, ignore.HelmIgnore))
108+
109+
// Load the .helmignore rules
110+
rules := ignore.Empty()
111+
if _, err = os.Stat(iFile); err == nil {
112+
r, err := ignore.ParseFile(iFile)
113+
if err != nil {
114+
return c, err
115+
}
116+
rules = r
117+
}
118+
rules.AddDefaults()
119+
120+
var files []*loader.BufferedFile
121+
topDir += string(filepath.Separator)
122+
123+
walk := func(name, absoluteName string, fi os.FileInfo, err error) error {
124+
n := strings.TrimPrefix(name, topDir)
125+
if n == "" {
126+
// No need to process top level. Avoid bug with helmignore .* matching
127+
// empty names. See issue 1779.
128+
return nil
129+
}
130+
131+
// Normalize to / since it will also work on Windows
132+
n = filepath.ToSlash(n)
133+
134+
if err != nil {
135+
return err
136+
}
137+
if fi.IsDir() {
138+
// Directory-based ignore rules should involve skipping the entire
139+
// contents of that directory.
140+
if rules.Ignore(n, fi) {
141+
return filepath.SkipDir
142+
}
143+
// Check after excluding ignores to provide the user with an option
144+
// to opt-out from including certain paths.
145+
if _, err := isSecureSymlinkPath(root, absoluteName); err != nil {
146+
return fmt.Errorf("cannot load '%s' directory: %w", n, err)
147+
}
148+
return nil
149+
}
150+
151+
// If a .helmignore file matches, skip this file.
152+
if rules.Ignore(n, fi) {
153+
return nil
154+
}
155+
156+
// Check after excluding ignores to provide the user with an option
157+
// to opt-out from including certain paths.
158+
if _, err := isSecureSymlinkPath(root, absoluteName); err != nil {
159+
return fmt.Errorf("cannot load '%s' file: %w", n, err)
160+
}
161+
162+
// Irregular files include devices, sockets, and other uses of files that
163+
// are not regular files. In Go they have a file mode type bit set.
164+
// See https://golang.org/pkg/os/#FileMode for examples.
165+
if !fi.Mode().IsRegular() {
166+
return fmt.Errorf("cannot load irregular file %s as it has file mode type bits set", n)
167+
}
168+
169+
if fileSize := fi.Size(); maxSize > 0 && fileSize > int64(maxSize) {
170+
return fmt.Errorf("cannot load file %s as file size (%d) exceeds limit (%d)", n, fileSize, maxSize)
171+
}
172+
173+
data, err := os.ReadFile(name)
174+
if err != nil {
175+
return fmt.Errorf("error reading %s: %w", n, err)
176+
}
177+
data = bytes.TrimPrefix(data, utf8bom)
178+
179+
files = append(files, &loader.BufferedFile{Name: n, Data: data})
180+
return nil
181+
}
182+
if err = sympath.Walk(topDir, walk); err != nil {
183+
return c, err
184+
}
185+
return loader.LoadFiles(files)
186+
}
187+
188+
// isSecureSymlinkPath attempts to make the given absolute path relative to
189+
// root and securely joins this with root. If the result equals absolute path,
190+
// it is safe to use.
191+
func isSecureSymlinkPath(root, absPath string) (bool, error) {
192+
root, absPath = filepath.Clean(root), filepath.Clean(absPath)
193+
if root == "/" {
194+
return true, nil
195+
}
196+
unsafePath, err := filepath.Rel(root, absPath)
197+
if err != nil {
198+
return false, fmt.Errorf("cannot calculate path relative to root for resolved symlink")
199+
}
200+
safePath, err := securejoin.SecureJoin(root, unsafePath)
201+
if err != nil {
202+
return false, fmt.Errorf("cannot securely join root with resolved relative symlink path")
203+
}
204+
if safePath != absPath {
205+
return false, fmt.Errorf("symlink traverses outside root boundary: relative path to root %s", unsafePath)
206+
}
207+
return true, nil
208+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
Copyright 2022 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package secureloader
18+
19+
import (
20+
"testing"
21+
22+
. "github.com/onsi/gomega"
23+
)
24+
25+
func Test_isSecureSymlinkPath(t *testing.T) {
26+
tests := []struct {
27+
name string
28+
root string
29+
absPath string
30+
safe bool
31+
wantErr string
32+
}{
33+
{
34+
name: "absolute path in root",
35+
root: "/",
36+
absPath: "/bar/",
37+
safe: true,
38+
},
39+
40+
{
41+
name: "abs path not relative to root",
42+
root: "/working/dir",
43+
absPath: "/working/in/another/dir",
44+
safe: false,
45+
wantErr: "symlink traverses outside root boundary",
46+
},
47+
{
48+
name: "abs path relative to root",
49+
root: "/working/dir/",
50+
absPath: "/working/dir/path",
51+
safe: true,
52+
},
53+
{
54+
name: "illegal abs path",
55+
root: "/working/dir",
56+
absPath: "/working/dir/../but/not/really",
57+
safe: false,
58+
wantErr: "symlink traverses outside root boundary",
59+
},
60+
{
61+
name: "illegal root",
62+
root: "working/dir/",
63+
absPath: "/working/dir",
64+
safe: false,
65+
wantErr: "cannot calculate path relative to root for resolved symlink",
66+
},
67+
}
68+
for _, tt := range tests {
69+
t.Run(tt.name, func(t *testing.T) {
70+
g := NewWithT(t)
71+
72+
got, err := isSecureSymlinkPath(tt.root, tt.absPath)
73+
g.Expect(got).To(Equal(tt.safe))
74+
if tt.wantErr != "" {
75+
g.Expect(err).To(HaveOccurred())
76+
g.Expect(err.Error()).To(ContainSubstring(tt.wantErr))
77+
return
78+
}
79+
g.Expect(err).ToNot(HaveOccurred())
80+
})
81+
}
82+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
Copyright The Helm Authors.
3+
Copyright 2022 The Flux authors
4+
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package secureloader
19+
20+
import (
21+
"io"
22+
23+
"helm.sh/helm/v3/pkg/chart"
24+
"helm.sh/helm/v3/pkg/chart/loader"
25+
)
26+
27+
// FileLoader is equal to Helm's.
28+
// Redeclared to avoid having to deal with multiple package imports,
29+
// possibly resulting in using the non-secure directory loader.
30+
type FileLoader = loader.FileLoader
31+
32+
// LoadFile loads from an archive file.
33+
func LoadFile(name string) (*chart.Chart, error) {
34+
return loader.LoadFile(name)
35+
}
36+
37+
// LoadArchiveFiles reads in files out of an archive into memory. This function
38+
// performs important path security checks and should always be used before
39+
// expanding a tarball
40+
func LoadArchiveFiles(in io.Reader) ([]*loader.BufferedFile, error) {
41+
return loader.LoadArchiveFiles(in)
42+
}
43+
44+
// LoadArchive loads from a reader containing a compressed tar archive.
45+
func LoadArchive(in io.Reader) (*chart.Chart, error) {
46+
return loader.LoadArchive(in)
47+
}
File renamed without changes.
File renamed without changes.
File renamed without changes.

internal/helm/chart/loader/ignore/testdata/.helmignore renamed to internal/helm/chart/secureloader/ignore/testdata/.helmignore

File renamed without changes.
File renamed without changes.
File renamed without changes.

internal/helm/chart/loader/ignore/testdata/cargo/a.txt renamed to internal/helm/chart/secureloader/ignore/testdata/cargo/a.txt

File renamed without changes.

0 commit comments

Comments
 (0)