Merge pull request #663 from fluxcd/helm-safe-dir-loader

This commit is contained in:
Hidde Beydals 2022-04-11 12:21:49 +02:00 committed by GitHub
commit 711780cdf9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1727 additions and 62 deletions

View File

@ -28,7 +28,6 @@ import (
"strings"
"time"
securejoin "github.com/cyphar/filepath-securejoin"
helmgetter "helm.sh/helm/v3/pkg/getter"
helmrepo "helm.sh/helm/v3/pkg/repo"
corev1 "k8s.io/api/core/v1"
@ -609,18 +608,6 @@ func (r *HelmChartReconciler) buildFromTarballArtifact(ctx context.Context, obj
}
}
// Calculate (secure) absolute chart path
chartPath, err := securejoin.SecureJoin(sourceDir, obj.Spec.Chart)
if err != nil {
e := &serror.Stalling{
Err: fmt.Errorf("path calculation for chart '%s' failed: %w", obj.Spec.Chart, err),
Reason: "IllegalPath",
}
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error())
// We are unable to recover from this change without a change in generation
return sreconcile.ResultEmpty, e
}
// Setup dependency manager
dm := chart.NewDependencyManager(
chart.WithRepositoryCallback(r.namespacedChartRepositoryCallback(ctx, obj.GetNamespace())),
@ -673,7 +660,7 @@ func (r *HelmChartReconciler) buildFromTarballArtifact(ctx context.Context, obj
cb := chart.NewLocalBuilder(dm)
build, err := cb.Build(ctx, chart.LocalReference{
WorkDir: sourceDir,
Path: chartPath,
Path: obj.Spec.Chart,
}, util.TempPathForObj("", ".tgz", obj), opts)
if err != nil {
return sreconcile.ResultEmpty, err

View File

@ -43,16 +43,25 @@ type LocalReference struct {
// WorkDir used as chroot during build operations.
// File references are not allowed to traverse outside it.
WorkDir string
// Path of the chart on the local filesystem.
// Path of the chart on the local filesystem relative to WorkDir.
Path string
}
// Validate returns an error if the LocalReference does not have
// a Path set.
func (r LocalReference) Validate() error {
if r.WorkDir == "" {
return fmt.Errorf("no work dir set for local chart reference")
}
if r.Path == "" {
return fmt.Errorf("no path set for local chart reference")
}
if !filepath.IsAbs(r.WorkDir) {
return fmt.Errorf("local chart reference work dir is expected to be absolute")
}
if filepath.IsAbs(r.Path) {
return fmt.Errorf("local chart reference path is expected to be relative")
}
return nil
}

View File

@ -24,10 +24,11 @@ import (
"github.com/Masterminds/semver/v3"
securejoin "github.com/cyphar/filepath-securejoin"
"helm.sh/helm/v3/pkg/chart/loader"
"sigs.k8s.io/yaml"
"github.com/fluxcd/pkg/runtime/transform"
"github.com/fluxcd/source-controller/internal/helm/chart/secureloader"
)
type localChartBuilder struct {
@ -75,7 +76,11 @@ func (b *localChartBuilder) Build(ctx context.Context, ref Reference, p string,
// Load the chart metadata from the LocalReference to ensure it points
// to a chart
curMeta, err := LoadChartMetadata(localRef.Path)
securePath, err := securejoin.SecureJoin(localRef.WorkDir, localRef.Path)
if err != nil {
return nil, &BuildError{Reason: ErrChartReference, Err: err}
}
curMeta, err := LoadChartMetadata(securePath)
if err != nil {
return nil, &BuildError{Reason: ErrChartReference, Err: err}
}
@ -101,7 +106,7 @@ func (b *localChartBuilder) Build(ctx context.Context, ref Reference, p string,
result.Version = ver.String()
}
isChartDir := pathIsDir(localRef.Path)
isChartDir := pathIsDir(securePath)
requiresPackaging := isChartDir || opts.VersionMetadata != "" || len(opts.GetValuesFiles()) != 0
// If all the following is true, we do not need to package the chart:
@ -127,7 +132,7 @@ func (b *localChartBuilder) Build(ctx context.Context, ref Reference, p string,
// If the chart at the path is already packaged and no custom values files
// options are set, we can copy the chart without making modifications
if !requiresPackaging {
if err = copyFileToPath(localRef.Path, p); err != nil {
if err = copyFileToPath(securePath, p); err != nil {
return result, &BuildError{Reason: ErrChartPull, Err: err}
}
result.Path = p
@ -145,15 +150,16 @@ func (b *localChartBuilder) Build(ctx context.Context, ref Reference, p string,
// At this point we are certain we need to load the chart;
// either to package it because it originates from a directory,
// or because we have merged values and need to repackage
chart, err := loader.Load(localRef.Path)
loadedChart, err := secureloader.Load(localRef.WorkDir, localRef.Path)
if err != nil {
return result, &BuildError{Reason: ErrChartPackage, Err: err}
}
// Set earlier resolved version (with metadata)
chart.Metadata.Version = result.Version
loadedChart.Metadata.Version = result.Version
// Overwrite default values with merged values, if any
if ok, err = OverwriteChartDefaultValues(chart, mergedValues); ok || err != nil {
if ok, err = OverwriteChartDefaultValues(loadedChart, mergedValues); ok || err != nil {
if err != nil {
return result, &BuildError{Reason: ErrValuesFilesMerge, Err: err}
}
@ -166,13 +172,13 @@ func (b *localChartBuilder) Build(ctx context.Context, ref Reference, p string,
err = fmt.Errorf("local chart builder requires dependency manager for unpackaged charts")
return result, &BuildError{Reason: ErrDependencyBuild, Err: err}
}
if result.ResolvedDependencies, err = b.dm.Build(ctx, ref, chart); err != nil {
if result.ResolvedDependencies, err = b.dm.Build(ctx, ref, loadedChart); err != nil {
return result, &BuildError{Reason: ErrDependencyBuild, Err: err}
}
}
// Package the chart
if err = packageToPath(chart, p); err != nil {
if err = packageToPath(loadedChart, p); err != nil {
return result, &BuildError{Reason: ErrChartPackage, Err: err}
}
result.Path = p

View File

@ -26,10 +26,10 @@ import (
. "github.com/onsi/gomega"
"github.com/otiai10/copy"
helmchart "helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/chart/loader"
"helm.sh/helm/v3/pkg/chartutil"
"helm.sh/helm/v3/pkg/repo"
"github.com/fluxcd/source-controller/internal/helm/chart/secureloader"
"github.com/fluxcd/source-controller/internal/helm/repository"
)
@ -86,31 +86,31 @@ func TestLocalBuilder_Build(t *testing.T) {
},
{
name: "invalid local reference - no file",
reference: LocalReference{Path: "/tmp/non-existent-path.xyz"},
reference: LocalReference{WorkDir: "/tmp", Path: "non-existent-path.xyz"},
wantErr: "no such file or directory",
},
{
name: "invalid version metadata",
reference: LocalReference{Path: "./../testdata/charts/helmchart"},
reference: LocalReference{Path: "../testdata/charts/helmchart"},
buildOpts: BuildOptions{VersionMetadata: "^"},
wantErr: "Invalid Metadata string",
},
{
name: "with version metadata",
reference: LocalReference{Path: "./../testdata/charts/helmchart"},
reference: LocalReference{Path: "../testdata/charts/helmchart"},
buildOpts: BuildOptions{VersionMetadata: "foo"},
wantVersion: "0.1.0+foo",
wantPackaged: true,
},
{
name: "already packaged chart",
reference: LocalReference{Path: "./../testdata/charts/helmchart-0.1.0.tgz"},
reference: LocalReference{Path: "../testdata/charts/helmchart-0.1.0.tgz"},
wantVersion: "0.1.0",
wantPackaged: false,
},
{
name: "default values",
reference: LocalReference{Path: "./../testdata/charts/helmchart"},
reference: LocalReference{Path: "../testdata/charts/helmchart"},
wantValues: chartutil.Values{
"replicaCount": float64(1),
},
@ -119,7 +119,7 @@ func TestLocalBuilder_Build(t *testing.T) {
},
{
name: "with values files",
reference: LocalReference{Path: "./../testdata/charts/helmchart"},
reference: LocalReference{Path: "../testdata/charts/helmchart"},
buildOpts: BuildOptions{
ValuesFiles: []string{"custom-values1.yaml", "custom-values2.yaml"},
},
@ -145,7 +145,7 @@ fullnameOverride: "full-foo-name-override"`),
},
{
name: "chart with dependencies",
reference: LocalReference{Path: "./../testdata/charts/helmchartwithdeps"},
reference: LocalReference{Path: "../testdata/charts/helmchartwithdeps"},
repositories: map[string]*repository.ChartRepository{
"https://grafana.github.io/helm-charts/": mockRepo(),
},
@ -164,11 +164,11 @@ fullnameOverride: "full-foo-name-override"`),
},
{
name: "v1 chart with dependencies",
reference: LocalReference{Path: "./../testdata/charts/helmchartwithdeps-v1"},
reference: LocalReference{Path: "../testdata/charts/helmchartwithdeps-v1"},
repositories: map[string]*repository.ChartRepository{
"https://grafana.github.io/helm-charts/": mockRepo(),
},
dependentChartPaths: []string{"./../testdata/charts/helmchart-v1"},
dependentChartPaths: []string{"../testdata/charts/helmchart-v1"},
wantVersion: "0.3.0",
wantPackaged: true,
},
@ -184,13 +184,23 @@ fullnameOverride: "full-foo-name-override"`),
// Only if the reference is a LocalReference, set the WorkDir.
localRef, ok := tt.reference.(LocalReference)
if ok {
// If the source chart path is valid, copy it into the workdir
// and update the localRef.Path with the copied local chart
// path.
if localRef.Path != "" {
_, err := os.Lstat(localRef.Path)
if err == nil {
helmchartDir := filepath.Join(workDir, "testdata", "charts", filepath.Base(localRef.Path))
g.Expect(copy.Copy(localRef.Path, helmchartDir)).ToNot(HaveOccurred())
}
}
localRef.WorkDir = workDir
tt.reference = localRef
}
// Write value file in the base dir.
for _, f := range tt.valuesFiles {
vPath := filepath.Join(workDir, f.Name)
vPath := filepath.Join(localRef.WorkDir, f.Name)
g.Expect(os.WriteFile(vPath, f.Data, 0644)).ToNot(HaveOccurred())
}
@ -223,7 +233,7 @@ fullnameOverride: "full-foo-name-override"`),
g.Expect(cb.Path).ToNot(BeEmpty(), "empty Build.Path")
// Load the resulting chart and verify the values.
resultChart, err := loader.Load(cb.Path)
resultChart, err := secureloader.LoadFile(cb.Path)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(resultChart.Metadata.Version).To(Equal(tt.wantVersion))
@ -241,7 +251,7 @@ func TestLocalBuilder_Build_CachedChart(t *testing.T) {
g.Expect(err).ToNot(HaveOccurred())
defer os.RemoveAll(workDir)
reference := LocalReference{Path: "./../testdata/charts/helmchart"}
testChartPath := "./../testdata/charts/helmchart"
dm := NewDependencyManager()
b := NewLocalBuilder(dm)
@ -250,6 +260,11 @@ func TestLocalBuilder_Build_CachedChart(t *testing.T) {
g.Expect(err).ToNot(HaveOccurred())
defer os.RemoveAll(tmpDir)
// Copy the source chart into the workdir.
g.Expect(copy.Copy(testChartPath, filepath.Join(workDir, "testdata", "charts", filepath.Base("helmchart")))).ToNot(HaveOccurred())
reference := LocalReference{WorkDir: workDir, Path: testChartPath}
// Build first time.
targetPath := filepath.Join(tmpDir, "chart1.tgz")
buildOpts := BuildOptions{}

View File

@ -25,13 +25,13 @@ import (
"github.com/Masterminds/semver/v3"
helmchart "helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/chart/loader"
"helm.sh/helm/v3/pkg/chartutil"
"sigs.k8s.io/yaml"
"github.com/fluxcd/pkg/runtime/transform"
"github.com/fluxcd/source-controller/internal/fs"
"github.com/fluxcd/source-controller/internal/helm/chart/secureloader"
"github.com/fluxcd/source-controller/internal/helm/repository"
)
@ -145,7 +145,7 @@ func (b *remoteChartBuilder) Build(_ context.Context, ref Reference, p string, o
// Load the chart and merge chart values
var chart *helmchart.Chart
if chart, err = loader.LoadArchive(res); err != nil {
if chart, err = secureloader.LoadArchive(res); err != nil {
err = fmt.Errorf("failed to load downloaded chart: %w", err)
return result, &BuildError{Reason: ErrChartPackage, Err: err}
}

View File

@ -27,10 +27,10 @@ import (
. "github.com/onsi/gomega"
helmchart "helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/chart/loader"
"helm.sh/helm/v3/pkg/chartutil"
helmgetter "helm.sh/helm/v3/pkg/getter"
"github.com/fluxcd/source-controller/internal/helm/chart/secureloader"
"github.com/fluxcd/source-controller/internal/helm/repository"
)
@ -186,7 +186,7 @@ entries:
g.Expect(cb.Path).ToNot(BeEmpty(), "empty Build.Path")
// Load the resulting chart and verify the values.
resultChart, err := loader.Load(cb.Path)
resultChart, err := secureloader.LoadFile(cb.Path)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(resultChart.Metadata.Version).To(Equal(tt.wantVersion))

View File

@ -24,8 +24,9 @@ import (
"testing"
. "github.com/onsi/gomega"
"helm.sh/helm/v3/pkg/chart/loader"
"helm.sh/helm/v3/pkg/chartutil"
"github.com/fluxcd/source-controller/internal/helm/chart/secureloader"
)
func TestLocalReference_Validate(t *testing.T) {
@ -35,18 +36,29 @@ func TestLocalReference_Validate(t *testing.T) {
wantErr string
}{
{
name: "ref with path",
ref: LocalReference{Path: "/a/path"},
name: "ref with path and work dir",
ref: LocalReference{WorkDir: "/workdir/", Path: "./a/path"},
},
{
name: "ref with path and work dir",
ref: LocalReference{Path: "/a/path", WorkDir: "/with/a/workdir"},
name: "ref without work dir",
ref: LocalReference{Path: "/a/path"},
wantErr: "no work dir set for local chart reference",
},
{
name: "ref with relative work dir",
ref: LocalReference{WorkDir: "../a/path", Path: "foo"},
wantErr: "local chart reference work dir is expected to be absolute",
},
{
name: "ref without path",
ref: LocalReference{WorkDir: "/just/a/workdir"},
wantErr: "no path set for local chart reference",
},
{
name: "ref with an absolute path",
ref: LocalReference{WorkDir: "/a/path", Path: "/foo"},
wantErr: "local chart reference path is expected to be relative",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -210,7 +222,7 @@ func TestChartBuildResult_String(t *testing.T) {
func Test_packageToPath(t *testing.T) {
g := NewWithT(t)
chart, err := loader.Load("../testdata/charts/helmchart-0.1.0.tgz")
chart, err := secureloader.LoadFile("../testdata/charts/helmchart-0.1.0.tgz")
g.Expect(err).ToNot(HaveOccurred())
g.Expect(chart).ToNot(BeNil())
@ -219,7 +231,7 @@ func Test_packageToPath(t *testing.T) {
err = packageToPath(chart, out)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(out).To(BeARegularFile())
_, err = loader.Load(out)
_, err = secureloader.LoadFile(out)
g.Expect(err).ToNot(HaveOccurred())
}

View File

@ -30,8 +30,8 @@ import (
"golang.org/x/sync/errgroup"
"golang.org/x/sync/semaphore"
helmchart "helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/chart/loader"
"github.com/fluxcd/source-controller/internal/helm/chart/secureloader"
"github.com/fluxcd/source-controller/internal/helm/repository"
)
@ -191,7 +191,7 @@ func (dm *DependencyManager) addLocalDependency(ref LocalReference, c *chartWith
if _, err := os.Stat(sLocalChartPath); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("no chart found at '%s' (reference '%s')", sLocalChartPath, dep.Repository)
return fmt.Errorf("no chart found at '%s' (reference '%s')", strings.TrimPrefix(sLocalChartPath, ref.WorkDir), dep.Repository)
}
return err
}
@ -202,7 +202,7 @@ func (dm *DependencyManager) addLocalDependency(ref LocalReference, c *chartWith
return err
}
ch, err := loader.Load(sLocalChartPath)
ch, err := secureloader.Load(ref.WorkDir, sLocalChartPath)
if err != nil {
return fmt.Errorf("failed to load chart from '%s' (reference '%s'): %w",
strings.TrimPrefix(sLocalChartPath, ref.WorkDir), dep.Repository, err)
@ -245,7 +245,7 @@ func (dm *DependencyManager) addRemoteDependency(chart *chartWithLock, dep *helm
if err != nil {
return fmt.Errorf("chart download of version '%s' failed: %w", ver.Version, err)
}
ch, err := loader.LoadArchive(res)
ch, err := secureloader.LoadArchive(res)
if err != nil {
return fmt.Errorf("failed to load downloaded archive of version '%s': %w", ver.Version, err)
}
@ -290,11 +290,7 @@ func (dm *DependencyManager) secureLocalChartPath(ref LocalReference, dep *helmc
if localUrl.Scheme != "" && localUrl.Scheme != "file" {
return "", fmt.Errorf("'%s' is not a local chart reference", dep.Repository)
}
relPath, err := filepath.Rel(ref.WorkDir, ref.Path)
if err != nil {
relPath = ref.Path
}
return securejoin.SecureJoin(ref.WorkDir, filepath.Join(relPath, localUrl.Host, localUrl.Path))
return securejoin.SecureJoin(ref.WorkDir, filepath.Join(ref.Path, localUrl.Host, localUrl.Path))
}
// collectMissing returns a map with dependencies from reqs that are missing

View File

@ -28,10 +28,10 @@ import (
. "github.com/onsi/gomega"
helmchart "helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/chart/loader"
helmgetter "helm.sh/helm/v3/pkg/getter"
"helm.sh/helm/v3/pkg/repo"
"github.com/fluxcd/source-controller/internal/helm/chart/secureloader"
"github.com/fluxcd/source-controller/internal/helm/repository"
)
@ -166,14 +166,16 @@ func TestDependencyManager_Build(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
chart, err := loader.Load(filepath.Join(tt.baseDir, tt.path))
chart, err := secureloader.Load(tt.baseDir, tt.path)
g.Expect(err).ToNot(HaveOccurred())
dm := NewDependencyManager(
WithRepositories(tt.repositories),
WithRepositoryCallback(tt.getChartRepositoryCallback),
)
got, err := dm.Build(context.TODO(), LocalReference{WorkDir: tt.baseDir, Path: tt.path}, chart)
absBaseDir, err := filepath.Abs(tt.baseDir)
g.Expect(err).ToNot(HaveOccurred())
got, err := dm.Build(context.TODO(), LocalReference{WorkDir: absBaseDir, Path: tt.path}, chart)
if tt.wantErr != "" {
g.Expect(err).To(HaveOccurred())
@ -262,7 +264,7 @@ func TestDependencyManager_addLocalDependency(t *testing.T) {
Version: chartVersion,
Repository: "file://../../../absolutely/invalid",
},
wantErr: "no chart found at '../testdata/charts/absolutely/invalid'",
wantErr: "no chart found at '/absolutely/invalid'",
},
{
name: "invalid chart archive",
@ -289,7 +291,11 @@ func TestDependencyManager_addLocalDependency(t *testing.T) {
dm := NewDependencyManager()
chart := &helmchart.Chart{}
err := dm.addLocalDependency(LocalReference{WorkDir: "../testdata/charts", Path: "helmchartwithdeps"},
absWorkDir, err := filepath.Abs("../testdata/charts")
g.Expect(err).ToNot(HaveOccurred())
err = dm.addLocalDependency(LocalReference{WorkDir: absWorkDir, Path: "helmchartwithdeps"},
&chartWithLock{Chart: chart}, tt.dep)
if tt.wantErr != "" {
g.Expect(err).To(HaveOccurred())

View File

@ -0,0 +1,253 @@
/*
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"
"errors"
"fmt"
"io/fs"
"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"
"github.com/fluxcd/source-controller/internal/helm/chart/secureloader/ignore"
"github.com/fluxcd/source-controller/internal/helm/chart/secureloader/sympath"
)
var (
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
path string
maxSize int64
}
// 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 helm.MaxChartFileSize, it can be
// disabled using a negative integer.
func NewSecureDirLoader(root string, path string, maxSize int64) SecureDirLoader {
if maxSize == 0 {
maxSize = helm.MaxChartFileSize
}
return SecureDirLoader{
root: root,
path: path,
maxSize: maxSize,
}
}
// Load loads and returns the chart.Chart, or an error.
func (l SecureDirLoader) Load() (*chart.Chart, error) {
return SecureLoadDir(l.root, l.path, l.maxSize)
}
// SecureLoadDir securely loads a chart from the path relative to root, without
// traversing outside root. When maxSize >= 0, files are not allowed to exceed
// this size, or an error is returned.
func SecureLoadDir(root, path string, maxSize int64) (*chart.Chart, error) {
root, err := filepath.Abs(root)
if err != nil {
return nil, err
}
// Ensure path is relative
if filepath.IsAbs(path) {
relChartPath, err := filepath.Rel(root, path)
if err != nil {
return nil, err
}
path = relChartPath
}
// Resolve secure absolute path
absChartName, err := securejoin.SecureJoin(root, path)
if err != nil {
return nil, err
}
// Load ignore rules
rules, err := secureLoadIgnoreRules(root, path)
if err != nil {
return nil, fmt.Errorf("cannot load ignore rules for chart: %w", err)
}
// Lets go for a walk...
fileWalker := newSecureFileWalker(root, absChartName, maxSize, rules)
if err = sympath.Walk(fileWalker.absChartPath, fileWalker.walk); err != nil {
return nil, fmt.Errorf("failed to load files from %s: %w", strings.TrimPrefix(fileWalker.absChartPath, fileWalker.root), err)
}
loaded, err := loader.LoadFiles(fileWalker.files)
if err != nil {
return nil, fmt.Errorf("failed to load chart from %s: %w", strings.TrimPrefix(fileWalker.absChartPath, fileWalker.root), err)
}
return loaded, nil
}
// secureLoadIgnoreRules attempts to load the ignore.HelmIgnore file from the
// chart path relative to root. If the file is a symbolic link, it is evaluated
// with the given root treated as root of the filesystem.
// If the ignore file does not exist, or points to a location outside of root,
// default ignore.Rules are returned. Any error other than fs.ErrNotExist is
// returned.
func secureLoadIgnoreRules(root, chartPath string) (*ignore.Rules, error) {
rules := ignore.Empty()
iFile, err := securejoin.SecureJoin(root, filepath.Join(chartPath, ignore.HelmIgnore))
if err != nil {
return nil, err
}
_, err = os.Stat(iFile)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, err
}
if err == nil {
if rules, err = ignore.ParseFile(iFile); err != nil {
return nil, err
}
}
rules.AddDefaults()
return rules, nil
}
// secureFileWalker does the actual walking over the directory, any file loaded
// by walk is appended to files.
type secureFileWalker struct {
root string
absChartPath string
maxSize int64
rules *ignore.Rules
files []*loader.BufferedFile
}
func newSecureFileWalker(root, absChartPath string, maxSize int64, rules *ignore.Rules) *secureFileWalker {
absChartPath = filepath.Clean(absChartPath) + string(filepath.Separator)
return &secureFileWalker{
root: root,
absChartPath: absChartPath,
maxSize: maxSize,
rules: rules,
files: make([]*loader.BufferedFile, 0),
}
}
func (w *secureFileWalker) walk(name, absName string, fi os.FileInfo, err error) error {
n := strings.TrimPrefix(name, w.absChartPath)
if n == "" {
// No need to process top level. Avoid bug with helmignore .* matching
// empty names. See issue 1779.
return nil
}
if err != nil {
return err
}
// Normalize to / since it will also work on Windows
n = filepath.ToSlash(n)
if fi.IsDir() {
// Directory-based ignore rules should involve skipping the entire
// contents of that directory.
if w.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 := isSecureAbsolutePath(w.root, absName); err != nil {
return fmt.Errorf("cannot load '%s' directory: %w", n, err)
}
return nil
}
// If a .helmignore file matches, skip this file.
if w.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 := isSecureAbsolutePath(w.root, absName); 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)
}
// Confirm size it not outside boundaries
if fileSize := fi.Size(); w.maxSize > 0 && fileSize > w.maxSize {
return fmt.Errorf("cannot load file %s as file size (%d) exceeds limit (%d)", n, fileSize, w.maxSize)
}
data, err := os.ReadFile(absName)
if err != nil {
if pathErr := new(fs.PathError); errors.As(err, &pathErr) {
err = &fs.PathError{Op: pathErr.Op, Path: strings.TrimPrefix(absName, w.root), Err: pathErr.Err}
}
return fmt.Errorf("error reading %s: %w", n, err)
}
data = bytes.TrimPrefix(data, utf8bom)
w.files = append(w.files, &loader.BufferedFile{Name: n, Data: data})
return nil
}
// isSecureAbsolutePath 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 isSecureAbsolutePath(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 absolute path")
}
safePath, err := securejoin.SecureJoin(root, unsafePath)
if err != nil {
return false, fmt.Errorf("cannot securely join root with resolved relative path")
}
if safePath != absPath {
return false, fmt.Errorf("absolute path traverses outside root boundary: relative path to root %s", unsafePath)
}
return true, nil
}

View File

@ -0,0 +1,421 @@
/*
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"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
. "github.com/onsi/gomega"
"helm.sh/helm/v3/pkg/chart"
"sigs.k8s.io/yaml"
"github.com/fluxcd/source-controller/internal/helm"
"github.com/fluxcd/source-controller/internal/helm/chart/secureloader/ignore"
)
func TestSecureDirLoader_Load(t *testing.T) {
metadata := chart.Metadata{
Name: "test",
APIVersion: "v2",
Version: "1.0",
Type: "application",
}
t.Run("chart", func(t *testing.T) {
g := NewWithT(t)
tmpDir := t.TempDir()
m := metadata
b, err := yaml.Marshal(&m)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(os.WriteFile(filepath.Join(tmpDir, "Chart.yaml"), b, 0o644)).To(Succeed())
got, err := (NewSecureDirLoader(tmpDir, "", helm.MaxChartFileSize)).Load()
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got).ToNot(BeNil())
g.Expect(got.Name()).To(Equal(m.Name))
})
t.Run("chart with absolute path", func(t *testing.T) {
g := NewWithT(t)
tmpDir := t.TempDir()
m := metadata
b, err := yaml.Marshal(&m)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(os.WriteFile(filepath.Join(tmpDir, "Chart.yaml"), b, 0o644)).To(Succeed())
got, err := (NewSecureDirLoader(tmpDir, tmpDir, helm.MaxChartFileSize)).Load()
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got).ToNot(BeNil())
g.Expect(got.Name()).To(Equal(m.Name))
})
t.Run("chart with illegal path", func(t *testing.T) {
g := NewWithT(t)
tmpDir := t.TempDir()
m := metadata
b, err := yaml.Marshal(&m)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(os.WriteFile(filepath.Join(tmpDir, "Chart.yaml"), b, 0o644)).To(Succeed())
root := filepath.Join(tmpDir, "root")
g.Expect(os.Mkdir(root, 0o700)).To(Succeed())
got, err := (NewSecureDirLoader(root, "../", helm.MaxChartFileSize)).Load()
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring("failed to load chart from /: Chart.yaml file is missing"))
g.Expect(got).To(BeNil())
got, err = (NewSecureDirLoader(root, tmpDir, helm.MaxChartFileSize)).Load()
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring("failed to load chart from /: Chart.yaml file is missing"))
g.Expect(got).To(BeNil())
})
t.Run("chart with .helmignore", func(t *testing.T) {
g := NewWithT(t)
tmpDir := t.TempDir()
m := metadata
b, err := yaml.Marshal(&m)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(os.WriteFile(filepath.Join(tmpDir, "Chart.yaml"), b, 0o644)).To(Succeed())
g.Expect(os.WriteFile(filepath.Join(tmpDir, ignore.HelmIgnore), []byte("file.txt"), 0o644)).To(Succeed())
g.Expect(os.WriteFile(filepath.Join(tmpDir, "file.txt"), []byte("not included"), 0o644)).To(Succeed())
got, err := (NewSecureDirLoader(tmpDir, "", helm.MaxChartFileSize)).Load()
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got).ToNot(BeNil())
g.Expect(got.Name()).To(Equal(m.Name))
g.Expect(got.Raw).To(HaveLen(2))
})
}
func Test_secureLoadIgnoreRules(t *testing.T) {
t.Run("defaults", func(t *testing.T) {
g := NewWithT(t)
r, err := secureLoadIgnoreRules("/workdir", "")
g.Expect(err).ToNot(HaveOccurred())
g.Expect(r.Ignore("file.txt", nil)).To(BeFalse())
g.Expect(r.Ignore("templates/.dotfile", nil)).To(BeTrue())
})
t.Run("with "+ignore.HelmIgnore, func(t *testing.T) {
g := NewWithT(t)
tmpDir := t.TempDir()
g.Expect(os.WriteFile(filepath.Join(tmpDir, ignore.HelmIgnore), []byte("file.txt"), 0o644)).To(Succeed())
r, err := secureLoadIgnoreRules(tmpDir, "")
g.Expect(err).ToNot(HaveOccurred())
g.Expect(r.Ignore("file.txt", nil)).To(BeTrue())
g.Expect(r.Ignore("templates/.dotfile", nil)).To(BeTrue())
g.Expect(r.Ignore("other.txt", nil)).To(BeFalse())
})
t.Run("with chart path and "+ignore.HelmIgnore, func(t *testing.T) {
g := NewWithT(t)
tmpDir := t.TempDir()
chartPath := "./sub/chart"
g.Expect(os.MkdirAll(filepath.Join(tmpDir, chartPath), 0o700)).To(Succeed())
g.Expect(os.WriteFile(filepath.Join(tmpDir, chartPath, ignore.HelmIgnore), []byte("file.txt"), 0o644)).To(Succeed())
r, err := secureLoadIgnoreRules(tmpDir, chartPath)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(r.Ignore("file.txt", nil)).To(BeTrue())
})
t.Run("with relative "+ignore.HelmIgnore+" symlink", func(t *testing.T) {
g := NewWithT(t)
tmpDir := t.TempDir()
chartPath := "sub/chart"
g.Expect(os.MkdirAll(filepath.Join(tmpDir, chartPath), 0o700)).To(Succeed())
g.Expect(os.WriteFile(filepath.Join(tmpDir, "symlink"), []byte("file.txt"), 0o644)).To(Succeed())
g.Expect(os.Symlink("../../symlink", filepath.Join(tmpDir, chartPath, ignore.HelmIgnore)))
r, err := secureLoadIgnoreRules(tmpDir, chartPath)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(r.Ignore("file.txt", nil)).To(BeTrue())
})
t.Run("with illegal "+ignore.HelmIgnore+" symlink", func(t *testing.T) {
g := NewWithT(t)
tmpDir := t.TempDir()
chartPath := "/sub/chart"
g.Expect(os.MkdirAll(filepath.Join(tmpDir, chartPath), 0o700)).To(Succeed())
g.Expect(os.WriteFile(filepath.Join(tmpDir, "symlink"), []byte("file.txt"), 0o644)).To(Succeed())
g.Expect(os.Symlink("../../symlink", filepath.Join(tmpDir, chartPath, ignore.HelmIgnore)))
r, err := secureLoadIgnoreRules(filepath.Join(tmpDir, chartPath), "")
g.Expect(err).ToNot(HaveOccurred())
g.Expect(r.Ignore("templates/.dotfile", nil)).To(BeTrue())
g.Expect(r.Ignore("file.txt", nil)).To(BeFalse())
})
t.Run("with "+ignore.HelmIgnore+" parsing error", func(t *testing.T) {
g := NewWithT(t)
tmpDir := t.TempDir()
g.Expect(os.WriteFile(filepath.Join(tmpDir, ignore.HelmIgnore), []byte("**"), 0o644)).To(Succeed())
_, err := secureLoadIgnoreRules(tmpDir, "")
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring("syntax is not supported"))
})
}
func Test_secureFileWalker_walk(t *testing.T) {
g := NewWithT(t)
const (
root = "/fake/root"
chartPath = "/fake/root/dir"
)
fakeDirName := "fake-dir"
fakeFileName := "fake-file"
fakeDeviceFileName := "fake-device"
fakeFS := fstest.MapFS{
fakeDirName: &fstest.MapFile{Mode: fs.ModeDir},
fakeFileName: &fstest.MapFile{Data: []byte("a couple bytes")},
fakeDeviceFileName: &fstest.MapFile{Mode: fs.ModeDevice},
}
// Safe to further re-use this for other paths
fakeDirInfo, err := fakeFS.Stat(fakeDirName)
g.Expect(err).ToNot(HaveOccurred())
fakeFileInfo, err := fakeFS.Stat(fakeFileName)
g.Expect(err).ToNot(HaveOccurred())
fakeDeviceInfo, err := fakeFS.Stat(fakeDeviceFileName)
g.Expect(err).ToNot(HaveOccurred())
t.Run("given name equals top dir", func(t *testing.T) {
g := NewWithT(t)
w := newSecureFileWalker(root, chartPath, helm.MaxChartFileSize, ignore.Empty())
g.Expect(w.walk(chartPath+"/", chartPath, nil, nil)).To(BeNil())
})
t.Run("given error is returned", func(t *testing.T) {
g := NewWithT(t)
err := errors.New("error argument")
got := (&secureFileWalker{}).walk("name", "/name", nil, err)
g.Expect(got).To(HaveOccurred())
g.Expect(got).To(Equal(err))
})
t.Run("ignore rule matches dir", func(t *testing.T) {
g := NewWithT(t)
rules, err := ignore.Parse(strings.NewReader(fakeDirName + "/"))
g.Expect(err).ToNot(HaveOccurred())
w := newSecureFileWalker(root, chartPath, helm.MaxChartFileSize, rules)
g.Expect(w.walk(filepath.Join(w.absChartPath, fakeDirName), filepath.Join(w.absChartPath, fakeDirName), fakeDirInfo, nil)).To(Equal(fs.SkipDir))
})
t.Run("absolute path match ignored", func(t *testing.T) {
g := NewWithT(t)
rules, err := ignore.Parse(strings.NewReader(fakeDirName + "/"))
g.Expect(err).ToNot(HaveOccurred())
w := newSecureFileWalker(root, chartPath, helm.MaxChartFileSize, rules)
g.Expect(w.walk(filepath.Join(w.absChartPath, "symlink"), filepath.Join(w.absChartPath, fakeDirName), fakeDirInfo, nil)).To(BeNil())
})
t.Run("ignore rule not applicable to dir", func(t *testing.T) {
g := NewWithT(t)
w := newSecureFileWalker(root, chartPath, helm.MaxChartFileSize, ignore.Empty())
g.Expect(w.walk(filepath.Join(w.absChartPath, fakeDirName), filepath.Join(w.absChartPath, fakeDirName), fakeDirInfo, nil)).To(BeNil())
})
t.Run("absolute path outside root", func(t *testing.T) {
g := NewWithT(t)
w := newSecureFileWalker(root, chartPath, helm.MaxChartFileSize, ignore.Empty())
err := w.walk(filepath.Join(w.absChartPath, fakeDirName), filepath.Join("/fake/another/root/", fakeDirName), fakeDirInfo, nil)
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring("cannot load 'fake-dir' directory: absolute path traverses outside root boundary"))
})
t.Run("dir ignore rules before secure path check", func(t *testing.T) {
g := NewWithT(t)
rules, err := ignore.Parse(strings.NewReader(fakeDirName + "/"))
g.Expect(err).ToNot(HaveOccurred())
w := newSecureFileWalker(root, chartPath, helm.MaxChartFileSize, rules)
g.Expect(w.walk(filepath.Join(w.absChartPath, fakeDirName), filepath.Join("/fake/another/root/", fakeDirName), fakeDirInfo, nil)).To(Equal(fs.SkipDir))
})
t.Run("ignore rule matches file", func(t *testing.T) {
g := NewWithT(t)
rules, err := ignore.Parse(strings.NewReader(fakeFileName))
g.Expect(err).ToNot(HaveOccurred())
w := newSecureFileWalker(root, chartPath, helm.MaxChartFileSize, rules)
g.Expect(w.walk(filepath.Join(w.absChartPath, fakeFileName), filepath.Join(w.absChartPath, fakeFileName), fakeFileInfo, nil)).To(BeNil())
})
t.Run("file path outside root", func(t *testing.T) {
g := NewWithT(t)
w := newSecureFileWalker(root, chartPath, helm.MaxChartFileSize, ignore.Empty())
err := w.walk(filepath.Join(w.absChartPath, fakeFileName), filepath.Join("/fake/another/root/", fakeFileName), fakeFileInfo, nil)
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring("cannot load 'fake-file' file: absolute path traverses outside root boundary"))
})
t.Run("irregular file", func(t *testing.T) {
w := newSecureFileWalker(root, chartPath, helm.MaxChartFileSize, ignore.Empty())
err := w.walk(fakeDeviceFileName, filepath.Join(w.absChartPath), fakeDeviceInfo, nil)
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring("cannot load irregular file fake-device as it has file mode type bits set"))
})
t.Run("file exceeds max size", func(t *testing.T) {
w := newSecureFileWalker(root, chartPath, 5, ignore.Empty())
err := w.walk(fakeFileName, filepath.Join(w.absChartPath), fakeFileInfo, nil)
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(Equal(fmt.Sprintf("cannot load file fake-file as file size (%d) exceeds limit (%d)", fakeFileInfo.Size(), w.maxSize)))
})
t.Run("file is appended", func(t *testing.T) {
g := NewWithT(t)
tmpDir := t.TempDir()
fileName := "append-file"
fileData := []byte("append-file-data")
absFilePath := filepath.Join(tmpDir, fileName)
g.Expect(os.WriteFile(absFilePath, fileData, 0o644)).To(Succeed())
fileInfo, err := os.Lstat(absFilePath)
g.Expect(err).ToNot(HaveOccurred())
w := newSecureFileWalker(tmpDir, tmpDir, helm.MaxChartFileSize, ignore.Empty())
g.Expect(w.walk(fileName, absFilePath, fileInfo, nil)).To(Succeed())
g.Expect(w.files).To(HaveLen(1))
g.Expect(w.files[0].Name).To(Equal(fileName))
g.Expect(w.files[0].Data).To(Equal(fileData))
})
t.Run("utf8bom is removed from file data", func(t *testing.T) {
g := NewWithT(t)
tmpDir := t.TempDir()
fileName := "append-file"
fileData := []byte("append-file-data")
fileDataWithBom := append(utf8bom, fileData...)
absFilePath := filepath.Join(tmpDir, fileName)
g.Expect(os.WriteFile(absFilePath, fileDataWithBom, 0o644)).To(Succeed())
fileInfo, err := os.Lstat(absFilePath)
g.Expect(err).ToNot(HaveOccurred())
w := newSecureFileWalker(tmpDir, tmpDir, helm.MaxChartFileSize, ignore.Empty())
g.Expect(w.walk(fileName, absFilePath, fileInfo, nil)).To(Succeed())
g.Expect(w.files).To(HaveLen(1))
g.Expect(w.files[0].Name).To(Equal(fileName))
g.Expect(w.files[0].Data).To(Equal(fileData))
})
t.Run("file does not exist", func(t *testing.T) {
g := NewWithT(t)
tmpDir := t.TempDir()
w := newSecureFileWalker(tmpDir, tmpDir, helm.MaxChartFileSize, ignore.Empty())
err := w.walk(filepath.Join(w.absChartPath, "invalid"), filepath.Join(w.absChartPath, "invalid"), fakeFileInfo, nil)
g.Expect(err).To(HaveOccurred())
g.Expect(errors.Is(err, fs.ErrNotExist)).To(BeTrue())
g.Expect(err.Error()).To(ContainSubstring("error reading invalid: open /invalid: no such file or directory"))
})
}
func Test_isSecureAbsolutePath(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: "absolute path 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: "absolute path traverses outside root boundary",
},
{
name: "illegal root",
root: "working/dir/",
absPath: "/working/dir",
safe: false,
wantErr: "cannot calculate path relative to root for absolute path",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
got, err := isSecureAbsolutePath(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,67 @@
/*
Copyright The Helm 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 ignore provides tools for writing ignore files (a la .gitignore).
This provides both an ignore parser and a file-aware processor.
The format of ignore files closely follows, but does not exactly match, the
format for .gitignore files (https://git-scm.com/docs/gitignore).
The formatting rules are as follows:
- Parsing is line-by-line
- Empty lines are ignored
- Lines the begin with # (comments) will be ignored
- Leading and trailing spaces are always ignored
- Inline comments are NOT supported ('foo* # Any foo' does not contain a comment)
- There is no support for multi-line patterns
- Shell glob patterns are supported. See Go's "path/filepath".Match
- If a pattern begins with a leading !, the match will be negated.
- If a pattern begins with a leading /, only paths relatively rooted will match.
- If the pattern ends with a trailing /, only directories will match
- If a pattern contains no slashes, file basenames are tested (not paths)
- The pattern sequence "**", while legal in a glob, will cause an error here
(to indicate incompatibility with .gitignore).
Example:
# Match any file named foo.txt
foo.txt
# Match any text file
*.txt
# Match only directories named mydir
mydir/
# Match only text files in the top-level directory
/*.txt
# Match only the file foo.txt in the top-level directory
/foo.txt
# Match any file named ab.txt, ac.txt, or ad.txt
a[b-d].txt
Notable differences from .gitignore:
- The '**' syntax is not supported.
- The globbing library is Go's 'filepath.Match', not fnmatch(3)
- Trailing spaces are always ignored (there is no supported escape sequence)
- The evaluation of escape sequences has not been tested for compatibility
- There is no support for '\!' as a special leading sequence.
*/
package ignore

View File

@ -0,0 +1,227 @@
/*
Copyright The Helm 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 ignore
import (
"bufio"
"bytes"
"errors"
"io"
"log"
"os"
"path/filepath"
"strings"
)
// HelmIgnore default name of an ignorefile.
const HelmIgnore = ".helmignore"
// Rules is a collection of path matching rules.
//
// Parse() and ParseFile() will construct and populate new Rules.
// Empty() will create an immutable empty ruleset.
type Rules struct {
patterns []*pattern
}
// Empty builds an empty ruleset.
func Empty() *Rules {
return &Rules{patterns: []*pattern{}}
}
// AddDefaults adds default ignore patterns.
//
// Ignore all dotfiles in "templates/"
func (r *Rules) AddDefaults() {
r.parseRule(`templates/.?*`)
}
// ParseFile parses a helmignore file and returns the *Rules.
func ParseFile(file string) (*Rules, error) {
f, err := os.Open(file)
if err != nil {
return nil, err
}
defer f.Close()
return Parse(f)
}
// Parse parses a rules file
func Parse(file io.Reader) (*Rules, error) {
r := &Rules{patterns: []*pattern{}}
s := bufio.NewScanner(file)
currentLine := 0
utf8bom := []byte{0xEF, 0xBB, 0xBF}
for s.Scan() {
scannedBytes := s.Bytes()
// We trim UTF8 BOM
if currentLine == 0 {
scannedBytes = bytes.TrimPrefix(scannedBytes, utf8bom)
}
line := string(scannedBytes)
currentLine++
if err := r.parseRule(line); err != nil {
return r, err
}
}
return r, s.Err()
}
// Ignore evaluates the file at the given path, and returns true if it should be ignored.
//
// Ignore evaluates path against the rules in order. Evaluation stops when a match
// is found. Matching a negative rule will stop evaluation.
func (r *Rules) Ignore(path string, fi os.FileInfo) bool {
// Don't match on empty dirs.
if path == "" {
return false
}
// Disallow ignoring the current working directory.
// See issue:
// 1776 (New York City) Hamilton: "Pardon me, are you Aaron Burr, sir?"
if path == "." || path == "./" {
return false
}
for _, p := range r.patterns {
if p.match == nil {
log.Printf("ignore: no matcher supplied for %q", p.raw)
return false
}
// For negative rules, we need to capture and return non-matches,
// and continue for matches.
if p.negate {
if p.mustDir && !fi.IsDir() {
return true
}
if !p.match(path, fi) {
return true
}
continue
}
// If the rule is looking for directories, and this is not a directory,
// skip it.
if p.mustDir && !fi.IsDir() {
continue
}
if p.match(path, fi) {
return true
}
}
return false
}
// parseRule parses a rule string and creates a pattern, which is then stored in the Rules object.
func (r *Rules) parseRule(rule string) error {
rule = strings.TrimSpace(rule)
// Ignore blank lines
if rule == "" {
return nil
}
// Comment
if strings.HasPrefix(rule, "#") {
return nil
}
// Fail any rules that contain **
if strings.Contains(rule, "**") {
return errors.New("double-star (**) syntax is not supported")
}
// Fail any patterns that can't compile. A non-empty string must be
// given to Match() to avoid optimization that skips rule evaluation.
if _, err := filepath.Match(rule, "abc"); err != nil {
return err
}
p := &pattern{raw: rule}
// Negation is handled at a higher level, so strip the leading ! from the
// string.
if strings.HasPrefix(rule, "!") {
p.negate = true
rule = rule[1:]
}
// Directory verification is handled by a higher level, so the trailing /
// is removed from the rule. That way, a directory named "foo" matches,
// even if the supplied string does not contain a literal slash character.
if strings.HasSuffix(rule, "/") {
p.mustDir = true
rule = strings.TrimSuffix(rule, "/")
}
if strings.HasPrefix(rule, "/") {
// Require path matches the root path.
p.match = func(n string, fi os.FileInfo) bool {
rule = strings.TrimPrefix(rule, "/")
ok, err := filepath.Match(rule, n)
if err != nil {
log.Printf("Failed to compile %q: %s", rule, err)
return false
}
return ok
}
} else if strings.Contains(rule, "/") {
// require structural match.
p.match = func(n string, fi os.FileInfo) bool {
ok, err := filepath.Match(rule, n)
if err != nil {
log.Printf("Failed to compile %q: %s", rule, err)
return false
}
return ok
}
} else {
p.match = func(n string, fi os.FileInfo) bool {
// When there is no slash in the pattern, we evaluate ONLY the
// filename.
n = filepath.Base(n)
ok, err := filepath.Match(rule, n)
if err != nil {
log.Printf("Failed to compile %q: %s", rule, err)
return false
}
return ok
}
}
r.patterns = append(r.patterns, p)
return nil
}
// matcher is a function capable of computing a match.
//
// It returns true if the rule matches.
type matcher func(name string, fi os.FileInfo) bool
// pattern describes a pattern to be matched in a rule set.
type pattern struct {
// raw is the unparsed string, with nothing stripped.
raw string
// match is the matcher function.
match matcher
// negate indicates that the rule's outcome should be negated.
negate bool
// mustDir indicates that the matched file must be a directory.
mustDir bool
}

View File

@ -0,0 +1,155 @@
/*
Copyright The Helm 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 ignore
import (
"bytes"
"os"
"path/filepath"
"testing"
)
var testdata = "./testdata"
func TestParse(t *testing.T) {
rules := `#ignore
#ignore
foo
bar/*
baz/bar/foo.txt
one/more
`
r, err := parseString(rules)
if err != nil {
t.Fatalf("Error parsing rules: %s", err)
}
if len(r.patterns) != 4 {
t.Errorf("Expected 4 rules, got %d", len(r.patterns))
}
expects := []string{"foo", "bar/*", "baz/bar/foo.txt", "one/more"}
for i, p := range r.patterns {
if p.raw != expects[i] {
t.Errorf("Expected %q, got %q", expects[i], p.raw)
}
if p.match == nil {
t.Errorf("Expected %s to have a matcher function.", p.raw)
}
}
}
func TestParseFail(t *testing.T) {
shouldFail := []string{"foo/**/bar", "[z-"}
for _, fail := range shouldFail {
_, err := parseString(fail)
if err == nil {
t.Errorf("Rule %q should have failed", fail)
}
}
}
func TestParseFile(t *testing.T) {
f := filepath.Join(testdata, HelmIgnore)
if _, err := os.Stat(f); err != nil {
t.Fatalf("Fixture %s missing: %s", f, err)
}
r, err := ParseFile(f)
if err != nil {
t.Fatalf("Failed to parse rules file: %s", err)
}
if len(r.patterns) != 3 {
t.Errorf("Expected 3 patterns, got %d", len(r.patterns))
}
}
func TestIgnore(t *testing.T) {
// Test table: Given pattern and name, Ignore should return expect.
tests := []struct {
pattern string
name string
expect bool
}{
// Glob tests
{`helm.txt`, "helm.txt", true},
{`helm.*`, "helm.txt", true},
{`helm.*`, "rudder.txt", false},
{`*.txt`, "tiller.txt", true},
{`*.txt`, "cargo/a.txt", true},
{`cargo/*.txt`, "cargo/a.txt", true},
{`cargo/*.*`, "cargo/a.txt", true},
{`cargo/*.txt`, "mast/a.txt", false},
{`ru[c-e]?er.txt`, "rudder.txt", true},
{`templates/.?*`, "templates/.dotfile", true},
// "." should never get ignored. https://github.com/helm/helm/issues/1776
{`.*`, ".", false},
{`.*`, "./", false},
{`.*`, ".joonix", true},
{`.*`, "helm.txt", false},
{`.*`, "", false},
// Directory tests
{`cargo/`, "cargo", true},
{`cargo/`, "cargo/", true},
{`cargo/`, "mast/", false},
{`helm.txt/`, "helm.txt", false},
// Negation tests
{`!helm.txt`, "helm.txt", false},
{`!helm.txt`, "tiller.txt", true},
{`!*.txt`, "cargo", true},
{`!cargo/`, "mast/", true},
// Absolute path tests
{`/a.txt`, "a.txt", true},
{`/a.txt`, "cargo/a.txt", false},
{`/cargo/a.txt`, "cargo/a.txt", true},
}
for _, test := range tests {
r, err := parseString(test.pattern)
if err != nil {
t.Fatalf("Failed to parse: %s", err)
}
fi, err := os.Stat(filepath.Join(testdata, test.name))
if err != nil {
t.Fatalf("Fixture missing: %s", err)
}
if r.Ignore(test.name, fi) != test.expect {
t.Errorf("Expected %q to be %v for pattern %q", test.name, test.expect, test.pattern)
}
}
}
func TestAddDefaults(t *testing.T) {
r := Rules{}
r.AddDefaults()
if len(r.patterns) != 1 {
t.Errorf("Expected 1 default patterns, got %d", len(r.patterns))
}
}
func parseString(str string) (*Rules, error) {
b := bytes.NewBuffer([]byte(str))
return Parse(b)
}

View File

@ -0,0 +1,3 @@
mast/a.txt
.DS_Store
.git

View File

View File

@ -0,0 +1,85 @@
/*
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"
"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"
)
// 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, err := filepath.Abs(root)
if err != nil {
return nil, err
}
relName := filepath.Clean(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: strings.TrimPrefix(secureName, root), Err: pathErr.Err}
}
return nil, err
}
if fi.IsDir() {
return NewSecureDirLoader(root, relName, helm.MaxChartFileSize), 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,92 @@
/*
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"
"helm.sh/helm/v3/pkg/chart/loader"
"sigs.k8s.io/yaml"
"github.com/fluxcd/source-controller/internal/helm"
)
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())
t.Run("file loader", func(t *testing.T) {
g := NewWithT(t)
got, err := Loader(tmpDir, fakeChart)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got).To(Equal(loader.FileLoader(fakeChart)))
})
t.Run("dir loader", func(t *testing.T) {
g := NewWithT(t)
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, path: "fake", maxSize: helm.MaxChartFileSize}))
})
t.Run("illegal path", func(t *testing.T) {
g := NewWithT(t)
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())
})
}
func TestLoad(t *testing.T) {
g := NewWithT(t)
tmpDir := t.TempDir()
metadata := chart.Metadata{
Name: "test",
APIVersion: "v2",
Version: "1.0",
Type: "application",
}
b, err := yaml.Marshal(&metadata)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(os.WriteFile(filepath.Join(tmpDir, "Chart.yaml"), b, 0o644)).To(Succeed())
got, err := Load(tmpDir, "")
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got).ToNot(BeNil())
g.Expect(got.Name()).To(Equal(metadata.Name))
}

View File

@ -0,0 +1,124 @@
/*
Copyright (c) for portions of walk.go are held by The Go Authors, 2009 and are
provided under the BSD license.
https://github.com/golang/go/blob/master/LICENSE
Copyright The Helm Authors.
Copyright 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 sympath
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
)
// AbsWalkFunc functions like filepath.WalkFunc but provides the absolute path
// of fs.FileInfo when path is a symlink.
type AbsWalkFunc func(path, absPath string, info fs.FileInfo, err error) error
// Walk walks the file tree rooted at root, calling walkFn for each file or directory
// in the tree, including root. All errors that arise visiting files and directories
// are filtered by walkFn. The files are walked in lexical order, which makes the
// output deterministic but means that for very large directories Walk can be
// inefficient. Walk follows symbolic links.
func Walk(root string, walkFn AbsWalkFunc) error {
info, err := os.Lstat(root)
if err != nil {
err = walkFn(root, root, nil, err)
} else {
err = symwalk(root, root, info, walkFn)
}
if err == filepath.SkipDir {
return nil
}
return err
}
// readDirNames reads the directory named by dirname and returns
// a sorted list of directory entries.
func readDirNames(dirname string) ([]string, error) {
f, err := os.Open(dirname)
if err != nil {
return nil, err
}
names, err := f.Readdirnames(-1)
f.Close()
if err != nil {
return nil, err
}
sort.Strings(names)
return names, nil
}
// symwalk recursively descends path, calling AbsWalkFunc.
func symwalk(path, absPath string, info os.FileInfo, walkFn AbsWalkFunc) error {
// Recursively walk symlinked directories.
if IsSymlink(info) {
resolved, err := filepath.EvalSymlinks(path)
if err != nil {
return fmt.Errorf("error evaluating symlink %s: %w", path, err)
}
if info, err = os.Lstat(resolved); err != nil {
return err
}
// NB: pass-on resolved as absolute path
if err := symwalk(path, resolved, info, walkFn); err != nil && err != filepath.SkipDir {
return err
}
return nil
}
if err := walkFn(path, absPath, info, nil); err != nil {
return err
}
if !info.IsDir() {
return nil
}
names, err := readDirNames(path)
if err != nil {
return walkFn(path, absPath, info, err)
}
for _, name := range names {
filename := filepath.Join(path, name)
// NB: possibly absPath != path separately
absFilename := filepath.Join(absPath, name)
fileInfo, err := os.Lstat(filename)
if err != nil {
if err := walkFn(filename, absFilename, fileInfo, err); err != nil && err != filepath.SkipDir {
return err
}
} else {
if err = symwalk(filename, absFilename, fileInfo, walkFn); err != nil {
if (!fileInfo.IsDir() && !IsSymlink(fileInfo)) || err != filepath.SkipDir {
return err
}
}
}
}
return nil
}
// IsSymlink is used to determine if the fileinfo is a symbolic link.
func IsSymlink(fi os.FileInfo) bool {
return fi.Mode()&os.ModeSymlink != 0
}

View File

@ -0,0 +1,160 @@
/*
Copyright (c) for portions of walk_test.go are held by The Go Authors, 2009 and are
provided under the BSD license.
https://github.com/golang/go/blob/master/LICENSE
Copyright The Helm 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 sympath
import (
"os"
"path/filepath"
"testing"
)
type Node struct {
name string
entries []*Node // nil if the entry is a file
marks int
expectedMarks int
symLinkedTo string
absPath string
expectedAbsPath string
}
var tree = &Node{
"testdata",
[]*Node{
{"a", nil, 0, 1, "", "", "testdata/a"},
{"b", []*Node{}, 0, 1, "", "", "testdata/b"},
{"c", nil, 0, 2, "", "", "testdata/c"},
{"d", nil, 0, 0, "c", "", "testdata/c"},
{
"e",
[]*Node{
{"x", nil, 0, 1, "", "", "testdata/e/x"},
{"y", []*Node{}, 0, 1, "", "", "testdata/e/y"},
{
"z",
[]*Node{
{"u", nil, 0, 1, "", "", "testdata/e/z/u"},
{"v", nil, 0, 1, "", "", "testdata/e/z/v"},
{"w", nil, 0, 1, "", "", "testdata/e/z/w"},
},
0,
1,
"", "", "testdata/e/z",
},
},
0,
1,
"", "", "testdata/e",
},
},
0,
1,
"", "", "testdata",
}
func walkTree(n *Node, path string, f func(path string, n *Node)) {
f(path, n)
for _, e := range n.entries {
walkTree(e, filepath.Join(path, e.name), f)
}
}
func makeTree(t *testing.T) {
walkTree(tree, tree.name, func(path string, n *Node) {
if n.entries == nil {
if n.symLinkedTo != "" {
if err := os.Symlink(n.symLinkedTo, path); err != nil {
t.Fatalf("makeTree: %v", err)
}
} else {
fd, err := os.Create(path)
if err != nil {
t.Fatalf("makeTree: %v", err)
return
}
fd.Close()
}
} else {
if err := os.Mkdir(path, 0770); err != nil {
t.Fatalf("makeTree: %v", err)
}
}
})
}
func checkMarks(t *testing.T, report bool) {
walkTree(tree, tree.name, func(path string, n *Node) {
if n.marks != n.expectedMarks && report {
t.Errorf("node %s mark = %d; expected %d", path, n.marks, n.expectedMarks)
}
if n.absPath != n.expectedAbsPath && report {
t.Errorf("node %s absPath = %s; expected %s", path, n.absPath, n.expectedAbsPath)
}
n.marks = 0
})
}
// Assumes that each node name is unique. Good enough for a test.
// If clear is true, any incoming error is cleared before return. The errors
// are always accumulated, though.
func mark(absPath string, info os.FileInfo, err error, errors *[]error, clear bool) error {
if err != nil {
*errors = append(*errors, err)
if clear {
return nil
}
return err
}
name := info.Name()
walkTree(tree, tree.name, func(path string, n *Node) {
if n.symLinkedTo == name {
n.absPath = absPath
}
if n.name == name {
n.marks++
n.absPath = absPath
}
})
return nil
}
func TestWalk(t *testing.T) {
makeTree(t)
errors := make([]error, 0, 10)
clear := true
markFn := func(path, absPath string, info os.FileInfo, err error) error {
return mark(absPath, info, err, &errors, clear)
}
// Expect no errors.
err := Walk(tree.name, markFn)
if err != nil {
t.Fatalf("no error expected, found: %s", err)
}
if len(errors) != 0 {
t.Fatalf("unexpected errors: %s", errors)
}
checkMarks(t, true)
// cleanup
if err := os.RemoveAll(tree.name); err != nil {
t.Errorf("removeTree: %v", err)
}
}