Skip to content

Commit 7bcf9bc

Browse files
committed
Reject ambiguous manifest formats
Refuse to process manifest / manifest list data that could possibly be interpreted as two different manifest formats, because differences in how those ambiguities are resolved could be used to bypass image verification or review mechanisms. Fixes CVE-2021-41190 / GHSA-77vh-xpmg-72qh . Signed-off-by: Miloslav Trmač <mitr@redhat.com>
1 parent b55fb86 commit 7bcf9bc

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)