github.com/stefanmcshane/helm@v0.0.0-20221213002717-88a4a2c6e77d/pkg/plugin/installer/http_installer.go (about)

     1  /*
     2  Copyright The Helm Authors.
     3  Licensed under the Apache License, Version 2.0 (the "License");
     4  you may not use this file except in compliance with the License.
     5  You may obtain a copy of the License at
     6  
     7  http://www.apache.org/licenses/LICENSE-2.0
     8  
     9  Unless required by applicable law or agreed to in writing, software
    10  distributed under the License is distributed on an "AS IS" BASIS,
    11  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  See the License for the specific language governing permissions and
    13  limitations under the License.
    14  */
    15  
    16  package installer // import "github.com/stefanmcshane/helm/pkg/plugin/installer"
    17  
    18  import (
    19  	"archive/tar"
    20  	"bytes"
    21  	"compress/gzip"
    22  	"io"
    23  	"os"
    24  	"path"
    25  	"path/filepath"
    26  	"regexp"
    27  	"strings"
    28  
    29  	securejoin "github.com/cyphar/filepath-securejoin"
    30  	"github.com/pkg/errors"
    31  
    32  	"github.com/stefanmcshane/helm/internal/third_party/dep/fs"
    33  	"github.com/stefanmcshane/helm/pkg/cli"
    34  	"github.com/stefanmcshane/helm/pkg/getter"
    35  	"github.com/stefanmcshane/helm/pkg/helmpath"
    36  	"github.com/stefanmcshane/helm/pkg/plugin/cache"
    37  )
    38  
    39  // HTTPInstaller installs plugins from an archive served by a web server.
    40  type HTTPInstaller struct {
    41  	CacheDir   string
    42  	PluginName string
    43  	base
    44  	extractor Extractor
    45  	getter    getter.Getter
    46  }
    47  
    48  // TarGzExtractor extracts gzip compressed tar archives
    49  type TarGzExtractor struct{}
    50  
    51  // Extractor provides an interface for extracting archives
    52  type Extractor interface {
    53  	Extract(buffer *bytes.Buffer, targetDir string) error
    54  }
    55  
    56  // Extractors contains a map of suffixes and matching implementations of extractor to return
    57  var Extractors = map[string]Extractor{
    58  	".tar.gz": &TarGzExtractor{},
    59  	".tgz":    &TarGzExtractor{},
    60  }
    61  
    62  // Convert a media type to an extractor extension.
    63  //
    64  // This should be refactored in Helm 4, combined with the extension-based mechanism.
    65  func mediaTypeToExtension(mt string) (string, bool) {
    66  	switch strings.ToLower(mt) {
    67  	case "application/gzip", "application/x-gzip", "application/x-tgz", "application/x-gtar":
    68  		return ".tgz", true
    69  	default:
    70  		return "", false
    71  	}
    72  }
    73  
    74  // NewExtractor creates a new extractor matching the source file name
    75  func NewExtractor(source string) (Extractor, error) {
    76  	for suffix, extractor := range Extractors {
    77  		if strings.HasSuffix(source, suffix) {
    78  			return extractor, nil
    79  		}
    80  	}
    81  	return nil, errors.Errorf("no extractor implemented yet for %s", source)
    82  }
    83  
    84  // NewHTTPInstaller creates a new HttpInstaller.
    85  func NewHTTPInstaller(source string) (*HTTPInstaller, error) {
    86  	key, err := cache.Key(source)
    87  	if err != nil {
    88  		return nil, err
    89  	}
    90  
    91  	extractor, err := NewExtractor(source)
    92  	if err != nil {
    93  		return nil, err
    94  	}
    95  
    96  	get, err := getter.All(new(cli.EnvSettings)).ByScheme("http")
    97  	if err != nil {
    98  		return nil, err
    99  	}
   100  
   101  	i := &HTTPInstaller{
   102  		CacheDir:   helmpath.CachePath("plugins", key),
   103  		PluginName: stripPluginName(filepath.Base(source)),
   104  		base:       newBase(source),
   105  		extractor:  extractor,
   106  		getter:     get,
   107  	}
   108  	return i, nil
   109  }
   110  
   111  // helper that relies on some sort of convention for plugin name (plugin-name-<version>)
   112  func stripPluginName(name string) string {
   113  	var strippedName string
   114  	for suffix := range Extractors {
   115  		if strings.HasSuffix(name, suffix) {
   116  			strippedName = strings.TrimSuffix(name, suffix)
   117  			break
   118  		}
   119  	}
   120  	re := regexp.MustCompile(`(.*)-[0-9]+\..*`)
   121  	return re.ReplaceAllString(strippedName, `$1`)
   122  }
   123  
   124  // Install downloads and extracts the tarball into the cache directory
   125  // and installs into the plugin directory.
   126  //
   127  // Implements Installer.
   128  func (i *HTTPInstaller) Install() error {
   129  	pluginData, err := i.getter.Get(i.Source)
   130  	if err != nil {
   131  		return err
   132  	}
   133  
   134  	if err := i.extractor.Extract(pluginData, i.CacheDir); err != nil {
   135  		return errors.Wrap(err, "extracting files from archive")
   136  	}
   137  
   138  	if !isPlugin(i.CacheDir) {
   139  		return ErrMissingMetadata
   140  	}
   141  
   142  	src, err := filepath.Abs(i.CacheDir)
   143  	if err != nil {
   144  		return err
   145  	}
   146  
   147  	debug("copying %s to %s", src, i.Path())
   148  	return fs.CopyDir(src, i.Path())
   149  }
   150  
   151  // Update updates a local repository
   152  // Not implemented for now since tarball most likely will be packaged by version
   153  func (i *HTTPInstaller) Update() error {
   154  	return errors.Errorf("method Update() not implemented for HttpInstaller")
   155  }
   156  
   157  // Path is overridden because we want to join on the plugin name not the file name
   158  func (i HTTPInstaller) Path() string {
   159  	if i.base.Source == "" {
   160  		return ""
   161  	}
   162  	return helmpath.DataPath("plugins", i.PluginName)
   163  }
   164  
   165  // cleanJoin resolves dest as a subpath of root.
   166  //
   167  // This function runs several security checks on the path, generating an error if
   168  // the supplied `dest` looks suspicious or would result in dubious behavior on the
   169  // filesystem.
   170  //
   171  // cleanJoin assumes that any attempt by `dest` to break out of the CWD is an attempt
   172  // to be malicious. (If you don't care about this, use the securejoin-filepath library.)
   173  // It will emit an error if it detects paths that _look_ malicious, operating on the
   174  // assumption that we don't actually want to do anything with files that already
   175  // appear to be nefarious.
   176  //
   177  //   - The character `:` is considered illegal because it is a separator on UNIX and a
   178  //     drive designator on Windows.
   179  //   - The path component `..` is considered suspicions, and therefore illegal
   180  //   - The character \ (backslash) is treated as a path separator and is converted to /.
   181  //   - Beginning a path with a path separator is illegal
   182  //   - Rudimentary symlink protects are offered by SecureJoin.
   183  func cleanJoin(root, dest string) (string, error) {
   184  
   185  	// On Windows, this is a drive separator. On UNIX-like, this is the path list separator.
   186  	// In neither case do we want to trust a TAR that contains these.
   187  	if strings.Contains(dest, ":") {
   188  		return "", errors.New("path contains ':', which is illegal")
   189  	}
   190  
   191  	// The Go tar library does not convert separators for us.
   192  	// We assume here, as we do elsewhere, that `\\` means a Windows path.
   193  	dest = strings.ReplaceAll(dest, "\\", "/")
   194  
   195  	// We want to alert the user that something bad was attempted. Cleaning it
   196  	// is not a good practice.
   197  	for _, part := range strings.Split(dest, "/") {
   198  		if part == ".." {
   199  			return "", errors.New("path contains '..', which is illegal")
   200  		}
   201  	}
   202  
   203  	// If a path is absolute, the creator of the TAR is doing something shady.
   204  	if path.IsAbs(dest) {
   205  		return "", errors.New("path is absolute, which is illegal")
   206  	}
   207  
   208  	// SecureJoin will do some cleaning, as well as some rudimentary checking of symlinks.
   209  	newpath, err := securejoin.SecureJoin(root, dest)
   210  	if err != nil {
   211  		return "", err
   212  	}
   213  
   214  	return filepath.ToSlash(newpath), nil
   215  }
   216  
   217  // Extract extracts compressed archives
   218  //
   219  // Implements Extractor.
   220  func (g *TarGzExtractor) Extract(buffer *bytes.Buffer, targetDir string) error {
   221  	uncompressedStream, err := gzip.NewReader(buffer)
   222  	if err != nil {
   223  		return err
   224  	}
   225  
   226  	if err := os.MkdirAll(targetDir, 0755); err != nil {
   227  		return err
   228  	}
   229  
   230  	tarReader := tar.NewReader(uncompressedStream)
   231  	for {
   232  		header, err := tarReader.Next()
   233  		if err == io.EOF {
   234  			break
   235  		}
   236  		if err != nil {
   237  			return err
   238  		}
   239  
   240  		path, err := cleanJoin(targetDir, header.Name)
   241  		if err != nil {
   242  			return err
   243  		}
   244  
   245  		switch header.Typeflag {
   246  		case tar.TypeDir:
   247  			if err := os.Mkdir(path, 0755); err != nil {
   248  				return err
   249  			}
   250  		case tar.TypeReg:
   251  			outFile, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
   252  			if err != nil {
   253  				return err
   254  			}
   255  			if _, err := io.Copy(outFile, tarReader); err != nil {
   256  				outFile.Close()
   257  				return err
   258  			}
   259  			outFile.Close()
   260  		// We don't want to process these extension header files.
   261  		case tar.TypeXGlobalHeader, tar.TypeXHeader:
   262  			continue
   263  		default:
   264  			return errors.Errorf("unknown type: %b in %s", header.Typeflag, header.Name)
   265  		}
   266  	}
   267  	return nil
   268  }