Make it possible to configure via env var max chart and file size

To avoid blocking user with the security fix introduced in e4da497, I
think we should permit to increase max size to greater values via env
var. I don't know if we should enforce a limit. It's probably
unnecessary.
We probably need to document this capability too.

Signed-off-by: Benoit Tigeot <benoit.tigeot@lifen.fr>
pull/30743/head
Benoit Tigeot 6 months ago
parent 1a06fe9901
commit b37c1c622b
No known key found for this signature in database
GPG Key ID: 8E6D4FC8AEBDA62C

@ -131,6 +131,10 @@ type Install struct {
// Lock to control raceconditions when the process receives a SIGTERM
Lock sync.Mutex
goroutineCount atomic.Int32
// MaxChartSize is the maximum size of a decompressed chart in bytes
MaxChartSize int64
// MaxChartFileSize is the maximum size of a single file in a chart in bytes
MaxChartFileSize int64
}
// ChartPathOptions captures common options used for controlling chart paths

@ -22,6 +22,7 @@ import (
"path/filepath"
"strings"
"helm.sh/helm/v4/pkg/chart/v2/loader"
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
"helm.sh/helm/v4/pkg/cli"
"helm.sh/helm/v4/pkg/downloader"
@ -44,6 +45,10 @@ type Pull struct {
UntarDir string
DestDir string
cfg *Configuration
// MaxChartSize is the maximum decompressed size of a chart in bytes
MaxChartSize int64
// MaxChartFileSize is the maximum size of a single file in a chart in bytes
MaxChartFileSize int64
}
type PullOpt func(*Pull)
@ -169,7 +174,14 @@ func (p *Pull) Run(chartRef string) (string, error) {
return out.String(), fmt.Errorf("failed to untar: a file or directory with the name %s already exists", udCheck)
}
return out.String(), chartutil.ExpandFile(ud, saved)
opts := loader.DefaultChartLoadOptions
if p.MaxChartSize > 0 {
opts.MaxDecompressedChartSize = p.MaxChartSize
}
if p.MaxChartFileSize > 0 {
opts.MaxDecompressedFileSize = p.MaxChartFileSize
}
return out.String(), chartutil.ExpandFileWithOptions(ud, saved, opts)
}
return out.String(), nil
}

