github.com/oras-project/oras-go@v0.3.0/pkg/oras/oras_test.go (about)

     1  /*
     2  Copyright The ORAS 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 oras
    17  
    18  import (
    19  	"archive/tar"
    20  	"bytes"
    21  	"compress/gzip"
    22  	"context"
    23  	_ "crypto/sha256"
    24  	"fmt"
    25  	"io"
    26  	"io/ioutil"
    27  	"os"
    28  	"path/filepath"
    29  	"testing"
    30  	"time"
    31  
    32  	orascontent "github.com/oras-project/oras-go/pkg/content"
    33  
    34  	"github.com/containerd/containerd/images"
    35  	"github.com/containerd/containerd/remotes"
    36  	"github.com/containerd/containerd/remotes/docker"
    37  	"github.com/docker/distribution/configuration"
    38  	"github.com/docker/distribution/registry"
    39  	_ "github.com/docker/distribution/registry/storage/driver/inmemory"
    40  	digest "github.com/opencontainers/go-digest"
    41  	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
    42  	"github.com/phayes/freeport"
    43  	"github.com/stretchr/testify/suite"
    44  )
    45  
    46  var (
    47  	testTarball  = "../../testdata/charts/chartmuseum-1.8.2.tgz"
    48  	testDir      = "../../testdata/charts/chartmuseum"
    49  	testDirFiles = []string{
    50  		"Chart.yaml",
    51  		"values.yaml",
    52  		"README.md",
    53  		"templates/_helpers.tpl",
    54  		"templates/NOTES.txt",
    55  		"templates/service.yaml",
    56  		".helmignore",
    57  	}
    58  )
    59  
    60  type ORASTestSuite struct {
    61  	suite.Suite
    62  	DockerRegistryHost string
    63  }
    64  
    65  func newContext() context.Context {
    66  	return context.Background()
    67  }
    68  
    69  func newResolver() remotes.Resolver {
    70  	return docker.NewResolver(docker.ResolverOptions{})
    71  }
    72  
    73  // Start Docker registry
    74  func (suite *ORASTestSuite) SetupSuite() {
    75  	config := &configuration.Configuration{}
    76  	port, err := freeport.GetFreePort()
    77  	if err != nil {
    78  		suite.Nil(err, "no error finding free port for test registry")
    79  	}
    80  	suite.DockerRegistryHost = fmt.Sprintf("localhost:%d", port)
    81  	config.HTTP.Addr = fmt.Sprintf(":%d", port)
    82  	config.HTTP.DrainTimeout = time.Duration(10) * time.Second
    83  	config.Storage = map[string]configuration.Parameters{"inmemory": map[string]interface{}{}}
    84  	dockerRegistry, err := registry.NewRegistry(context.Background(), config)
    85  	suite.Nil(err, "no error finding free port for test registry")
    86  
    87  	go dockerRegistry.ListenAndServe()
    88  }
    89  
    90  // Push files to docker registry
    91  func (suite *ORASTestSuite) Test_0_Push() {
    92  	var (
    93  		err         error
    94  		ref         string
    95  		desc        ocispec.Descriptor
    96  		descriptors []ocispec.Descriptor
    97  		store       *orascontent.FileStore
    98  	)
    99  
   100  	_, err = Push(newContext(), nil, ref, nil, descriptors)
   101  	suite.NotNil(err, "error pushing with empty resolver")
   102  
   103  	_, err = Push(newContext(), newResolver(), ref, nil, descriptors)
   104  	suite.NotNil(err, "error pushing when context missing hostname")
   105  
   106  	ref = fmt.Sprintf("%s/empty:test", suite.DockerRegistryHost)
   107  	_, err = Push(newContext(), newResolver(), ref, nil, descriptors)
   108  	suite.Nil(err, "no error pushing with empty descriptors")
   109  
   110  	// Load descriptors with test chart tgz (as single layer)
   111  	store = orascontent.NewFileStore("")
   112  	basename := filepath.Base(testTarball)
   113  	desc, err = store.Add(basename, "", testTarball)
   114  	suite.Nil(err, "no error loading test chart")
   115  	descriptors = []ocispec.Descriptor{desc}
   116  
   117  	ref = fmt.Sprintf("%s/chart-tgz:test", suite.DockerRegistryHost)
   118  	_, err = Push(newContext(), newResolver(), ref, store, descriptors)
   119  	suite.Nil(err, "no error pushing test chart tgz (as single layer)")
   120  
   121  	// Load descriptors with test chart dir (each file as layer)
   122  	testDirAbs, err := filepath.Abs(testDir)
   123  	suite.Nil(err, "no error parsing test directory")
   124  	store = orascontent.NewFileStore(testDirAbs)
   125  	descriptors = []ocispec.Descriptor{}
   126  	var ff = func(pathX string, infoX os.FileInfo, errX error) error {
   127  		if !infoX.IsDir() {
   128  			filename := filepath.Join(filepath.Dir(pathX), infoX.Name())
   129  			name := filepath.ToSlash(filename)
   130  			desc, err = store.Add(name, "", filename)
   131  			if err != nil {
   132  				return err
   133  			}
   134  			descriptors = append(descriptors, desc)
   135  		}
   136  		return nil
   137  	}
   138  
   139  	cwd, _ := os.Getwd()
   140  	os.Chdir(testDir)
   141  	filepath.Walk(".", ff)
   142  	os.Chdir(cwd)
   143  
   144  	ref = fmt.Sprintf("%s/chart-dir:test", suite.DockerRegistryHost)
   145  	_, err = Push(newContext(), newResolver(), ref, store, descriptors)
   146  	suite.Nil(err, "no error pushing test chart dir (each file as layer)")
   147  }
   148  
   149  // Pull files and verify descriptors
   150  func (suite *ORASTestSuite) Test_1_Pull() {
   151  	var (
   152  		err         error
   153  		ref         string
   154  		descriptors []ocispec.Descriptor
   155  		store       *orascontent.Memorystore
   156  	)
   157  
   158  	_, descriptors, err = Pull(newContext(), nil, ref, nil)
   159  	suite.NotNil(err, "error pulling with empty resolver")
   160  	suite.Nil(descriptors, "descriptors nil pulling with empty resolver")
   161  
   162  	// Pull non-existant
   163  	store = orascontent.NewMemoryStore()
   164  	ref = fmt.Sprintf("%s/nonexistant:test", suite.DockerRegistryHost)
   165  	_, descriptors, err = Pull(newContext(), newResolver(), ref, store)
   166  	suite.NotNil(err, "error pulling non-existant ref")
   167  	suite.Nil(descriptors, "descriptors empty with error")
   168  
   169  	// Pull chart-tgz
   170  	store = orascontent.NewMemoryStore()
   171  	ref = fmt.Sprintf("%s/chart-tgz:test", suite.DockerRegistryHost)
   172  	_, descriptors, err = Pull(newContext(), newResolver(), ref, store)
   173  	suite.Nil(err, "no error pulling chart-tgz ref")
   174  
   175  	// Verify the descriptors, single layer/file
   176  	content, err := ioutil.ReadFile(testTarball)
   177  	suite.Nil(err, "no error loading test chart")
   178  	name := filepath.Base(testTarball)
   179  	_, actualContent, ok := store.GetByName(name)
   180  	suite.True(ok, "find in memory")
   181  	suite.Equal(content, actualContent, ".tgz content matches on pull")
   182  
   183  	// Pull chart-dir
   184  	store = orascontent.NewMemoryStore()
   185  	ref = fmt.Sprintf("%s/chart-dir:test", suite.DockerRegistryHost)
   186  	_, descriptors, err = Pull(newContext(), newResolver(), ref, store)
   187  	suite.Nil(err, "no error pulling chart-dir ref")
   188  
   189  	// Verify the descriptors, multiple layers/files
   190  	cwd, _ := os.Getwd()
   191  	os.Chdir(testDir)
   192  	for _, filename := range testDirFiles {
   193  		content, err = ioutil.ReadFile(filename)
   194  		suite.Nil(err, fmt.Sprintf("no error loading %s", filename))
   195  		_, actualContent, ok := store.GetByName(filename)
   196  		suite.True(ok, "find in memory")
   197  		suite.Equal(content, actualContent, fmt.Sprintf("%s content matches on pull", filename))
   198  	}
   199  	os.Chdir(cwd)
   200  }
   201  
   202  // Push and pull with customized media types
   203  func (suite *ORASTestSuite) Test_2_MediaType() {
   204  	var (
   205  		testData = [][]string{
   206  			{"hi.txt", "application/vnd.me.hi", "hi"},
   207  			{"bye.txt", "application/vnd.me.bye", "bye"},
   208  		}
   209  		err         error
   210  		ref         string
   211  		descriptors []ocispec.Descriptor
   212  		store       *orascontent.Memorystore
   213  	)
   214  
   215  	// Push content with customized media types
   216  	store = orascontent.NewMemoryStore()
   217  	descriptors = nil
   218  	for _, data := range testData {
   219  		desc := store.Add(data[0], data[1], []byte(data[2]))
   220  		descriptors = append(descriptors, desc)
   221  	}
   222  	ref = fmt.Sprintf("%s/media-type:test", suite.DockerRegistryHost)
   223  	_, err = Push(newContext(), newResolver(), ref, store, descriptors)
   224  	suite.Nil(err, "no error pushing test data with customized media type")
   225  
   226  	// Pull with all media types
   227  	store = orascontent.NewMemoryStore()
   228  	ref = fmt.Sprintf("%s/media-type:test", suite.DockerRegistryHost)
   229  	_, descriptors, err = Pull(newContext(), newResolver(), ref, store)
   230  	suite.Nil(err, "no error pulling media-type ref")
   231  	suite.Equal(2, len(descriptors), "number of contents matches on pull")
   232  	for _, data := range testData {
   233  		_, actualContent, ok := store.GetByName(data[0])
   234  		suite.True(ok, "find in memory")
   235  		content := []byte(data[2])
   236  		suite.Equal(content, actualContent, "test content matches on pull")
   237  	}
   238  
   239  	// Pull with specified media type
   240  	store = orascontent.NewMemoryStore()
   241  	ref = fmt.Sprintf("%s/media-type:test", suite.DockerRegistryHost)
   242  	_, descriptors, err = Pull(newContext(), newResolver(), ref, store, WithAllowedMediaType(testData[0][1]))
   243  	suite.Nil(err, "no error pulling media-type ref")
   244  	suite.Equal(1, len(descriptors), "number of contents matches on pull")
   245  	for _, data := range testData[:1] {
   246  		_, actualContent, ok := store.GetByName(data[0])
   247  		suite.True(ok, "find in memory")
   248  		content := []byte(data[2])
   249  		suite.Equal(content, actualContent, "test content matches on pull")
   250  	}
   251  
   252  	// Pull with non-existing media type
   253  	store = orascontent.NewMemoryStore()
   254  	ref = fmt.Sprintf("%s/media-type:test", suite.DockerRegistryHost)
   255  	_, descriptors, err = Pull(newContext(), newResolver(), ref, store, WithAllowedMediaType("non.existing.media.type"))
   256  	suite.Nil(err, "no error pulling media-type ref")
   257  	suite.Equal(0, len(descriptors), "number of contents matches on pull")
   258  }
   259  
   260  // Pull with condition
   261  func (suite *ORASTestSuite) Test_3_Conditional_Pull() {
   262  	var (
   263  		testData = [][]string{
   264  			{"version.txt", "edge"},
   265  			{"content.txt", "hello world"},
   266  		}
   267  		err         error
   268  		ref         string
   269  		descriptors []ocispec.Descriptor
   270  		store       *orascontent.Memorystore
   271  		stop        bool
   272  	)
   273  
   274  	// Push test content
   275  	store = orascontent.NewMemoryStore()
   276  	descriptors = nil
   277  	for _, data := range testData {
   278  		desc := store.Add(data[0], "", []byte(data[1]))
   279  		descriptors = append(descriptors, desc)
   280  	}
   281  	ref = fmt.Sprintf("%s/conditional-pull:test", suite.DockerRegistryHost)
   282  	_, err = Push(newContext(), newResolver(), ref, store, descriptors)
   283  	suite.Nil(err, "no error pushing test data")
   284  
   285  	// Pull all contents in sequence
   286  	store = orascontent.NewMemoryStore()
   287  	ref = fmt.Sprintf("%s/conditional-pull:test", suite.DockerRegistryHost)
   288  	_, descriptors, err = Pull(newContext(), newResolver(), ref, store, WithPullByBFS)
   289  	suite.Nil(err, "no error pulling ref")
   290  	suite.Equal(2, len(descriptors), "number of contents matches on pull")
   291  	for i, data := range testData {
   292  		_, actualContent, ok := store.GetByName(data[0])
   293  		suite.True(ok, "find in memory")
   294  		content := []byte(data[1])
   295  		suite.Equal(content, actualContent, "test content matches on pull")
   296  		name, _ := orascontent.ResolveName(descriptors[i])
   297  		suite.Equal(data[0], name, "content sequence matches on pull")
   298  	}
   299  
   300  	// Selective pull contents: stop at the very beginning
   301  	store = orascontent.NewMemoryStore()
   302  	ref = fmt.Sprintf("%s/conditional-pull:test", suite.DockerRegistryHost)
   303  	_, descriptors, err = Pull(newContext(), newResolver(), ref, store, WithPullByBFS,
   304  		WithPullBaseHandler(images.HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
   305  			if name, ok := orascontent.ResolveName(desc); ok && name == testData[0][0] {
   306  				return nil, ErrStopProcessing
   307  			}
   308  			return nil, nil
   309  		})))
   310  	suite.Nil(err, "no error pulling ref")
   311  	suite.Equal(0, len(descriptors), "number of contents matches on pull")
   312  
   313  	// Selective pull contents: stop in the middle
   314  	store = orascontent.NewMemoryStore()
   315  	ref = fmt.Sprintf("%s/conditional-pull:test", suite.DockerRegistryHost)
   316  	stop = false
   317  	_, descriptors, err = Pull(newContext(), newResolver(), ref, store, WithPullByBFS,
   318  		WithPullBaseHandler(images.HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
   319  			if stop {
   320  				return nil, ErrStopProcessing
   321  			}
   322  			if name, ok := orascontent.ResolveName(desc); ok && name == testData[0][0] {
   323  				stop = true
   324  			}
   325  			return nil, nil
   326  		})))
   327  	suite.Nil(err, "no error pulling ref")
   328  	suite.Equal(1, len(descriptors), "number of contents matches on pull")
   329  	for _, data := range testData[:1] {
   330  		_, actualContent, ok := store.GetByName(data[0])
   331  		suite.True(ok, "find in memory")
   332  		content := []byte(data[1])
   333  		suite.Equal(content, actualContent, "test content matches on pull")
   334  	}
   335  }
   336  
   337  // Test for vulnerability GHSA-g5v4-5x39-vwhx
   338  func (suite *ORASTestSuite) Test_4_GHSA_g5v4_5x39_vwhx() {
   339  	var testVulnerability = func(headers []tar.Header, tag string, expectedError string) {
   340  		// Step 1: build malicious tar+gzip
   341  		buf := bytes.NewBuffer(nil)
   342  		digester := digest.Canonical.Digester()
   343  		zw := gzip.NewWriter(io.MultiWriter(buf, digester.Hash()))
   344  		tarDigester := digest.Canonical.Digester()
   345  		tw := tar.NewWriter(io.MultiWriter(zw, tarDigester.Hash()))
   346  		for _, header := range headers {
   347  			err := tw.WriteHeader(&header)
   348  			suite.Nil(err, "error writing header")
   349  		}
   350  		err := tw.Close()
   351  		suite.Nil(err, "error closing tar")
   352  		err = zw.Close()
   353  		suite.Nil(err, "error closing gzip")
   354  
   355  		// Step 2: construct malicious descriptor
   356  		evilDesc := ocispec.Descriptor{
   357  			MediaType: ocispec.MediaTypeImageLayerGzip,
   358  			Digest:    digester.Digest(),
   359  			Size:      int64(buf.Len()),
   360  			Annotations: map[string]string{
   361  				orascontent.AnnotationDigest: tarDigester.Digest().String(),
   362  				orascontent.AnnotationUnpack: "true",
   363  				ocispec.AnnotationTitle:      "foo",
   364  			},
   365  		}
   366  
   367  		// Step 3: upload malicious artifact to registry
   368  		memoryStore := orascontent.NewMemoryStore()
   369  		memoryStore.Set(evilDesc, buf.Bytes())
   370  		ref := fmt.Sprintf("%s/evil:%s", suite.DockerRegistryHost, tag)
   371  		_, err = Push(newContext(), newResolver(), ref, memoryStore, []ocispec.Descriptor{evilDesc})
   372  		suite.Nil(err, "no error pushing test data")
   373  
   374  		// Step 4: pull malicious tar with oras filestore and ensure error
   375  		tempDir, err := ioutil.TempDir("", "oras_test")
   376  		if err != nil {
   377  			suite.FailNow("error creating temp directory", err)
   378  		}
   379  		defer os.RemoveAll(tempDir)
   380  		store := orascontent.NewFileStore(tempDir)
   381  		defer store.Close()
   382  		ref = fmt.Sprintf("%s/evil:%s", suite.DockerRegistryHost, tag)
   383  		_, _, err = Pull(newContext(), newResolver(), ref, store)
   384  		suite.NotNil(err, "error expected pulling malicious tar")
   385  		suite.Contains(err.Error(),
   386  			expectedError,
   387  			"did not get correct error message",
   388  		)
   389  	}
   390  
   391  	tests := []struct {
   392  		name          string
   393  		headers       []tar.Header
   394  		tag           string
   395  		expectedError string
   396  	}{
   397  		{
   398  			name: "Test symbolic link path traversal",
   399  			headers: []tar.Header{
   400  				{
   401  					Typeflag: tar.TypeDir,
   402  					Name:     "foo/subdir/",
   403  					Mode:     0755,
   404  				},
   405  				{ // Symbolic link to `foo`
   406  					Typeflag: tar.TypeSymlink,
   407  					Name:     "foo/subdir/parent",
   408  					Linkname: "..",
   409  					Mode:     0755,
   410  				},
   411  				{ // Symbolic link to `../etc/passwd`
   412  					Typeflag: tar.TypeSymlink,
   413  					Name:     "foo/subdir/parent/passwd",
   414  					Linkname: "../../etc/passwd",
   415  					Mode:     0644,
   416  				},
   417  				{ // Symbolic link to `../etc`
   418  					Typeflag: tar.TypeSymlink,
   419  					Name:     "foo/subdir/parent/etc",
   420  					Linkname: "../../etc",
   421  					Mode:     0644,
   422  				},
   423  			},
   424  			tag:           "symlink_path",
   425  			expectedError: "no symbolic link allowed",
   426  		},
   427  		{
   428  			name: "Test symbolic link pointing to outside",
   429  			headers: []tar.Header{
   430  				{ // Symbolic link to `/etc/passwd`
   431  					Typeflag: tar.TypeSymlink,
   432  					Name:     "foo/passwd",
   433  					Linkname: "../../../etc/passwd",
   434  					Mode:     0644,
   435  				},
   436  			},
   437  			tag:           "symlink",
   438  			expectedError: "is outside of",
   439  		},
   440  		{
   441  			name: "Test hard link pointing to outside",
   442  			headers: []tar.Header{
   443  				{ // Hard link to `/etc/passwd`
   444  					Typeflag: tar.TypeLink,
   445  					Name:     "foo/passwd",
   446  					Linkname: "../../../etc/passwd",
   447  					Mode:     0644,
   448  				},
   449  			},
   450  			tag:           "hardlink",
   451  			expectedError: "is outside of",
   452  		},
   453  	}
   454  	for _, test := range tests {
   455  		suite.T().Log(test.name)
   456  		testVulnerability(test.headers, test.tag, test.expectedError)
   457  	}
   458  }
   459  
   460  func TestORASTestSuite(t *testing.T) {
   461  	suite.Run(t, new(ORASTestSuite))
   462  }