mirror of
https://github.com/containerd/containerd.git
synced 2026-06-24 08:48:48 +00:00
Do not propagate reserved labels from image configs
Image config labels are copied onto the container by both the CRI
plugin (BuildLabels) and the client's WithImageConfigLabels option
used by `ctr run`. Labels in the containerd.io/* namespace are
interpreted by containerd itself and labels in the io.cri-containerd*
namespace are interpreted by the CRI plugin. An image config is not a
trusted source for labels in either namespace.
Skip labels in both reserved namespaces when copying labels from an
image config to a container, and warn about each label skipped: an
image that tries to set them may be attempting to alter containerd
behavior. Oversized image labels are already skipped this way by
the CRI plugin.
Labels set explicitly by clients, for example via `ctr run --label`
or in the CRI request, are unaffected.
Verified with the CRI plugin and with `ctr run` against an image
whose config carries labels like these: the labels are no longer
present on the created container and a warning is logged for each.
Assisted-by: Claude Code
Signed-off-by: Ben Cressey <ben@cressey.org>
Signed-off-by: Samuel Karp <samuelkarp@google.com>
(cherry picked from commit 0ec1af4cae)
Signed-off-by: Akihiro Suda <akihiro.suda.cz@hco.ntt.co.jp>
Signed-off-by: Samuel Karp <samuelkarp@google.com>
This commit is contained in:
@@ -26,10 +26,12 @@ import (
|
||||
"github.com/containerd/containerd/content"
|
||||
"github.com/containerd/containerd/errdefs"
|
||||
"github.com/containerd/containerd/images"
|
||||
"github.com/containerd/containerd/labels"
|
||||
"github.com/containerd/containerd/namespaces"
|
||||
"github.com/containerd/containerd/oci"
|
||||
"github.com/containerd/containerd/protobuf"
|
||||
"github.com/containerd/containerd/snapshots"
|
||||
"github.com/containerd/log"
|
||||
"github.com/containerd/typeurl/v2"
|
||||
"github.com/opencontainers/image-spec/identity"
|
||||
v1 "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
@@ -114,6 +116,10 @@ func WithContainerLabels(labels map[string]string) NewContainerOpts {
|
||||
// The existing labels are cleared as this is expected to be the first
|
||||
// operation in setting up a container's labels. Use WithAdditionalContainerLabels
|
||||
// to add/overwrite the existing image config labels.
|
||||
//
|
||||
// Image config labels in the namespaces reserved for containerd
|
||||
// (containerd.io/) and the CRI plugin (io.cri-containerd) are not copied
|
||||
// to the container.
|
||||
func WithImageConfigLabels(image Image) NewContainerOpts {
|
||||
return func(ctx context.Context, _ *Client, c *containers.Container) error {
|
||||
ic, err := image.Config(ctx)
|
||||
@@ -139,6 +145,15 @@ func WithImageConfigLabels(image Image) NewContainerOpts {
|
||||
return fmt.Errorf("unknown image config media type %s", ic.MediaType)
|
||||
}
|
||||
c.Labels = config.Labels
|
||||
// Labels in the containerd.io/* namespace are interpreted by containerd
|
||||
// itself, and labels in the io.cri-containerd.* namespace are interpreted
|
||||
// by the CRI plugin, so they are not copied from untrusted image configs.
|
||||
for k := range c.Labels {
|
||||
if labels.IsReserved(k) {
|
||||
log.G(ctx).Warnf("skipping image label %q: the label namespace is reserved for containerd; possible malicious image attempting to alter containerd behavior", k)
|
||||
delete(c.Labels, k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
75
container_opts_test.go
Normal file
75
container_opts_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
Copyright The containerd Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package containerd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/containerd/containerd/containers"
|
||||
"github.com/containerd/containerd/content"
|
||||
"github.com/opencontainers/go-digest"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeImage implements the subset of Image used by WithImageConfigLabels:
|
||||
// Config returns a descriptor with the config blob inlined in Data, so the
|
||||
// content store is never consulted.
|
||||
type fakeImage struct {
|
||||
Image
|
||||
config ocispec.Descriptor
|
||||
}
|
||||
|
||||
func (i fakeImage) Config(context.Context) (ocispec.Descriptor, error) {
|
||||
return i.config, nil
|
||||
}
|
||||
|
||||
func (i fakeImage) ContentStore() content.Store {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestWithImageConfigLabels(t *testing.T) {
|
||||
blob, err := json.Marshal(ocispec.Image{
|
||||
Config: ocispec.ImageConfig{
|
||||
Labels: map[string]string{
|
||||
"foo": "bar",
|
||||
"containerd.io/restart.policy": "always",
|
||||
"io.cri-containerd.kind": "sandbox",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
img := fakeImage{
|
||||
config: ocispec.Descriptor{
|
||||
MediaType: ocispec.MediaTypeImageConfig,
|
||||
Digest: digest.FromBytes(blob),
|
||||
Size: int64(len(blob)),
|
||||
Data: blob,
|
||||
},
|
||||
}
|
||||
|
||||
var c containers.Container
|
||||
require.NoError(t, WithImageConfigLabels(img)(t.Context(), nil, &c))
|
||||
|
||||
// labels in the namespaces reserved for containerd and the CRI plugin
|
||||
// are not copied from the image config
|
||||
assert.Equal(t, map[string]string{"foo": "bar"}, c.Labels)
|
||||
}
|
||||
@@ -16,6 +16,18 @@
|
||||
|
||||
package labels
|
||||
|
||||
// ReservedPrefix is the prefix of the label namespace reserved for labels
|
||||
// defined and consumed by containerd itself. Labels in this namespace must
|
||||
// not be copied from untrusted sources such as image config labels. Use
|
||||
// IsReserved to check for such labels.
|
||||
const ReservedPrefix = "containerd.io/"
|
||||
|
||||
// CRIContainerdPrefix is the prefix of the label namespace reserved for
|
||||
// labels defined and consumed by containerd's CRI plugin. Labels in this
|
||||
// namespace must not be copied from untrusted sources such as image config
|
||||
// labels. Use IsReserved to check for such labels.
|
||||
const CRIContainerdPrefix = "io.cri-containerd"
|
||||
|
||||
// LabelUncompressed is added to compressed layer contents.
|
||||
// The value is digest of the uncompressed content.
|
||||
const LabelUncompressed = "containerd.io/uncompressed"
|
||||
|
||||
@@ -18,6 +18,7 @@ package labels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/containerd/containerd/errdefs"
|
||||
)
|
||||
@@ -39,3 +40,11 @@ func Validate(k, v string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsReserved returns true if the label key is in a namespace reserved for
|
||||
// containerd (ReservedPrefix) or its CRI plugin (CRIContainerdPrefix).
|
||||
// Reserved labels are interpreted by containerd and must not be copied from
|
||||
// untrusted sources such as image config labels.
|
||||
func IsReserved(k string) bool {
|
||||
return strings.HasPrefix(k, ReservedPrefix) || strings.HasPrefix(k, CRIContainerdPrefix)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,23 @@ func TestInvalidLabels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsReserved(t *testing.T) {
|
||||
for key, reserved := range map[string]bool{
|
||||
"containerd.io/": true,
|
||||
"containerd.io/restart.status": true,
|
||||
"containerd.io/gc.ref.content": true,
|
||||
"io.cri-containerd": true,
|
||||
"io.cri-containerd.kind": true,
|
||||
"io.cri-containerd.image": true,
|
||||
"io.cri-containerdfoo": true,
|
||||
"containerd.io": false,
|
||||
"io.containerd.something": false,
|
||||
"com.example.app": false,
|
||||
} {
|
||||
assert.Equal(t, reserved, IsReserved(key), "IsReserved(%q)", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongKey(t *testing.T) {
|
||||
key := strings.Repeat("s", keyMaxLen+1)
|
||||
value := strings.Repeat("v", maxSize-len(key))
|
||||
|
||||
@@ -16,9 +16,13 @@
|
||||
|
||||
package labels
|
||||
|
||||
import (
|
||||
clabels "github.com/containerd/containerd/labels"
|
||||
)
|
||||
|
||||
const (
|
||||
// criContainerdPrefix is common prefix for cri-containerd
|
||||
criContainerdPrefix = "io.cri-containerd"
|
||||
criContainerdPrefix = clabels.CRIContainerdPrefix
|
||||
// ImageLabelKey is the label key indicating the image is managed by cri plugin.
|
||||
ImageLabelKey = criContainerdPrefix + ".image"
|
||||
// ImageLabelValue is the label value indicating the image is managed by cri plugin.
|
||||
|
||||
@@ -313,11 +313,21 @@ func filterLabel(k, v string) string {
|
||||
return fmt.Sprintf("labels.%q==%q", k, v)
|
||||
}
|
||||
|
||||
// buildLabel builds the labels from config to be passed to containerd
|
||||
// buildLabels builds the labels from config to be passed to containerd.
|
||||
// Image config labels in the namespaces reserved for containerd
|
||||
// (containerd.io/) and the CRI plugin (io.cri-containerd) are not copied
|
||||
// to the container.
|
||||
func buildLabels(configLabels, imageConfigLabels map[string]string, containerType string) map[string]string {
|
||||
labels := make(map[string]string)
|
||||
|
||||
for k, v := range imageConfigLabels {
|
||||
// Labels in the containerd.io/* namespace are interpreted by containerd
|
||||
// itself, and labels in the io.cri-containerd.* namespace are interpreted
|
||||
// by the CRI plugin, so they are not copied from untrusted image configs.
|
||||
if clabels.IsReserved(k) {
|
||||
logrus.Warnf("skipping image label %q: the label namespace is reserved for containerd; possible malicious image attempting to alter containerd behavior", k)
|
||||
continue
|
||||
}
|
||||
if err := clabels.Validate(k, v); err == nil {
|
||||
labels[k] = v
|
||||
} else {
|
||||
|
||||
@@ -128,21 +128,29 @@ func TestGetRepoDigestAndTag(t *testing.T) {
|
||||
|
||||
func TestBuildLabels(t *testing.T) {
|
||||
imageConfigLabels := map[string]string{
|
||||
"a": "z",
|
||||
"d": "y",
|
||||
"long-label": strings.Repeat("example", 10000),
|
||||
"a": "z",
|
||||
"d": "y",
|
||||
"long-label": strings.Repeat("example", 10000),
|
||||
"containerd.io/restart.policy": "always",
|
||||
"io.cri-containerd.image": "managed",
|
||||
}
|
||||
configLabels := map[string]string{
|
||||
"a": "b",
|
||||
"c": "d",
|
||||
// reserved namespaces are only filtered for image config labels, not
|
||||
// for labels from the CRI request
|
||||
"containerd.io/restart.status": "stopped",
|
||||
}
|
||||
newLabels := buildLabels(configLabels, imageConfigLabels, containerKindSandbox)
|
||||
assert.Len(t, newLabels, 4)
|
||||
assert.Len(t, newLabels, 5)
|
||||
assert.Equal(t, "b", newLabels["a"])
|
||||
assert.Equal(t, "d", newLabels["c"])
|
||||
assert.Equal(t, "y", newLabels["d"])
|
||||
assert.Equal(t, "stopped", newLabels["containerd.io/restart.status"])
|
||||
assert.Equal(t, containerKindSandbox, newLabels[containerKindLabel])
|
||||
assert.NotContains(t, newLabels, "long-label")
|
||||
assert.NotContains(t, newLabels, "containerd.io/restart.policy")
|
||||
assert.NotContains(t, newLabels, "io.cri-containerd.image")
|
||||
|
||||
newLabels["a"] = "e"
|
||||
assert.Empty(t, configLabels[containerKindLabel], "should not add new labels into original label")
|
||||
|
||||
@@ -122,11 +122,21 @@ func getUserFromImage(user string) (*int64, string) {
|
||||
return &uid, ""
|
||||
}
|
||||
|
||||
// buildLabel builds the labels from config to be passed to containerd
|
||||
// buildLabels builds the labels from config to be passed to containerd.
|
||||
// Image config labels in the namespaces reserved for containerd
|
||||
// (containerd.io/) and the CRI plugin (io.cri-containerd) are not copied
|
||||
// to the container.
|
||||
func buildLabels(configLabels, imageConfigLabels map[string]string, containerType string) map[string]string {
|
||||
labels := make(map[string]string)
|
||||
|
||||
for k, v := range imageConfigLabels {
|
||||
// Labels in the containerd.io/* namespace are interpreted by containerd
|
||||
// itself, and labels in the io.cri-containerd.* namespace are interpreted
|
||||
// by the CRI plugin, so they are not copied from untrusted image configs.
|
||||
if clabels.IsReserved(k) {
|
||||
logrus.Warnf("skipping image label %q: the label namespace is reserved for containerd; possible malicious image attempting to alter containerd behavior", k)
|
||||
continue
|
||||
}
|
||||
if err := clabels.Validate(k, v); err == nil {
|
||||
labels[k] = v
|
||||
} else {
|
||||
|
||||
@@ -111,21 +111,29 @@ func TestGetRepoDigestAndTag(t *testing.T) {
|
||||
|
||||
func TestBuildLabels(t *testing.T) {
|
||||
imageConfigLabels := map[string]string{
|
||||
"a": "z",
|
||||
"d": "y",
|
||||
"long-label": strings.Repeat("example", 10000),
|
||||
"a": "z",
|
||||
"d": "y",
|
||||
"long-label": strings.Repeat("example", 10000),
|
||||
"containerd.io/restart.policy": "always",
|
||||
"io.cri-containerd.image": "managed",
|
||||
}
|
||||
configLabels := map[string]string{
|
||||
"a": "b",
|
||||
"c": "d",
|
||||
// reserved namespaces are only filtered for image config labels, not
|
||||
// for labels from the CRI request
|
||||
"containerd.io/restart.status": "stopped",
|
||||
}
|
||||
newLabels := buildLabels(configLabels, imageConfigLabels, containerKindSandbox)
|
||||
assert.Len(t, newLabels, 4)
|
||||
assert.Len(t, newLabels, 5)
|
||||
assert.Equal(t, "b", newLabels["a"])
|
||||
assert.Equal(t, "d", newLabels["c"])
|
||||
assert.Equal(t, "y", newLabels["d"])
|
||||
assert.Equal(t, "stopped", newLabels["containerd.io/restart.status"])
|
||||
assert.Equal(t, containerKindSandbox, newLabels[containerKindLabel])
|
||||
assert.NotContains(t, newLabels, "long-label")
|
||||
assert.NotContains(t, newLabels, "containerd.io/restart.policy")
|
||||
assert.NotContains(t, newLabels, "io.cri-containerd.image")
|
||||
|
||||
newLabels["a"] = "e"
|
||||
assert.Empty(t, configLabels[containerKindLabel], "should not add new labels into original label")
|
||||
|
||||
@@ -282,11 +282,21 @@ func filterLabel(k, v string) string {
|
||||
return fmt.Sprintf("labels.%q==%q", k, v)
|
||||
}
|
||||
|
||||
// buildLabel builds the labels from config to be passed to containerd
|
||||
// buildLabels builds the labels from config to be passed to containerd.
|
||||
// Image config labels in the namespaces reserved for containerd
|
||||
// (containerd.io/) and the CRI plugin (io.cri-containerd) are not copied
|
||||
// to the container.
|
||||
func buildLabels(configLabels, imageConfigLabels map[string]string, containerType string) map[string]string {
|
||||
labels := make(map[string]string)
|
||||
|
||||
for k, v := range imageConfigLabels {
|
||||
// Labels in the containerd.io/* namespace are interpreted by containerd
|
||||
// itself, and labels in the io.cri-containerd.* namespace are interpreted
|
||||
// by the CRI plugin, so they are not copied from untrusted image configs.
|
||||
if clabels.IsReserved(k) {
|
||||
logrus.Warnf("skipping image label %q: the label namespace is reserved for containerd; possible malicious image attempting to alter containerd behavior", k)
|
||||
continue
|
||||
}
|
||||
if err := clabels.Validate(k, v); err == nil {
|
||||
labels[k] = v
|
||||
} else {
|
||||
|
||||
@@ -128,21 +128,29 @@ func TestGetRepoDigestAndTag(t *testing.T) {
|
||||
|
||||
func TestBuildLabels(t *testing.T) {
|
||||
imageConfigLabels := map[string]string{
|
||||
"a": "z",
|
||||
"d": "y",
|
||||
"long-label": strings.Repeat("example", 10000),
|
||||
"a": "z",
|
||||
"d": "y",
|
||||
"long-label": strings.Repeat("example", 10000),
|
||||
"containerd.io/restart.policy": "always",
|
||||
"io.cri-containerd.image": "managed",
|
||||
}
|
||||
configLabels := map[string]string{
|
||||
"a": "b",
|
||||
"c": "d",
|
||||
// reserved namespaces are only filtered for image config labels, not
|
||||
// for labels from the CRI request
|
||||
"containerd.io/restart.status": "stopped",
|
||||
}
|
||||
newLabels := buildLabels(configLabels, imageConfigLabels, containerKindSandbox)
|
||||
assert.Len(t, newLabels, 4)
|
||||
assert.Len(t, newLabels, 5)
|
||||
assert.Equal(t, "b", newLabels["a"])
|
||||
assert.Equal(t, "d", newLabels["c"])
|
||||
assert.Equal(t, "y", newLabels["d"])
|
||||
assert.Equal(t, "stopped", newLabels["containerd.io/restart.status"])
|
||||
assert.Equal(t, containerKindSandbox, newLabels[containerKindLabel])
|
||||
assert.NotContains(t, newLabels, "long-label")
|
||||
assert.NotContains(t, newLabels, "containerd.io/restart.policy")
|
||||
assert.NotContains(t, newLabels, "io.cri-containerd.image")
|
||||
|
||||
newLabels["a"] = "e"
|
||||
assert.Empty(t, configLabels[containerKindLabel], "should not add new labels into original label")
|
||||
|
||||
Reference in New Issue
Block a user