@ -127,6 +127,10 @@ type Upgrade struct {
EnableDNS bool
// TakeOwnership will skip the check for helm annotations and adopt all existing resources.
TakeOwnership bool
// MaxChartSize is the maximum decompressed size of a chart in bytes
MaxChartSize int64
// MaxChartFileSize is the maximum size of a single file in a chart in bytes
MaxChartFileSize int64
}
type resultMessage struct {

@ -32,27 +32,56 @@ import (
chart "helm.sh/helm/v4/pkg/chart/v2"
)
// MaxDecompressedChartSize is the maximum size of a chart archive that will be
// decompressed. This is the decompressed size of all the files.
// The default value is 100 MiB.
var MaxDecompressedChartSize int64 = 100 * 1024 * 1024 // Default 100 MiB
var drivePathPattern = regexp.MustCompile(`^[a-zA-Z]:/`)
// MaxDecompressedFileSize is the size of the largest file that Helm will attempt to load.
// The size of the file is the decompressed version of it when it is stored in an archive.
var MaxDecompressedFileSize int64 = 5 * 1024 * 1024 // Default 5 MiB
type ChartLoadOptions struct {
MaxDecompressedChartSize int64
MaxDecompressedFileSize int64
}
var drivePathPattern = regexp.MustCompile(`^[a-zA-Z]:/`)
// DefaultChartLoadOptions provides the default size limits
var DefaultChartLoadOptions = ChartLoadOptions{
// MaxDecompressedChartSize is the maximum size of a chart archive that will be
// decompressed. This is the decompressed size of all the files.
MaxDecompressedChartSize: 100 * 1024 * 1024, // 100 MiB
// MaxDecompressedFileSize is the size of the largest file that Helm will attempt to load.
// The size of the file is the decompressed version of it when it is stored in an archive.
MaxDecompressedFileSize: 5 * 1024 * 1024, // 5 MiB
}
// FileLoader with embedded options
type FileLoader struct {
path string
opts ChartLoadOptions
}
// FileLoader loads a chart from a file
type FileLoader string
// NewFileLoader creates a file loader with custom options
func NewFileLoader(path string, opts ChartLoadOptions) FileLoader {
return FileLoader{path: path, opts: opts}
}
// Load loads a chart
// NewDefaultFileLoader creates a file loader with default options
func NewDefaultFileLoader(path string) FileLoader {
return FileLoader{path: path, opts: DefaultChartLoadOptions}
}
// Load loads a chart using default options
func (l FileLoader) Load() (*chart.Chart, error) {
return LoadFile(string(l))
return LoadFileWithOptions(l.path, DefaultChartLoadOptions)
}
// LoadFile loads from an archive file.
// LoadWithOptions loads a chart using the provided options
func (l FileLoader) LoadWithOptions() (*chart.Chart, error) {
return LoadFileWithOptions(l.path, l.opts)
}
// LoadFile load a chart with default options
func LoadFile(name string) (*chart.Chart, error) {
return LoadFileWithOptions(name, DefaultChartLoadOptions)
}
// LoadFile loads from an archive file with the provided options
func LoadFileWithOptions(name string, opts ChartLoadOptions) (*chart.Chart, error) {
if fi, err := os.Stat(name); err != nil {
return nil, err
} else if fi.IsDir() {
@ -70,7 +99,7 @@ func LoadFile(name string) (*chart.Chart, error) {
return nil, err
}
c, err := LoadArchive(raw)
c, err := LoadArchiveWithOptions(raw, opts)
if err != nil {
if err == gzip.ErrHeader {
return nil, fmt.Errorf("file '%s' does not appear to be a valid chart file (details: %s)", name, err)
@ -117,8 +146,15 @@ func isGZipApplication(data []byte) bool {
// 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
// expanding a tarball. It use default options.
func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) {
return LoadArchiveFilesWithOptions(in, DefaultChartLoadOptions)
}
// 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. It uses the provided options.
func LoadArchiveFilesWithOptions(in io.Reader, opts ChartLoadOptions) ([]*BufferedFile, error) {
unzipped, err := gzip.NewReader(in)
if err != nil {
return nil, err
@ -127,7 +163,7 @@ func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) {
files := []*BufferedFile{}
tr := tar.NewReader(unzipped)
remainingSize := MaxDecompressedChartSize
remainingSize := opts.MaxDecompressedChartSize
for {
b := bytes.NewBuffer(nil)
hd, err := tr.Next()
@ -188,11 +224,11 @@ func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) {
}
if hd.Size > remainingSize {
return nil, fmt.Errorf("decompressed chart is larger than the maximum size %d", MaxDecompressedChartSize)
return nil, fmt.Errorf("decompressed chart is larger than the maximum size %d bytes", opts.MaxDecompressedChartSize)
}
if hd.Size > MaxDecompressedFileSize {
return nil, fmt.Errorf("decompressed chart file %q is larger than the maximum file size %d", hd.Name, MaxDecompressedFileSize)
if hd.Size > opts.MaxDecompressedFileSize {
return nil, fmt.Errorf("decompressed chart file %q is larger than the maximum file size %d bytes", hd.Name, opts.MaxDecompressedFileSize)
}
limitedReader := io.LimitReader(tr, remainingSize)
@ -208,7 +244,7 @@ func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) {
// is the one that goes over the limit. It assumes the Size stored in the tar header
// is correct, something many applications do.
if bytesWritten < hd.Size || remainingSize <= 0 {
return nil, fmt.Errorf("decompressed chart is larger than the maximum size %d", MaxDecompressedChartSize)
return nil, fmt.Errorf("decompressed chart is larger than the maximum size %d bytes", opts.MaxDecompressedChartSize)
}
data := bytes.TrimPrefix(b.Bytes(), utf8bom)
@ -223,9 +259,14 @@ func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) {
return files, nil
}
// LoadArchive loads from a reader containing a compressed tar archive.
// LoadArchive loads from a reader containing a compressed tar archive with default options
func LoadArchive(in io.Reader) (*chart.Chart, error) {
files, err := LoadArchiveFiles(in)
return LoadArchiveWithOptions(in, DefaultChartLoadOptions)
}
// LoadArchive loads from a reader containing a compressed tar archive with the provided options
func LoadArchiveWithOptions(in io.Reader, opts ChartLoadOptions) (*chart.Chart, error) {
files, err := LoadArchiveFilesWithOptions(in, opts)
if err != nil {
return nil, err
}

@ -31,17 +31,45 @@ import (
var utf8bom = []byte{0xEF, 0xBB, 0xBF}
// DirLoader loads a chart from a directory
type DirLoader string
type DirLoader struct {
path string
opts ChartLoadOptions
}
// NewDirLoader creates a new directory loader with default options
func NewDefaultDirLoader(path string) DirLoader {
return DirLoader{path: path, opts: DefaultChartLoadOptions}
}
// NewDirLoader creates a new directory loader with custom options
func NewDirLoader(path string, opts ChartLoadOptions) DirLoader {
return DirLoader{path: path, opts: opts}
}
// Load loads the chart
func (l DirLoader) Load() (*chart.Chart, error) {
return LoadDir(string(l))
return LoadDir(l.path)
}
// LoadWithOptions loads the chart with custom options
func (l DirLoader) LoadWithOptions() (*chart.Chart, error) {
return LoadDirWithOptions(l.path, l.opts)
}
// LoadDirWithOptions loads from a directory with default options
func LoadDir(dir string) (*chart.Chart, error) {
return LoadDirWithOptions(dir, DefaultChartLoadOptions)
}
// LoadDirWithOptions loads from a directory with custom options
func (l DirLoader) LoadDirWithOptions() (*chart.Chart, error) {
return LoadDirWithOptions(l.path, l.opts)
}
// LoadDir loads from a directory.
//
// This loads charts only from directories.
func LoadDir(dir string) (*chart.Chart, error) {
func LoadDirWithOptions(dir string, opts ChartLoadOptions) (*chart.Chart, error) {
topdir, err := filepath.Abs(dir)
if err != nil {
return nil, err
@ -99,8 +127,8 @@ func LoadDir(dir string) (*chart.Chart, error) {
return fmt.Errorf("cannot load irregular file %s as it has file mode type bits set", name)
}
if fi.Size() > MaxDecompressedFileSize {
return fmt.Errorf("chart file %q is larger than the maximum file size %d", fi.Name(), MaxDecompressedFileSize)
if fi.Size() > opts.MaxDecompressedFileSize {
return fmt.Errorf("chart file %q is larger than the maximum file size %d", fi.Name(), opts.MaxDecompressedFileSize)
}
data, err := os.ReadFile(name)

@ -38,6 +38,7 @@ import (
// ChartLoader loads a chart.
type ChartLoader interface {
Load() (*chart.Chart, error)
LoadWithOptions() (*chart.Chart, error)
}
// Loader returns a new ChartLoader appropriate for the given chart name
@ -47,9 +48,23 @@ func Loader(name string) (ChartLoader, error) {
return nil, err
}
if fi.IsDir() {
return DirLoader(name), nil
return NewDefaultDirLoader(name), nil
}
return FileLoader(name), nil
return NewDefaultFileLoader(name), nil
}
// WithOptions returns a new ChartLoader appropriate for the given chart name
// with the provided options.
func WithOptions(name string, opts ChartLoadOptions) (ChartLoader, error) {
fi, err := os.Stat(name)
if err != nil {
return nil, err
}
if fi.IsDir() {
return NewDirLoader(name, opts), nil
}
return NewFileLoader(name, opts), nil
}
// Load takes a string name, tries to resolve it to a file or directory, and then loads it.
@ -67,6 +82,22 @@ func Load(name string) (*chart.Chart, error) {
return l.Load()
}
// LoadWithOptions takes a string name, tries to resolve it to a file or directory,
// and then loads it. It uses the provided options to load the chart.
//
// 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 LoadWithOptions(name string, opts ChartLoadOptions) (*chart.Chart, error) {
l, err := WithOptions(name, opts)
if err != nil {
return nil, err
}
return l.LoadWithOptions()
}
// BufferedFile represents an archive file buffered for later processing.
type BufferedFile struct {
Name string

@ -30,9 +30,16 @@ import (
"helm.sh/helm/v4/pkg/chart/v2/loader"
)
// Expand uncompresses and extracts a chart into the specified directory.
// Expand uncompresses and extracts a chart into the specified directory
// with default options.
func Expand(dir string, r io.Reader) error {
files, err := loader.LoadArchiveFiles(r)
return ExpandWithOptions(dir, r, loader.DefaultChartLoadOptions)
}
// Expand uncompresses and extracts a chart into the specified directory
// with custom options.
func ExpandWithOptions(dir string, r io.Reader, opts loader.ChartLoadOptions) error {
files, err := loader.LoadArchiveFilesWithOptions(r, opts)
if err != nil {
return err
}
@ -84,11 +91,18 @@ func Expand(dir string, r io.Reader) error {
}
// ExpandFile expands the src file into the dest directory.
// It uses default options to control the loading of the chart.
func ExpandFile(dest, src string) error {
return ExpandFileWithOptions(dest, src, loader.DefaultChartLoadOptions)
}
// ExpandFile expands the src file into the dest directory.
// It uses custom options to control the loading of the chart.
func ExpandFileWithOptions(dest, src string, opts loader.ChartLoadOptions) error {
h, err := os.Open(src)
if err != nil {
return err
}
defer h.Close()
return Expand(dest, h)
return ExpandWithOptions(dest, h, opts)
}

@ -93,6 +93,10 @@ type EnvSettings struct {
ColorMode string
// ContentCache is the location where cached charts are stored
ContentCache string
// MaxChartSize is the maximum size of a decompressed chart in bytes
MaxChartSize int64
// MaxChartFileSize is the maximum size of a single file in a chart in bytes
MaxChartFileSize int64
}
func New() *EnvSettings {
@ -115,6 +119,8 @@ func New() *EnvSettings {
BurstLimit: envIntOr("HELM_BURST_LIMIT", defaultBurstLimit),
QPS: envFloat32Or("HELM_QPS", defaultQPS),
ColorMode: envColorMode(),
MaxChartSize: envInt64Or("HELM_MAX_CHART_SIZE", 100*1024*1024), // 100 MiB
MaxChartFileSize: envInt64Or("HELM_MAX_FILE_SIZE", 5*1024*1024), // 5 MiB
}
env.Debug, _ = strconv.ParseBool(os.Getenv("HELM_DEBUG"))
@ -214,6 +220,20 @@ func envFloat32Or(name string, def float32) float32 {
return float32(ret)
}
// We want to handle int64 like returned by https://pkg.go.dev/io/fs#FileInfo
func envInt64Or(name string, def int64) int64 {
if name == "" {
return def
}
envVal := envOr(name, strconv.FormatInt(def, 10))
ret, err := strconv.ParseInt(envVal, 10, 64)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Environment variable %s has invalid value %q (expected an integer): %v\n", name, envVal, err)
return def
}
return ret
}
func envCSV(name string) (ls []string) {
trimmed := strings.Trim(os.Getenv(name), ", ")
if trimmed != "" {
@ -255,6 +275,8 @@ func (s *EnvSettings) EnvVars() map[string]string {
"HELM_MAX_HISTORY": strconv.Itoa(s.MaxHistory),
"HELM_BURST_LIMIT": strconv.Itoa(s.BurstLimit),
"HELM_QPS": strconv.FormatFloat(float64(s.QPS), 'f', 2, 32),
"HELM_MAX_CHART_SIZE": strconv.FormatInt(s.MaxChartSize, 10),
"HELM_MAX_FILE_SIZE": strconv.FormatInt(s.MaxChartFileSize, 10),
// broken, these are populated from helm flags and not kubeconfig.
"HELM_KUBECONTEXT": s.KubeContext,

@ -178,6 +178,8 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
// it is added separately
f := cmd.Flags()
f.BoolVar(&client.HideSecret, "hide-secret", false, "hide Kubernetes Secrets when also using the --dry-run flag")
f.Int64Var(&client.MaxChartSize, "max-chart-size", settings.MaxChartSize, "maximum size in bytes for a decompressed chart (default is 100mb)")
f.Int64Var(&client.MaxChartFileSize, "max-file-size", settings.MaxChartFileSize, "maximum size in bytes for a single file in a chart (default is 5mb)")
bindOutputFlag(cmd, &outfmt)
bindPostRenderFlag(cmd, &client.PostRenderer, settings)
@ -264,8 +266,15 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options
return nil, err
}
opts := loader.DefaultChartLoadOptions
if client.MaxChartSize > 0 {
opts.MaxDecompressedChartSize = client.MaxChartSize
}
if client.MaxChartFileSize > 0 {
opts.MaxDecompressedFileSize = client.MaxChartFileSize
}
// Check chart dependencies to make sure all are present in /charts
chartRequested, err := loader.Load(cp)
chartRequested, err := loader.LoadWithOptions(cp, opts)
if err != nil {
return nil, err
}

@ -274,6 +274,12 @@ func TestInstall(t *testing.T) {
wantError: true,
golden: "output/install-hide-secret.txt",
},
{
name: "install with restricted max size",
cmd: "install too-big testdata/testcharts/compressedchart-0.1.0.tgz --max-chart-size=42",
wantError: true,
golden: "output/install-with-restricted-chart-size.txt",
},
}
runTestCmd(t, tests)

@ -89,6 +89,8 @@ func newPullCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
f.BoolVar(&client.VerifyLater, "prov", false, "fetch the provenance file, but don't perform verification")
f.StringVar(&client.UntarDir, "untardir", ".", "if untar is specified, this flag specifies the name of the directory into which the chart is expanded")
f.StringVarP(&client.DestDir, "destination", "d", ".", "location to write the chart. If this and untardir are specified, untardir is appended to this")
f.Int64Var(&client.MaxChartSize, "max-chart-size", settings.MaxChartSize, "maximum size in bytes for a decompressed chart (default is 100mb)")
f.Int64Var(&client.MaxChartFileSize, "max-file-size", settings.MaxChartFileSize, "maximum size in bytes for a single file in a chart (default is 5mb)")
addChartPathOptionsFlags(f, &client.ChartPathOptions)
err := cmd.RegisterFlagCompletionFunc("version", func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {

@ -210,6 +210,12 @@ func TestPullCmd(t *testing.T) {
wantErrorMsg: "Error: chart reference and version mismatch: 0.2.0 is not 0.1.0",
wantError: true,
},
{
name: "Fail because of small max chart size",
args: "test/test1 --max-chart-size=90",
wantError: true,
wantErrorMsg: "decompressed chart is larger than the maximum size 90 bytes",
},
}
contentCache := t.TempDir()

@ -83,6 +83,8 @@ Environment variables:
| $HELM_QPS | set the Queries Per Second in cases where a high number of calls exceed the option for higher burst values |
| $HELM_COLOR | set color output mode. Allowed values: never, always, auto (default: never) |
| $NO_COLOR | set to any non-empty value to disable all colored output (overrides $HELM_COLOR) |
| $HELM_MAX_CHART_SIZE | set the maximum size in bytes for a decompressed chart (default: 100MiB, 0 means use default limit) |
| $HELM_MAX_FILE_SIZE | set the maximum size in bytes for a single file in a chart (default: 5MiB, 0 means use default limit) |
Helm stores cache, configuration, and data based on the following configuration order:

@ -13,6 +13,8 @@ HELM_KUBECONTEXT
HELM_KUBEINSECURE_SKIP_TLS_VERIFY
HELM_KUBETLS_SERVER_NAME
HELM_KUBETOKEN
HELM_MAX_CHART_SIZE
HELM_MAX_FILE_SIZE
HELM_MAX_HISTORY
HELM_NAMESPACE
HELM_PLUGINS

@ -0,0 +1 @@
Error: INSTALLATION FAILED: decompressed chart is larger than the maximum size 42 bytes

@ -0,0 +1 @@
Error: decompressed chart is larger than the maximum size 52 bytes

@ -193,8 +193,15 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
return err
}
opts := loader.DefaultChartLoadOptions
if client.MaxChartSize > 0 {
opts.MaxDecompressedChartSize = client.MaxChartSize
}
if client.MaxChartFileSize > 0 {
opts.MaxDecompressedFileSize = client.MaxChartFileSize
}
// Check chart dependencies to make sure all are present in /charts
ch, err := loader.Load(chartPath)
ch, err := loader.LoadWithOptions(chartPath, opts)
if err != nil {
return err
}
@ -217,7 +224,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
return err
}
// Reload the chart with the updated Chart.lock file.
if ch, err = loader.Load(chartPath); err != nil {
if ch, err = loader.LoadWithOptions(chartPath, opts); err != nil {
return fmt.Errorf("failed reloading chart after repo update: %w", err)
}
} else {
@ -297,6 +304,8 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
f.BoolVar(&client.DependencyUpdate, "dependency-update", false, "update dependencies if they are missing before installing the chart")
f.BoolVar(&client.EnableDNS, "enable-dns", false, "enable DNS lookups when rendering templates")
f.BoolVar(&client.TakeOwnership, "take-ownership", false, "if set, upgrade will ignore the check for helm annotations and take ownership of the existing resources")
f.Int64Var(&client.MaxChartSize, "max-chart-size", settings.MaxChartSize, "maximum size in bytes for a decompressed chart (default is 100mb)")
f.Int64Var(&client.MaxChartFileSize, "max-file-size", settings.MaxChartFileSize, "maximum size in bytes for a single file in a chart (default is 5mb)")
addChartPathOptionsFlags(f, &client.ChartPathOptions)
addValueOptionsFlags(f, valueOpts)
bindOutputFlag(cmd, &outfmt)

@ -81,6 +81,7 @@ func TestUpgradeCmd(t *testing.T) {
missingDepsPath := "testdata/testcharts/chart-missing-deps"
badDepsPath := "testdata/testcharts/chart-bad-requirements"
presentDepsPath := "testdata/testcharts/chart-with-subchart-update"
compressedPath := "testdata/testcharts/compressedchart-0.1.0.tgz"
relWithStatusMock := func(n string, v int, ch *chart.Chart, status release.Status) *release.Release {
return release.Mock(&release.MockReleaseOptions{Name: n, Version: v, Chart: ch, Status: status})
@ -188,6 +189,12 @@ func TestUpgradeCmd(t *testing.T) {
golden: "output/upgrade-uninstalled-with-keep-history.txt",
rels: []*release.Release{relWithStatusMock("funny-bunny", 2, ch, release.StatusUninstalled)},
},
{
name: "upgrade with restricted max size",
cmd: fmt.Sprintf("upgrade too-big '%s' --max-chart-size=52", compressedPath),
wantError: true,
golden: "output/upgrade-failed-max-chart-size.txt",
},
}
runTestCmd(t, tests)
}

Loading…
Cancel
Save