Skip to content

Commit e2f122e

Browse files
authored
Merge pull request #1409 from mtrmac/ambiguous-1
Reject ambiguous manifest formats
2 parents b55fb86 + 7bcf9bc commit e2f122e

12 files changed

+294
-0
lines changed

manifest/common.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package manifest
22

33
import (
4+
"encoding/json"
45
"fmt"
56

67
compressiontypes "github.com/containers/image/v5/pkg/compression/types"
@@ -32,6 +33,72 @@ func dupStringStringMap(m map[string]string) map[string]string {
3233
return result
3334
}
3435

36+
// allowedManifestFields is a bit mask of “essential” manifest fields that validateUnambiguousManifestFormat
37+
// can expect to be present.
38+
type allowedManifestFields int
39+
40+
const (
41+
allowedFieldConfig allowedManifestFields = 1 << iota
42+
allowedFieldFSLayers
43+
allowedFieldHistory
44+
allowedFieldLayers
45+
allowedFieldManifests
46+
allowedFieldFirstUnusedBit // Keep this at the end!
47+
)
48+
49+
// validateUnambiguousManifestFormat rejects manifests (incl. multi-arch) that look like more than
50+
// one kind we currently recognize, i.e. if they contain any of the known “essential” format fields
51+
// other than the ones the caller specifically allows.
52+
// expectedMIMEType is used only for diagnostics.
53+
// NOTE: The caller should do the non-heuristic validations (e.g. check for any specified format
54+
// identification/version, or other “magic numbers”) before calling this, to cleanly reject unambigous
55+
// data that just isn’t what was expected, as opposed to actually ambiguous data.
56+
func validateUnambiguousManifestFormat(manifest []byte, expectedMIMEType string,
57+
allowed allowedManifestFields) error {
58+
if allowed >= allowedFieldFirstUnusedBit {
59+
return fmt.Errorf("internal error: invalid allowedManifestFields value %#v", allowed)
60+
}
61+
// Use a private type to decode, not just a map[string]interface{}, because we want
62+
// to also reject case-insensitive matches (which would be used by Go when really decoding
63+
// the manifest).
64+
// (It is expected that as manifest formats are added or extended over time, more fields will be added
65+
// here.)
66+
detectedFields := struct {
67+
Config interface{} `json:"config"`
68+
FSLayers interface{} `json:"fsLayers"`
69+
History interface{} `json:"history"`
70+
Layers interface{} `json:"layers"`
71+
Manifests interface{} `json:"manifests"`
72+
}{}
73+
if err := json.Unmarshal(manifest, &detectedFields); err != nil {
74+
// The caller was supposed to already validate version numbers, so this shold not happen;
75+
// let’s not bother with making this error “nice”.
76+
return err
77+
}
78+
unexpected := []string{}
79+
// Sadly this isn’t easy to automate in Go, without reflection. So, copy&paste.
80+
if detectedFields.Config != nil && (allowed&allowedFieldConfig) == 0 {
81+
unexpected = append(unexpected, "config")
82+
}
83+
if detectedFields.FSLayers != nil && (allowed&allowedFieldFSLayers) == 0 {
84+
unexpected = append(unexpected, "fsLayers")
85+
}
86+
if detectedFields.History != nil && (allowed&allowedFieldHistory) == 0 {
87+
unexpected = append(unexpected, "history")
88+
}
89+
if detectedFields.Layers != nil && (allowed&allowedFieldLayers) == 0 {
90+
unexpected = append(unexpected, "layers")
91+
}
92+
if detectedFields.Manifests != nil && (allowed&allowedFieldManifests) == 0 {
93+
unexpected = append(unexpected, "manifests")
94+
}
95+
if len(unexpected) != 0 {
96+
return fmt.Errorf(`rejecting ambiguous manifest, unexpected fields %#v in supposedly %s`,
97+
unexpected, expectedMIMEType)
98+
}
99+
return nil
100+
}
101+
35102
// layerInfosToStrings converts a list of layer infos, presumably obtained from a Manifest.LayerInfos()
36103
// method call, into a format suitable for inclusion in a types.ImageInspectInfo structure.
37104
func layerInfosToStrings(infos []LayerInfo) []string {

manifest/common_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package manifest
22

33
import (
4+
"bytes"
5+
"fmt"
6+
"io/ioutil"
7+
"path/filepath"
48
"testing"
59

610
"github.com/containers/image/v5/pkg/compression"
@@ -11,6 +15,86 @@ import (
1115
"github.com/stretchr/testify/require"
1216
)
1317

18+
func TestValidateUnambiguousManifestFormat(t *testing.T) {
19+
const allAllowedFields = allowedFieldFirstUnusedBit - 1
20+
const mt = "text/plain" // Just some MIME type that shows up in error messages
21+
22+
type test struct {
23+
manifest string
24+
allowed allowedManifestFields
25+
}
26+
27+
// Smoke tests: Success
28+
for _, c := range []test{
29+
{"{}", allAllowedFields},
30+
{"{}", 0},
31+
} {
32+
err := validateUnambiguousManifestFormat([]byte(c.manifest), mt, c.allowed)
33+
assert.NoError(t, err, c)
34+
}
35+
// Smoke tests: Failure
36+
for _, c := range []test{
37+
{"{}", allowedFieldFirstUnusedBit}, // Invalid "allowed"
38+
{"@", allAllowedFields}, // Invalid JSON
39+
} {
40+
err := validateUnambiguousManifestFormat([]byte(c.manifest), mt, c.allowed)
41+
assert.Error(t, err, c)
42+
}
43+
44+
fields := map[allowedManifestFields]string{
45+
allowedFieldConfig: "config",
46+
allowedFieldFSLayers: "fsLayers",
47+
allowedFieldHistory: "history",
48+
allowedFieldLayers: "layers",
49+
allowedFieldManifests: "manifests",
50+
}
51+
// Ensure this test covers all defined allowedManifestFields values
52+
allFields := allowedManifestFields(0)
53+
for k := range fields {
54+
allFields |= k
55+
}
56+
assert.Equal(t, allAllowedFields, allFields)
57+
58+
// Every single field is allowed by its bit, and rejected by any other bit
59+
for bit, fieldName := range fields {
60+
json := []byte(fmt.Sprintf(`{"%s":[]}`, fieldName))
61+
err := validateUnambiguousManifestFormat(json, mt, bit)
62+
assert.NoError(t, err, fieldName)
63+
err = validateUnambiguousManifestFormat(json, mt, allAllowedFields^bit)
64+
assert.Error(t, err, fieldName)
65+
}
66+
}
67+
68+
// Test that parser() rejects all of the provided manifest fixtures.
69+
// Intended to help test manifest parsers' detection of schema mismatches.
70+
func testManifestFixturesAreRejected(t *testing.T, parser func([]byte) error, fixtures []string) {
71+
for _, fixture := range fixtures {
72+
manifest, err := ioutil.ReadFile(filepath.Join("fixtures", fixture))
73+
require.NoError(t, err, fixture)
74+
err = parser(manifest)
75+
assert.Error(t, err, fixture)
76+
}
77+
}
78+
79+
// Test that parser() rejects validManifest with an added top-level field with any of the provided field names.
80+
// Intended to help test callers of validateUnambiguousManifestFormat.
81+
func testValidManifestWithExtraFieldsIsRejected(t *testing.T, parser func([]byte) error,
82+
validManifest []byte, fields []string) {
83+
for _, field := range fields {
84+
// end (the final '}') is not always at len(validManifest)-1 because the manifest can end with
85+
// white space.
86+
end := bytes.LastIndexByte(validManifest, '}')
87+
require.NotEqual(t, end, -1)
88+
updatedManifest := []byte(string(validManifest[:end]) +
89+
fmt.Sprintf(`,"%s":[]}`, field))
90+
err := parser(updatedManifest)
91+
assert.Error(t, err, field)
92+
// Make sure it is the error from validateUnambiguousManifestFormat, not something that
93+
// went wrong with creating updatedManifest.
94+
assert.Contains(t, err.Error(), "rejecting ambiguous manifest")
95+
}
96+
}
97+
1498
func TestLayerInfosToStrings(t *testing.T) {
1599
strings := layerInfosToStrings([]LayerInfo{})
16100
assert.Equal(t, []string{}, strings)

manifest/docker_schema1.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ func Schema1FromManifest(manifest []byte) (*Schema1, error) {
6060
if s1.SchemaVersion != 1 {
6161
return nil, errors.Errorf("unsupported schema version %d", s1.SchemaVersion)
6262
}
63+
if err := validateUnambiguousManifestFormat(manifest, DockerV2Schema1SignedMediaType,
64+
allowedFieldFSLayers|allowedFieldHistory); err != nil {
65+
return nil, err
66+
}
6367
if err := s1.initialize(); err != nil {
6468
return nil, err
6569
}

manifest/docker_schema1_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,32 @@ func manifestSchema1FromFixture(t *testing.T, fixture string) *Schema1 {
2020
return m
2121
}
2222

23+
func TestSchema1FromManifest(t *testing.T) {
24+
validManifest, err := ioutil.ReadFile(filepath.Join("fixtures", "schema2-to-schema1-by-docker.json"))
25+
require.NoError(t, err)
26+
27+
// Invalid manifest version is rejected
28+
m, err := Schema1FromManifest(validManifest)
29+
require.NoError(t, err)
30+
m.SchemaVersion = 2
31+
manifest, err := m.Serialize()
32+
require.NoError(t, err)
33+
_, err = Schema1FromManifest(manifest)
34+
assert.Error(t, err)
35+
36+
parser := func(m []byte) error {
37+
_, err := Schema1FromManifest(m)
38+
return err
39+
}
40+
// Schema mismatch is rejected
41+
testManifestFixturesAreRejected(t, parser, []string{
42+
"v2s2.manifest.json", "v2list.manifest.json",
43+
"ociv1.manifest.json", "ociv1.image.index.json",
44+
})
45+
// Extra fields are rejected
46+
testValidManifestWithExtraFieldsIsRejected(t, parser, validManifest, []string{"config", "layers", "manifests"})
47+
}
48+
2349
func TestSchema1Initialize(t *testing.T) {
2450
// Test this indirectly via Schema1FromComponents; otherwise we would have to break the API and create an instance manually.
2551

manifest/docker_schema2.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,10 @@ func Schema2FromManifest(manifest []byte) (*Schema2, error) {
165165
if err := json.Unmarshal(manifest, &s2); err != nil {
166166
return nil, err
167167
}
168+
if err := validateUnambiguousManifestFormat(manifest, DockerV2Schema2MediaType,
169+
allowedFieldConfig|allowedFieldLayers); err != nil {
170+
return nil, err
171+
}
168172
// Check manifest's and layers' media types.
169173
if err := SupportedSchema2MediaType(s2.MediaType); err != nil {
170174
return nil, err

manifest/docker_schema2_list.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,10 @@ func Schema2ListFromManifest(manifest []byte) (*Schema2List, error) {
192192
if err := json.Unmarshal(manifest, &list); err != nil {
193193
return nil, errors.Wrapf(err, "unmarshaling Schema2List %q", string(manifest))
194194
}
195+
if err := validateUnambiguousManifestFormat(manifest, DockerV2ListMediaType,
196+
allowedFieldManifests); err != nil {
197+
return nil, err
198+
}
195199
return &list, nil
196200
}
197201

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package manifest
2+
3+
import (
4+
"io/ioutil"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestSchema2ListFromManifest(t *testing.T) {
12+
validManifest, err := ioutil.ReadFile(filepath.Join("fixtures", "v2list.manifest.json"))
13+
require.NoError(t, err)
14+
15+
parser := func(m []byte) error {
16+
_, err := Schema2ListFromManifest(m)
17+
return err
18+
}
19+
// Schema mismatch is rejected
20+
testManifestFixturesAreRejected(t, parser, []string{
21+
"schema2-to-schema1-by-docker.json",
22+
"v2s2.manifest.json",
23+
"ociv1.manifest.json",
24+
// Not "ociv1.image.index.json" yet, without validating mediaType the two are too similar to tell the difference.
25+
})
26+
// Extra fields are rejected
27+
testValidManifestWithExtraFieldsIsRejected(t, parser, validManifest, []string{"config", "fsLayers", "history", "layers"})
28+
}

manifest/docker_schema2_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ package manifest
22

33
import (
44
"io/ioutil"
5+
"path/filepath"
56
"testing"
67

78
"github.com/containers/image/v5/pkg/compression"
89
"github.com/containers/image/v5/types"
910
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
1012
)
1113

1214
func TestSupportedSchema2MediaType(t *testing.T) {
@@ -58,6 +60,24 @@ func TestSupportedSchema2MediaType(t *testing.T) {
5860
}
5961
}
6062

63+
func TestSchema2FromManifest(t *testing.T) {
64+
validManifest, err := ioutil.ReadFile(filepath.Join("fixtures", "v2s2.manifest.json"))
65+
require.NoError(t, err)
66+
67+
parser := func(m []byte) error {
68+
_, err := Schema2FromManifest(m)
69+
return err
70+
}
71+
// Schema mismatch is rejected
72+
testManifestFixturesAreRejected(t, parser, []string{
73+
"schema2-to-schema1-by-docker.json",
74+
"v2list.manifest.json",
75+
"ociv1.manifest.json", "ociv1.image.index.json",
76+
})
77+
// Extra fields are rejected
78+
testValidManifestWithExtraFieldsIsRejected(t, parser, validManifest, []string{"fsLayers", "history", "manifests"})
79+
}
80+
6181
func TestUpdateLayerInfosV2S2GzipToZstd(t *testing.T) {
6282
bytes, err := ioutil.ReadFile("fixtures/v2s2.manifest.json")
6383
assert.Nil(t, err)

manifest/oci.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ func OCI1FromManifest(manifest []byte) (*OCI1, error) {
5454
if err := json.Unmarshal(manifest, &oci1); err != nil {
5555
return nil, err
5656
}
57+
if err := validateUnambiguousManifestFormat(manifest, imgspecv1.MediaTypeImageIndex,
58+
allowedFieldConfig|allowedFieldLayers); err != nil {
59+
return nil, err
60+
}
5761
return &oci1, nil
5862
}
5963

manifest/oci_index.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,10 @@ func OCI1IndexFromManifest(manifest []byte) (*OCI1Index, error) {
202202
if err := json.Unmarshal(manifest, &index); err != nil {
203203
return nil, errors.Wrapf(err, "unmarshaling OCI1Index %q", string(manifest))
204204
}
205+
if err := validateUnambiguousManifestFormat(manifest, imgspecv1.MediaTypeImageIndex,
206+
allowedFieldManifests); err != nil {
207+
return nil, err
208+
}
205209
return &index, nil
206210
}
207211

0 commit comments

Comments
 (0)