High severityOSV Advisory· Published Jan 27, 2026· Updated Jan 27, 2026
Kyverno Denial of Service via Context Variable Amplification in Policy Engine
CVE-2026-23881
Description
Kyverno is a policy engine designed for cloud native platform engineering teams. Versions prior to 1.16.3 and 1.15.3 have unbounded memory consumption in Kyverno's policy engine that allows users with policy creation privileges to cause denial of service by crafting policies that exponentially amplify string data through context variables. Versions 1.16.3 and 1.15.3 contain a patch for the vulnerability.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/kyverno/kyvernoGo | < 1.15.3 | 1.15.3 |
github.com/kyverno/kyvernoGo | >= 1.16.0-rc.1, < 1.16.3 | 1.16.3 |
Affected products
1Patches
27a651be3a8c7Merge commit from fork (#14844)
10 files changed · +739 −11
charts/kyverno/README.md+1 −0 modified@@ -300,6 +300,7 @@ The chart values are organised per component. | config.excludeRoles | list | `[]` | Exclude roles | | config.excludeClusterRoles | list | `[]` | Exclude roles | | config.generateSuccessEvents | bool | `false` | Generate success events. | +| config.maxContextSize | string | 2Mi | Maximum cumulative size of context data during policy evaluation. Supports Kubernetes quantity format (e.g., 100Mi, 2Gi) or plain bytes (e.g., 2097152). Limits memory used by context variables to prevent unbounded growth. Increase if policies legitimately need large context data (e.g., processing large ConfigMaps). Set to 0 to disable the limit (not recommended for production). | | config.resourceFilters | list | See [values.yaml](values.yaml) | Resource types to be skipped by the Kyverno policy engine. Make sure to surround each entry in quotes so that it doesn't get parsed as a nested YAML list. These are joined together without spaces, run through `tpl`, and the result is set in the config map. | | config.updateRequestThreshold | int | `1000` | Sets the threshold for the total number of UpdateRequests generated for mutateExisitng and generate policies. | | config.webhooks | object | `{"namespaceSelector":{"matchExpressions":[{"key":"kubernetes.io/metadata.name","operator":"NotIn","values":["kube-system"]}]}}` | Defines the `namespaceSelector`/`objectSelector` in the webhook configurations. The Kyverno namespace is excluded if `excludeKyvernoNamespace` is `true` (default) |
charts/kyverno/templates/config/configmap.yaml+3 −0 modified@@ -54,4 +54,7 @@ data: {{- with .Values.config.matchConditions }} matchConditions: {{ toJson . | quote }} {{- end }} + {{- with .Values.config.maxContextSize }} + maxContextSize: {{ . | quote }} + {{- end }} {{- end -}}
charts/kyverno/values.yaml+8 −0 modified@@ -264,6 +264,14 @@ config: # -- Generate success events. generateSuccessEvents: false + # -- Maximum cumulative size of context data during policy evaluation. + # Supports Kubernetes quantity format (e.g., 100Mi, 2Gi) or plain bytes (e.g., 2097152). + # Limits memory used by context variables to prevent unbounded growth. + # Increase if policies legitimately need large context data (e.g., processing large ConfigMaps). + # Set to 0 to disable the limit (not recommended for production). + # @default -- 2Mi + maxContextSize: ~ + # -- Resource types to be skipped by the Kyverno policy engine. # Make sure to surround each entry in quotes so that it doesn't get parsed as a nested YAML list. # These are joined together without spaces, run through `tpl`, and the result is set in the config map.
pkg/config/config.go+29 −0 modified@@ -11,6 +11,7 @@ import ( osutils "github.com/kyverno/kyverno/pkg/utils/os" admissionregistrationv1 "k8s.io/api/admissionregistration/v1" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/runtime/schema" ) @@ -113,10 +114,14 @@ const ( webhookLabels = "webhookLabels" matchConditions = "matchConditions" updateRequestThreshold = "updateRequestThreshold" + maxContextSize = "maxContextSize" ) const UpdateRequestThreshold = 1000 +// DefaultMaxContextSize is the default maximum size of context data in bytes (2MB) +const DefaultMaxContextSize int64 = 2 * 1024 * 1024 + var ( // kyvernoNamespace is the Kyverno namespace kyvernoNamespace = osutils.GetEnvWithFallback("KYVERNO_NAMESPACE", "kyverno") @@ -204,6 +209,8 @@ type Configuration interface { OnChanged(func()) // GetUpdateRequestThreshold gets the threshold limit for the total number of updaterequests GetUpdateRequestThreshold() int64 + // GetMaxContextSize gets the maximum context size in bytes for policy evaluation + GetMaxContextSize() int64 } // configuration stores the configuration @@ -222,6 +229,7 @@ type configuration struct { mux sync.RWMutex callbacks []func() updateRequestThreshold int64 + maxContextSize int64 } type match struct { @@ -356,6 +364,12 @@ func (cd *configuration) GetUpdateRequestThreshold() int64 { return cd.updateRequestThreshold } +func (cd *configuration) GetMaxContextSize() int64 { + cd.mux.RLock() + defer cd.mux.RUnlock() + return cd.maxContextSize +} + func (cd *configuration) Load(cm *corev1.ConfigMap) { if cm != nil { cd.load(cm) @@ -530,6 +544,20 @@ func (cd *configuration) load(cm *corev1.ConfigMap) { logger.V(2).Info("enableDefaultRegistryMutation configured") } } + // load maxContextSize (supports Kubernetes quantity format: 100Mi, 2Gi, etc.) + cd.maxContextSize = DefaultMaxContextSize + if maxCtxSizeStr, ok := data[maxContextSize]; ok { + logger := logger.WithValues("maxContextSize", maxCtxSizeStr) + quantity, err := resource.ParseQuantity(maxCtxSizeStr) + if err != nil { + logger.Error(err, "maxContextSize is not a valid quantity (use formats like 100Mi, 2Gi, or plain bytes)") + } else { + cd.maxContextSize = quantity.Value() + logger.V(2).Info("maxContextSize configured", "bytes", cd.maxContextSize) + } + } else { + logger.V(2).Info("maxContextSize not set, using default", "default", DefaultMaxContextSize) + } } func (cd *configuration) unload() { @@ -545,6 +573,7 @@ func (cd *configuration) unload() { cd.webhook = WebhookConfig{} cd.webhookAnnotations = nil cd.webhookLabels = nil + cd.maxContextSize = DefaultMaxContextSize logger.V(2).Info("configuration unloaded") }
pkg/config/config_test.go+123 −0 added@@ -0,0 +1,123 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestConfiguration_GetMaxContextSize_Default(t *testing.T) { + cfg := NewDefaultConfiguration(false) + // Load with nil configmap to trigger unload which sets defaults + cfg.Load(nil) + + assert.Equal(t, DefaultMaxContextSize, cfg.GetMaxContextSize()) +} + +func TestConfiguration_GetMaxContextSize_FromConfigMap(t *testing.T) { + cfg := NewDefaultConfiguration(false) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kyverno", + Namespace: "kyverno", + }, + Data: map[string]string{ + "maxContextSize": "4194304", // 4MB + }, + } + + cfg.Load(cm) + + assert.Equal(t, int64(4194304), cfg.GetMaxContextSize()) +} + +func TestConfiguration_GetMaxContextSize_InvalidValue(t *testing.T) { + cfg := NewDefaultConfiguration(false) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kyverno", + Namespace: "kyverno", + }, + Data: map[string]string{ + "maxContextSize": "invalid", + }, + } + + cfg.Load(cm) + + // Should fall back to default on parse error + assert.Equal(t, DefaultMaxContextSize, cfg.GetMaxContextSize()) +} + +func TestConfiguration_GetMaxContextSize_NotSet(t *testing.T) { + cfg := NewDefaultConfiguration(false) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kyverno", + Namespace: "kyverno", + }, + Data: map[string]string{}, + } + + cfg.Load(cm) + + // Should use default when not set + assert.Equal(t, DefaultMaxContextSize, cfg.GetMaxContextSize()) +} + +func TestConfiguration_GetMaxContextSize_ZeroDisablesLimit(t *testing.T) { + cfg := NewDefaultConfiguration(false) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kyverno", + Namespace: "kyverno", + }, + Data: map[string]string{ + "maxContextSize": "0", + }, + } + + cfg.Load(cm) + + // Zero should be valid and disable the limit + assert.Equal(t, int64(0), cfg.GetMaxContextSize()) +} + +func TestConfiguration_GetMaxContextSize_KubernetesQuantityFormat(t *testing.T) { + tests := []struct { + name string + value string + expected int64 + }{ + {"100Mi", "100Mi", 100 * 1024 * 1024}, + {"4Mi", "4Mi", 4 * 1024 * 1024}, + {"1Gi", "1Gi", 1024 * 1024 * 1024}, + {"500Ki", "500Ki", 500 * 1024}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := NewDefaultConfiguration(false) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kyverno", + Namespace: "kyverno", + }, + Data: map[string]string{ + "maxContextSize": tt.value, + }, + } + + cfg.Load(cm) + + assert.Equal(t, tt.expected, cfg.GetMaxContextSize()) + }) + } +}
pkg/config/mocks/mock_config.go+14 −0 modified@@ -93,6 +93,20 @@ func (mr *MockConfigurationMockRecorder) GetMatchConditions() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMatchConditions", reflect.TypeOf((*MockConfiguration)(nil).GetMatchConditions)) } +// GetMaxContextSize mocks base method. +func (m *MockConfiguration) GetMaxContextSize() int64 { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetMaxContextSize") + ret0, _ := ret[0].(int64) + return ret0 +} + +// GetMaxContextSize indicates an expected call of GetMaxContextSize. +func (mr *MockConfigurationMockRecorder) GetMaxContextSize() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaxContextSize", reflect.TypeOf((*MockConfiguration)(nil).GetMaxContextSize)) +} + // GetUpdateRequestThreshold mocks base method. func (m *MockConfiguration) GetUpdateRequestThreshold() int64 { m.ctrl.T.Helper()
pkg/engine/context/context.go+46 −0 modified@@ -114,6 +114,19 @@ type Interface interface { addJSON(dataMap map[string]interface{}, overwriteMaps bool) error } +// DefaultMaxContextSize is the default maximum size of context data in bytes (2MB) +const DefaultMaxContextSize = 2 * 1024 * 1024 + +// ContextSizeLimitExceededError is returned when context size exceeds the limit +type ContextSizeLimitExceededError struct { + Size int64 + Limit int64 +} + +func (e ContextSizeLimitExceededError) Error() string { + return fmt.Sprintf("context size limit exceeded: %d bytes exceeds limit of %d bytes", e.Size, e.Limit) +} + // Context stores the data resources as JSON type context struct { jp jmespath.Interface @@ -122,6 +135,8 @@ type context struct { images map[string]map[string]apiutils.ImageInfo operation kyvernov1.AdmissionOperation deferred DeferredLoaders + contextSize int64 + maxContextSize int64 } // NewContext returns a new context @@ -136,6 +151,18 @@ func NewContextFromRaw(jp jmespath.Interface, raw map[string]interface{}) Interf jsonRaw: raw, jsonRawCheckpoints: make([]map[string]interface{}, 0), deferred: NewDeferredLoaders(), + maxContextSize: DefaultMaxContextSize, + } +} + +// NewContextWithMaxSize returns a new context with a specified maximum context size +func NewContextWithMaxSize(jp jmespath.Interface, maxSize int64) Interface { + return &context{ + jp: jp, + jsonRaw: map[string]interface{}{}, + jsonRawCheckpoints: make([]map[string]interface{}, 0), + deferred: NewDeferredLoaders(), + maxContextSize: maxSize, } } @@ -187,15 +214,22 @@ func (ctx *context) AddVariable(key string, value interface{}) error { } func (ctx *context) AddContextEntry(name string, dataRaw []byte) error { + if err := ctx.checkContextSizeLimit(int64(len(dataRaw))); err != nil { + return err + } var data interface{} if err := json.Unmarshal(dataRaw, &data); err != nil { logger.Error(err, "failed to unmarshal the resource") return err } + ctx.contextSize += int64(len(dataRaw)) return addToContext(ctx, data, false, name) } func (ctx *context) ReplaceContextEntry(name string, dataRaw []byte) error { + if err := ctx.checkContextSizeLimit(int64(len(dataRaw))); err != nil { + return err + } var data interface{} if err := json.Unmarshal(dataRaw, &data); err != nil { logger.Error(err, "failed to unmarshal the resource") @@ -206,6 +240,7 @@ func (ctx *context) ReplaceContextEntry(name string, dataRaw []byte) error { logger.Error(err, "unable to replace context entry", "context entry name", name) return err } + ctx.contextSize += int64(len(dataRaw)) return addToContext(ctx, data, false, name) } @@ -464,3 +499,14 @@ func (ctx *context) AddDeferredLoader(dl DeferredLoader) error { ctx.deferred.Add(dl, len(ctx.jsonRawCheckpoints)) return nil } + +// checkContextSizeLimit checks if adding additionalSize bytes would exceed the context size limit +func (ctx *context) checkContextSizeLimit(additionalSize int64) error { + if ctx.maxContextSize > 0 && ctx.contextSize+additionalSize > ctx.maxContextSize { + return ContextSizeLimitExceededError{ + Size: ctx.contextSize + additionalSize, + Limit: ctx.maxContextSize, + } + } + return nil +}
pkg/engine/context/context_test.go+177 −0 modified@@ -1,7 +1,10 @@ package context import ( + stdjson "encoding/json" + "fmt" "reflect" + "strings" "testing" urkyverno "github.com/kyverno/kyverno/api/kyverno/v2" @@ -284,3 +287,177 @@ func Test_ImageInfoLoader_OnDirectCall(t *testing.T) { imageinfos := newctx.ImageInfo() assert.Equal(t, imageinfos["containers"]["test_container"].Name, "nginx") } + +func Test_ContextSizeLimit(t *testing.T) { + tests := []struct { + name string + maxSize int64 + entries []struct { + name string + data string + } + wantErr bool + expectedErrMsg string + }{ + { + name: "within limit", + maxSize: 1024, + entries: []struct { + name string + data string + }{ + {name: "small", data: `"hello"`}, + }, + wantErr: false, + }, + { + name: "exceeds limit single entry", + maxSize: 10, + entries: []struct { + name string + data string + }{ + {name: "large", data: `"this is a string that exceeds the limit"`}, + }, + wantErr: true, + expectedErrMsg: "context size limit exceeded", + }, + { + name: "exceeds limit cumulative", + maxSize: 50, + entries: []struct { + name string + data string + }{ + {name: "first", data: `"first entry data"`}, + {name: "second", data: `"second entry data"`}, + {name: "third", data: `"third entry that pushes over"`}, + }, + wantErr: true, + expectedErrMsg: "context size limit exceeded", + }, + { + name: "zero limit disables check", + maxSize: 0, + entries: []struct { + name string + data string + }{ + {name: "large", data: `"this can be any size when limit is zero"`}, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &context{ + jp: jp, + jsonRaw: map[string]interface{}{}, + maxContextSize: tt.maxSize, + deferred: NewDeferredLoaders(), + } + + var lastErr error + for _, entry := range tt.entries { + lastErr = ctx.AddContextEntry(entry.name, []byte(entry.data)) + if lastErr != nil { + break + } + } + + if tt.wantErr { + assert.Error(t, lastErr) + assert.Contains(t, lastErr.Error(), tt.expectedErrMsg) + // Verify it's the correct error type + var sizeErr ContextSizeLimitExceededError + assert.ErrorAs(t, lastErr, &sizeErr) + } else { + assert.NoError(t, lastErr) + } + }) + } +} + +func Test_ContextSizeLimitWithReplace(t *testing.T) { + ctx := &context{ + jp: jp, + jsonRaw: map[string]interface{}{}, + maxContextSize: 30, + deferred: NewDeferredLoaders(), + } + + // First entry should succeed + err := ctx.ReplaceContextEntry("var1", []byte(`"a"`)) + assert.NoError(t, err) + assert.Greater(t, ctx.contextSize, int64(0)) + + // Second entry should succeed + err = ctx.ReplaceContextEntry("var2", []byte(`"b"`)) + assert.NoError(t, err) + + // Large entry that exceeds limit should fail + largeData := []byte(`"this string is definitely larger than 30 bytes total"`) + err = ctx.ReplaceContextEntry("var3", largeData) + assert.Error(t, err) + var sizeErr ContextSizeLimitExceededError + assert.ErrorAs(t, err, &sizeErr) +} + +func Test_ContextSizeLimitExceededError(t *testing.T) { + err := ContextSizeLimitExceededError{Size: 3000, Limit: 2000} + assert.Equal(t, "context size limit exceeded: 3000 bytes exceeds limit of 2000 bytes", err.Error()) +} + +// Test_ContextSizeLimitBlocksExponentialAmplification simulates a case where +// exponential string doubling via context variables attempts to consume +// unbounded memory (e.g., 1KB -> 2KB -> 4KB -> ... -> 256MB). +// This test verifies that the context size limit blocks such attacks. +func Test_ContextSizeLimitBlocksExponentialAmplification(t *testing.T) { + // Use a small limit to make the test fast (16KB instead of 2MB default) + const testLimit = 16 * 1024 + + ctx := &context{ + jp: jp, + jsonRaw: map[string]interface{}{}, + maxContextSize: testLimit, + deferred: NewDeferredLoaders(), + } + + // Simulate the pattern: + // l0 = random('[a-zA-Z0-9]{1000}') -> ~1KB + // l1 = join('', [l0, l0]) -> ~2KB + // l2 = join('', [l1, l1]) -> ~4KB + // ... exponential growth until blocked + + baseString := strings.Repeat("a", 1000) + currentData := baseString + + var lastErr error + level := 0 + + for level < 20 { // Would reach 1GB if unchecked + jsonData, err := stdjson.Marshal(currentData) + assert.NoError(t, err) + + entryName := fmt.Sprintf("l%d", level) + lastErr = ctx.AddContextEntry(entryName, jsonData) + + if lastErr != nil { + // Attack blocked by size limit + break + } + + // Double the string for next iteration (simulating join('', [prev, prev])) + currentData = currentData + currentData + level++ + } + + // Verify the attack was blocked before reaching dangerous levels + assert.Error(t, lastErr, "exponential amplification should be blocked") + assert.Less(t, level, 20, "attack should be blocked well before 20 doublings (1GB)") + + var sizeErr ContextSizeLimitExceededError + assert.ErrorAs(t, lastErr, &sizeErr) + assert.LessOrEqual(t, sizeErr.Limit, int64(testLimit)) +}
pkg/engine/policycontext/policy_context.go+12 −11 modified@@ -201,37 +201,37 @@ func NewPolicyContext( admissionInfo *kyvernov2.RequestInfo, configuration config.Configuration, ) (*PolicyContext, error) { - enginectx := enginectx.NewContext(jp) + engineCtx := enginectx.NewContextWithMaxSize(jp, configuration.GetMaxContextSize()) if operation != kyvernov1.Delete { - if err := enginectx.AddResource(resource.Object); err != nil { + if err := engineCtx.AddResource(resource.Object); err != nil { return nil, err } } else { - if err := enginectx.AddOldResource(resource.Object); err != nil { + if err := engineCtx.AddOldResource(resource.Object); err != nil { return nil, err } } - if err := enginectx.AddNamespace(resource.GetNamespace()); err != nil { + if err := engineCtx.AddNamespace(resource.GetNamespace()); err != nil { return nil, err } - if err := enginectx.AddImageInfos(&resource, configuration); err != nil { + if err := engineCtx.AddImageInfos(&resource, configuration); err != nil { return nil, err } if admissionInfo != nil { - if err := enginectx.AddUserInfo(*admissionInfo); err != nil { + if err := engineCtx.AddUserInfo(*admissionInfo); err != nil { return nil, err } - if err := enginectx.AddServiceAccount(admissionInfo.AdmissionUserInfo.Username); err != nil { + if err := engineCtx.AddServiceAccount(admissionInfo.AdmissionUserInfo.Username); err != nil { return nil, err } } - if err := enginectx.AddOperation(string(operation)); err != nil { + if err := engineCtx.AddOperation(string(operation)); err != nil { return nil, err } - policyContext := newPolicyContextWithJsonContext(operation, enginectx) + policyContext := newPolicyContextWithJsonContext(operation, engineCtx) if operation != kyvernov1.Delete { policyContext = policyContext.WithNewResource(resource) } else { @@ -251,7 +251,7 @@ func NewPolicyContextFromAdmissionRequest( gvk schema.GroupVersionKind, configuration config.Configuration, ) (*PolicyContext, error) { - engineCtx, err := newJsonContext(jp, request, &admissionInfo) + engineCtx, err := newJsonContext(jp, request, &admissionInfo, configuration.GetMaxContextSize()) if err != nil { return nil, fmt.Errorf("failed to create policy rule context: %w", err) } @@ -277,8 +277,9 @@ func newJsonContext( jp jmespath.Interface, request admissionv1.AdmissionRequest, userRequestInfo *kyvernov2.RequestInfo, + maxContextSize int64, ) (enginectx.Interface, error) { - engineCtx := enginectx.NewContext(jp) + engineCtx := enginectx.NewContextWithMaxSize(jp, maxContextSize) if err := engineCtx.AddRequest(request); err != nil { return nil, fmt.Errorf("failed to load incoming request in context: %w", err) }
pkg/webhooks/handlers/filter_test.go+326 −0 added@@ -0,0 +1,326 @@ +package handlers + +import ( + "context" + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/kyverno/kyverno/pkg/config" + "github.com/stretchr/testify/assert" + admissionv1 "k8s.io/api/admission/v1" + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type mockConfiguration struct { + excluded bool + filtered bool +} + +func (m *mockConfiguration) GetDefaultRegistry() string { return "" } +func (m *mockConfiguration) GetEnableDefaultRegistryMutation() bool { return false } +func (m *mockConfiguration) GetGenerateSuccessEvents() bool { return false } +func (m *mockConfiguration) GetWebhook() config.WebhookConfig { return config.WebhookConfig{} } +func (m *mockConfiguration) GetWebhookAnnotations() map[string]string { return nil } +func (m *mockConfiguration) GetWebhookLabels() map[string]string { return nil } +func (m *mockConfiguration) GetMatchConditions() []admissionregistrationv1.MatchCondition { + return nil +} +func (m *mockConfiguration) Load(*corev1.ConfigMap) {} +func (m *mockConfiguration) OnChanged(func()) {} +func (m *mockConfiguration) GetUpdateRequestThreshold() int64 { + return 0 +} +func (m *mockConfiguration) GetMaxContextSize() int64 { + return config.DefaultMaxContextSize +} + +func (m *mockConfiguration) IsExcluded(username string, groups []string, roles []string, clusterroles []string) bool { + return m.excluded +} + +func (m *mockConfiguration) ToFilter(kind schema.GroupVersionKind, subresource, namespace, name string) bool { + return m.filtered +} + +func newTestAdmissionRequest(uid string, kind metav1.GroupVersionKind, operation admissionv1.Operation, subResource string) AdmissionRequest { + return AdmissionRequest{ + AdmissionRequest: admissionv1.AdmissionRequest{ + UID: "test-uid", + Kind: kind, + Operation: operation, + Resource: metav1.GroupVersionResource{ + Group: kind.Group, + Version: kind.Version, + Resource: "pods", + }, + SubResource: subResource, + RequestKind: &metav1.GroupVersionKind{ + Group: kind.Group, + Version: kind.Version, + Kind: kind.Kind, + }, + RequestResource: &metav1.GroupVersionResource{ + Group: kind.Group, + Version: kind.Version, + Resource: "pods", + }, + }, + } +} + +func Test_WithFilter(t *testing.T) { + tests := []struct { + name string + config config.Configuration + request AdmissionRequest + wantAllowed bool + wantInnerCalled bool + }{{ + name: "excluded by user exclusion", + config: &mockConfiguration{ + excluded: true, + }, + request: newTestAdmissionRequest("test-uid", metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, admissionv1.Create, ""), + wantAllowed: true, + wantInnerCalled: false, + }, { + name: "filtered by resource filter", + config: &mockConfiguration{ + filtered: true, + }, + request: newTestAdmissionRequest("test-uid", metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, admissionv1.Create, ""), + wantAllowed: true, + wantInnerCalled: false, + }, { + name: "filtered kyverno resource - AdmissionReport", + config: &mockConfiguration{}, + request: newTestAdmissionRequest("test-uid", metav1.GroupVersionKind{ + Group: "kyverno.io", + Version: "v1alpha2", + Kind: "AdmissionReport", + }, admissionv1.Create, ""), + wantAllowed: true, + wantInnerCalled: false, + }, { + name: "filtered kyverno resource - ClusterAdmissionReport", + config: &mockConfiguration{}, + request: newTestAdmissionRequest("test-uid", metav1.GroupVersionKind{ + Group: "kyverno.io", + Version: "v1alpha2", + Kind: "ClusterAdmissionReport", + }, admissionv1.Create, ""), + wantAllowed: true, + wantInnerCalled: false, + }, { + name: "filtered kyverno resource - BackgroundScanReport", + config: &mockConfiguration{}, + request: newTestAdmissionRequest("test-uid", metav1.GroupVersionKind{ + Group: "kyverno.io", + Version: "v1alpha2", + Kind: "BackgroundScanReport", + }, admissionv1.Create, ""), + wantAllowed: true, + wantInnerCalled: false, + }, { + name: "filtered kyverno resource - UpdateRequest", + config: &mockConfiguration{}, + request: newTestAdmissionRequest("test-uid", metav1.GroupVersionKind{ + Group: "kyverno.io", + Version: "v1beta1", + Kind: "UpdateRequest", + }, admissionv1.Create, ""), + wantAllowed: true, + wantInnerCalled: false, + }, { + name: "not filtered - regular Pod", + config: &mockConfiguration{}, + request: newTestAdmissionRequest("test-uid", metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, admissionv1.Create, ""), + wantAllowed: false, + wantInnerCalled: true, + }, { + name: "not filtered - ClusterPolicy (not excluded by ExcludeKyvernoResources)", + config: &mockConfiguration{}, + request: newTestAdmissionRequest("test-uid", metav1.GroupVersionKind{ + Group: "kyverno.io", + Version: "v1", + Kind: "ClusterPolicy", + }, admissionv1.Create, ""), + wantAllowed: false, + wantInnerCalled: true, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + innerCalled := false + inner := func(ctx context.Context, logger logr.Logger, request AdmissionRequest, startTime time.Time) AdmissionResponse { + innerCalled = true + return AdmissionResponse{Allowed: false} + } + handler := AdmissionHandler(inner).WithFilter(tt.config) + response := handler(context.TODO(), logr.Discard(), tt.request, time.Now()) + assert.Equal(t, tt.wantAllowed, response.Allowed) + assert.Equal(t, tt.wantInnerCalled, innerCalled) + }) + } +} + +func Test_WithOperationFilter(t *testing.T) { + tests := []struct { + name string + operations []admissionv1.Operation + requestOp admissionv1.Operation + wantAllowed bool + wantInnerCalled bool + }{{ + name: "allowed operation - CREATE", + operations: []admissionv1.Operation{admissionv1.Create}, + requestOp: admissionv1.Create, + wantAllowed: false, + wantInnerCalled: true, + }, { + name: "allowed operation - UPDATE", + operations: []admissionv1.Operation{admissionv1.Update}, + requestOp: admissionv1.Update, + wantAllowed: false, + wantInnerCalled: true, + }, { + name: "allowed operation - DELETE", + operations: []admissionv1.Operation{admissionv1.Delete}, + requestOp: admissionv1.Delete, + wantAllowed: false, + wantInnerCalled: true, + }, { + name: "allowed multiple operations - CREATE and UPDATE", + operations: []admissionv1.Operation{admissionv1.Create, admissionv1.Update}, + requestOp: admissionv1.Update, + wantAllowed: false, + wantInnerCalled: true, + }, { + name: "filtered operation - UPDATE not in allowed list", + operations: []admissionv1.Operation{admissionv1.Create}, + requestOp: admissionv1.Update, + wantAllowed: true, + wantInnerCalled: false, + }, { + name: "filtered operation - DELETE not in allowed list", + operations: []admissionv1.Operation{admissionv1.Create, admissionv1.Update}, + requestOp: admissionv1.Delete, + wantAllowed: true, + wantInnerCalled: false, + }, { + name: "empty operations list filters all", + operations: []admissionv1.Operation{}, + requestOp: admissionv1.Create, + wantAllowed: true, + wantInnerCalled: false, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + innerCalled := false + inner := func(ctx context.Context, logger logr.Logger, request AdmissionRequest, startTime time.Time) AdmissionResponse { + innerCalled = true + return AdmissionResponse{Allowed: false} + } + request := newTestAdmissionRequest("test-uid", metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, tt.requestOp, "") + handler := AdmissionHandler(inner).WithOperationFilter(tt.operations...) + response := handler(context.TODO(), logr.Discard(), request, time.Now()) + assert.Equal(t, tt.wantAllowed, response.Allowed) + assert.Equal(t, tt.wantInnerCalled, innerCalled) + }) + } +} + +func Test_WithSubResourceFilter(t *testing.T) { + tests := []struct { + name string + subresources []string + requestSubRes string + wantAllowed bool + wantInnerCalled bool + }{{ + name: "allowed subresource - status", + subresources: []string{"status"}, + requestSubRes: "status", + wantAllowed: false, + wantInnerCalled: true, + }, { + name: "allowed subresource - scale", + subresources: []string{"scale"}, + requestSubRes: "scale", + wantAllowed: false, + wantInnerCalled: true, + }, { + name: "allowed multiple subresources", + subresources: []string{"status", "scale"}, + requestSubRes: "scale", + wantAllowed: false, + wantInnerCalled: true, + }, { + name: "empty request subresource is always allowed", + subresources: []string{"status"}, + requestSubRes: "", + wantAllowed: false, + wantInnerCalled: true, + }, { + name: "empty subresource list - empty request allowed", + subresources: []string{}, + requestSubRes: "", + wantAllowed: false, + wantInnerCalled: true, + }, { + name: "filtered subresource - scale not in allowed list", + subresources: []string{"status"}, + requestSubRes: "scale", + wantAllowed: true, + wantInnerCalled: false, + }, { + name: "filtered subresource - eviction not in allowed list", + subresources: []string{"status", "scale"}, + requestSubRes: "eviction", + wantAllowed: true, + wantInnerCalled: false, + }, { + name: "empty subresource list filters all non-empty requests", + subresources: []string{}, + requestSubRes: "status", + wantAllowed: true, + wantInnerCalled: false, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + innerCalled := false + inner := func(ctx context.Context, logger logr.Logger, request AdmissionRequest, startTime time.Time) AdmissionResponse { + innerCalled = true + return AdmissionResponse{Allowed: false} + } + request := newTestAdmissionRequest("test-uid", metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, admissionv1.Create, tt.requestSubRes) + handler := AdmissionHandler(inner).WithSubResourceFilter(tt.subresources...) + response := handler(context.TODO(), logr.Discard(), request, time.Now()) + assert.Equal(t, tt.wantAllowed, response.Allowed) + assert.Equal(t, tt.wantInnerCalled, innerCalled) + }) + } +}
f5617f609205Merge commit from fork (#14846)
10 files changed · +739 −11
charts/kyverno/README.md+1 −0 modified@@ -300,6 +300,7 @@ The chart values are organised per component. | config.excludeUsernames | list | `[]` | Exclude usernames | | config.generateSuccessEvents | bool | `false` | Generate success events. | | config.matchConditions | list | `[]` | Defines match conditions to set on webhook configurations (requires Kubernetes 1.27+). | +| config.maxContextSize | string | 2Mi | Maximum cumulative size of context data during policy evaluation. Supports Kubernetes quantity format (e.g., 100Mi, 2Gi) or plain bytes (e.g., 2097152). Limits memory used by context variables to prevent unbounded growth. Increase if policies legitimately need large context data (e.g., processing large ConfigMaps). Set to 0 to disable the limit (not recommended for production). | | config.name | string | `nil` | The configmap name (required if `create` is `false`). | | config.preserve | bool | `true` | Preserve the configmap settings during upgrade. | | config.resourceFilters | list | See [values.yaml](values.yaml) | Resource types to be skipped by the Kyverno policy engine. Make sure to surround each entry in quotes so that it doesn't get parsed as a nested YAML list. These are joined together without spaces, run through `tpl`, and the result is set in the config map. |
charts/kyverno/templates/config/configmap.yaml+3 −0 modified@@ -54,4 +54,7 @@ data: {{- with .Values.config.matchConditions }} matchConditions: {{ toJson . | quote }} {{- end }} + {{- with .Values.config.maxContextSize }} + maxContextSize: {{ . | quote }} + {{- end }} {{- end -}}
charts/kyverno/values.yaml+8 −0 modified@@ -252,6 +252,14 @@ config: # -- Generate success events. generateSuccessEvents: false + # -- Maximum cumulative size of context data during policy evaluation. + # Supports Kubernetes quantity format (e.g., 100Mi, 2Gi) or plain bytes (e.g., 2097152). + # Limits memory used by context variables to prevent unbounded growth. + # Increase if policies legitimately need large context data (e.g., processing large ConfigMaps). + # Set to 0 to disable the limit (not recommended for production). + # @default -- 2Mi + maxContextSize: ~ + # -- Resource types to be skipped by the Kyverno policy engine. # Make sure to surround each entry in quotes so that it doesn't get parsed as a nested YAML list. # These are joined together without spaces, run through `tpl`, and the result is set in the config map.
pkg/config/config.go+29 −0 modified@@ -11,6 +11,7 @@ import ( osutils "github.com/kyverno/kyverno/pkg/utils/os" admissionregistrationv1 "k8s.io/api/admissionregistration/v1" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/runtime/schema" ) @@ -117,10 +118,14 @@ const ( webhookLabels = "webhookLabels" matchConditions = "matchConditions" updateRequestThreshold = "updateRequestThreshold" + maxContextSize = "maxContextSize" ) const UpdateRequestThreshold = 1000 +// DefaultMaxContextSize is the default maximum size of context data in bytes (2MB) +const DefaultMaxContextSize int64 = 2 * 1024 * 1024 + var ( // kyvernoNamespace is the Kyverno namespace kyvernoNamespace = osutils.GetEnvWithFallback("KYVERNO_NAMESPACE", "kyverno") @@ -208,6 +213,8 @@ type Configuration interface { OnChanged(func()) // GetUpdateRequestThreshold gets the threshold limit for the total number of updaterequests GetUpdateRequestThreshold() int64 + // GetMaxContextSize gets the maximum context size in bytes for policy evaluation + GetMaxContextSize() int64 } // configuration stores the configuration @@ -226,6 +233,7 @@ type configuration struct { mux sync.RWMutex callbacks []func() updateRequestThreshold int64 + maxContextSize int64 } type match struct { @@ -360,6 +368,12 @@ func (cd *configuration) GetUpdateRequestThreshold() int64 { return cd.updateRequestThreshold } +func (cd *configuration) GetMaxContextSize() int64 { + cd.mux.RLock() + defer cd.mux.RUnlock() + return cd.maxContextSize +} + func (cd *configuration) Load(cm *corev1.ConfigMap) { if cm != nil { cd.load(cm) @@ -534,6 +548,20 @@ func (cd *configuration) load(cm *corev1.ConfigMap) { logger.V(2).Info("enableDefaultRegistryMutation configured") } } + // load maxContextSize (supports Kubernetes quantity format: 100Mi, 2Gi, etc.) + cd.maxContextSize = DefaultMaxContextSize + if maxCtxSizeStr, ok := data[maxContextSize]; ok { + logger := logger.WithValues("maxContextSize", maxCtxSizeStr) + quantity, err := resource.ParseQuantity(maxCtxSizeStr) + if err != nil { + logger.Error(err, "maxContextSize is not a valid quantity (use formats like 100Mi, 2Gi, or plain bytes)") + } else { + cd.maxContextSize = quantity.Value() + logger.V(2).Info("maxContextSize configured", "bytes", cd.maxContextSize) + } + } else { + logger.V(2).Info("maxContextSize not set, using default", "default", DefaultMaxContextSize) + } } func (cd *configuration) unload() { @@ -549,6 +577,7 @@ func (cd *configuration) unload() { cd.webhook = WebhookConfig{} cd.webhookAnnotations = nil cd.webhookLabels = nil + cd.maxContextSize = DefaultMaxContextSize logger.V(2).Info("configuration unloaded") }
pkg/config/config_test.go+123 −0 added@@ -0,0 +1,123 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestConfiguration_GetMaxContextSize_Default(t *testing.T) { + cfg := NewDefaultConfiguration(false) + // Load with nil configmap to trigger unload which sets defaults + cfg.Load(nil) + + assert.Equal(t, DefaultMaxContextSize, cfg.GetMaxContextSize()) +} + +func TestConfiguration_GetMaxContextSize_FromConfigMap(t *testing.T) { + cfg := NewDefaultConfiguration(false) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kyverno", + Namespace: "kyverno", + }, + Data: map[string]string{ + "maxContextSize": "4194304", // 4MB + }, + } + + cfg.Load(cm) + + assert.Equal(t, int64(4194304), cfg.GetMaxContextSize()) +} + +func TestConfiguration_GetMaxContextSize_InvalidValue(t *testing.T) { + cfg := NewDefaultConfiguration(false) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kyverno", + Namespace: "kyverno", + }, + Data: map[string]string{ + "maxContextSize": "invalid", + }, + } + + cfg.Load(cm) + + // Should fall back to default on parse error + assert.Equal(t, DefaultMaxContextSize, cfg.GetMaxContextSize()) +} + +func TestConfiguration_GetMaxContextSize_NotSet(t *testing.T) { + cfg := NewDefaultConfiguration(false) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kyverno", + Namespace: "kyverno", + }, + Data: map[string]string{}, + } + + cfg.Load(cm) + + // Should use default when not set + assert.Equal(t, DefaultMaxContextSize, cfg.GetMaxContextSize()) +} + +func TestConfiguration_GetMaxContextSize_ZeroDisablesLimit(t *testing.T) { + cfg := NewDefaultConfiguration(false) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kyverno", + Namespace: "kyverno", + }, + Data: map[string]string{ + "maxContextSize": "0", + }, + } + + cfg.Load(cm) + + // Zero should be valid and disable the limit + assert.Equal(t, int64(0), cfg.GetMaxContextSize()) +} + +func TestConfiguration_GetMaxContextSize_KubernetesQuantityFormat(t *testing.T) { + tests := []struct { + name string + value string + expected int64 + }{ + {"100Mi", "100Mi", 100 * 1024 * 1024}, + {"4Mi", "4Mi", 4 * 1024 * 1024}, + {"1Gi", "1Gi", 1024 * 1024 * 1024}, + {"500Ki", "500Ki", 500 * 1024}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := NewDefaultConfiguration(false) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kyverno", + Namespace: "kyverno", + }, + Data: map[string]string{ + "maxContextSize": tt.value, + }, + } + + cfg.Load(cm) + + assert.Equal(t, tt.expected, cfg.GetMaxContextSize()) + }) + } +}
pkg/config/mocks/mock_config.go+14 −0 modified@@ -93,6 +93,20 @@ func (mr *MockConfigurationMockRecorder) GetMatchConditions() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMatchConditions", reflect.TypeOf((*MockConfiguration)(nil).GetMatchConditions)) } +// GetMaxContextSize mocks base method. +func (m *MockConfiguration) GetMaxContextSize() int64 { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetMaxContextSize") + ret0, _ := ret[0].(int64) + return ret0 +} + +// GetMaxContextSize indicates an expected call of GetMaxContextSize. +func (mr *MockConfigurationMockRecorder) GetMaxContextSize() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaxContextSize", reflect.TypeOf((*MockConfiguration)(nil).GetMaxContextSize)) +} + // GetUpdateRequestThreshold mocks base method. func (m *MockConfiguration) GetUpdateRequestThreshold() int64 { m.ctrl.T.Helper()
pkg/engine/context/context.go+46 −0 modified@@ -114,6 +114,19 @@ type Interface interface { addJSON(dataMap map[string]interface{}, overwriteMaps bool) error } +// DefaultMaxContextSize is the default maximum size of context data in bytes (2MB) +const DefaultMaxContextSize = 2 * 1024 * 1024 + +// ContextSizeLimitExceededError is returned when context size exceeds the limit +type ContextSizeLimitExceededError struct { + Size int64 + Limit int64 +} + +func (e ContextSizeLimitExceededError) Error() string { + return fmt.Sprintf("context size limit exceeded: %d bytes exceeds limit of %d bytes", e.Size, e.Limit) +} + // Context stores the data resources as JSON type context struct { jp jmespath.Interface @@ -122,6 +135,8 @@ type context struct { images map[string]map[string]apiutils.ImageInfo operation kyvernov1.AdmissionOperation deferred DeferredLoaders + contextSize int64 + maxContextSize int64 } // NewContext returns a new context @@ -136,6 +151,18 @@ func NewContextFromRaw(jp jmespath.Interface, raw map[string]interface{}) Interf jsonRaw: raw, jsonRawCheckpoints: make([]map[string]interface{}, 0), deferred: NewDeferredLoaders(), + maxContextSize: DefaultMaxContextSize, + } +} + +// NewContextWithMaxSize returns a new context with a specified maximum context size +func NewContextWithMaxSize(jp jmespath.Interface, maxSize int64) Interface { + return &context{ + jp: jp, + jsonRaw: map[string]interface{}{}, + jsonRawCheckpoints: make([]map[string]interface{}, 0), + deferred: NewDeferredLoaders(), + maxContextSize: maxSize, } } @@ -187,15 +214,22 @@ func (ctx *context) AddVariable(key string, value interface{}) error { } func (ctx *context) AddContextEntry(name string, dataRaw []byte) error { + if err := ctx.checkContextSizeLimit(int64(len(dataRaw))); err != nil { + return err + } var data interface{} if err := json.Unmarshal(dataRaw, &data); err != nil { logger.Error(err, "failed to unmarshal the resource") return err } + ctx.contextSize += int64(len(dataRaw)) return addToContext(ctx, data, false, name) } func (ctx *context) ReplaceContextEntry(name string, dataRaw []byte) error { + if err := ctx.checkContextSizeLimit(int64(len(dataRaw))); err != nil { + return err + } var data interface{} if err := json.Unmarshal(dataRaw, &data); err != nil { logger.Error(err, "failed to unmarshal the resource") @@ -206,6 +240,7 @@ func (ctx *context) ReplaceContextEntry(name string, dataRaw []byte) error { logger.Error(err, "unable to replace context entry", "context entry name", name) return err } + ctx.contextSize += int64(len(dataRaw)) return addToContext(ctx, data, false, name) } @@ -464,3 +499,14 @@ func (ctx *context) AddDeferredLoader(dl DeferredLoader) error { ctx.deferred.Add(dl, len(ctx.jsonRawCheckpoints)) return nil } + +// checkContextSizeLimit checks if adding additionalSize bytes would exceed the context size limit +func (ctx *context) checkContextSizeLimit(additionalSize int64) error { + if ctx.maxContextSize > 0 && ctx.contextSize+additionalSize > ctx.maxContextSize { + return ContextSizeLimitExceededError{ + Size: ctx.contextSize + additionalSize, + Limit: ctx.maxContextSize, + } + } + return nil +}
pkg/engine/context/context_test.go+177 −0 modified@@ -1,7 +1,10 @@ package context import ( + stdjson "encoding/json" + "fmt" "reflect" + "strings" "testing" urkyverno "github.com/kyverno/kyverno/api/kyverno/v2" @@ -284,3 +287,177 @@ func Test_ImageInfoLoader_OnDirectCall(t *testing.T) { imageinfos := newctx.ImageInfo() assert.Equal(t, imageinfos["containers"]["test_container"].Name, "nginx") } + +func Test_ContextSizeLimit(t *testing.T) { + tests := []struct { + name string + maxSize int64 + entries []struct { + name string + data string + } + wantErr bool + expectedErrMsg string + }{ + { + name: "within limit", + maxSize: 1024, + entries: []struct { + name string + data string + }{ + {name: "small", data: `"hello"`}, + }, + wantErr: false, + }, + { + name: "exceeds limit single entry", + maxSize: 10, + entries: []struct { + name string + data string + }{ + {name: "large", data: `"this is a string that exceeds the limit"`}, + }, + wantErr: true, + expectedErrMsg: "context size limit exceeded", + }, + { + name: "exceeds limit cumulative", + maxSize: 50, + entries: []struct { + name string + data string + }{ + {name: "first", data: `"first entry data"`}, + {name: "second", data: `"second entry data"`}, + {name: "third", data: `"third entry that pushes over"`}, + }, + wantErr: true, + expectedErrMsg: "context size limit exceeded", + }, + { + name: "zero limit disables check", + maxSize: 0, + entries: []struct { + name string + data string + }{ + {name: "large", data: `"this can be any size when limit is zero"`}, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &context{ + jp: jp, + jsonRaw: map[string]interface{}{}, + maxContextSize: tt.maxSize, + deferred: NewDeferredLoaders(), + } + + var lastErr error + for _, entry := range tt.entries { + lastErr = ctx.AddContextEntry(entry.name, []byte(entry.data)) + if lastErr != nil { + break + } + } + + if tt.wantErr { + assert.Error(t, lastErr) + assert.Contains(t, lastErr.Error(), tt.expectedErrMsg) + // Verify it's the correct error type + var sizeErr ContextSizeLimitExceededError + assert.ErrorAs(t, lastErr, &sizeErr) + } else { + assert.NoError(t, lastErr) + } + }) + } +} + +func Test_ContextSizeLimitWithReplace(t *testing.T) { + ctx := &context{ + jp: jp, + jsonRaw: map[string]interface{}{}, + maxContextSize: 30, + deferred: NewDeferredLoaders(), + } + + // First entry should succeed + err := ctx.ReplaceContextEntry("var1", []byte(`"a"`)) + assert.NoError(t, err) + assert.Greater(t, ctx.contextSize, int64(0)) + + // Second entry should succeed + err = ctx.ReplaceContextEntry("var2", []byte(`"b"`)) + assert.NoError(t, err) + + // Large entry that exceeds limit should fail + largeData := []byte(`"this string is definitely larger than 30 bytes total"`) + err = ctx.ReplaceContextEntry("var3", largeData) + assert.Error(t, err) + var sizeErr ContextSizeLimitExceededError + assert.ErrorAs(t, err, &sizeErr) +} + +func Test_ContextSizeLimitExceededError(t *testing.T) { + err := ContextSizeLimitExceededError{Size: 3000, Limit: 2000} + assert.Equal(t, "context size limit exceeded: 3000 bytes exceeds limit of 2000 bytes", err.Error()) +} + +// Test_ContextSizeLimitBlocksExponentialAmplification simulates a case where +// exponential string doubling via context variables attempts to consume +// unbounded memory (e.g., 1KB -> 2KB -> 4KB -> ... -> 256MB). +// This test verifies that the context size limit blocks such attacks. +func Test_ContextSizeLimitBlocksExponentialAmplification(t *testing.T) { + // Use a small limit to make the test fast (16KB instead of 2MB default) + const testLimit = 16 * 1024 + + ctx := &context{ + jp: jp, + jsonRaw: map[string]interface{}{}, + maxContextSize: testLimit, + deferred: NewDeferredLoaders(), + } + + // Simulate the pattern: + // l0 = random('[a-zA-Z0-9]{1000}') -> ~1KB + // l1 = join('', [l0, l0]) -> ~2KB + // l2 = join('', [l1, l1]) -> ~4KB + // ... exponential growth until blocked + + baseString := strings.Repeat("a", 1000) + currentData := baseString + + var lastErr error + level := 0 + + for level < 20 { // Would reach 1GB if unchecked + jsonData, err := stdjson.Marshal(currentData) + assert.NoError(t, err) + + entryName := fmt.Sprintf("l%d", level) + lastErr = ctx.AddContextEntry(entryName, jsonData) + + if lastErr != nil { + // Attack blocked by size limit + break + } + + // Double the string for next iteration (simulating join('', [prev, prev])) + currentData = currentData + currentData + level++ + } + + // Verify the attack was blocked before reaching dangerous levels + assert.Error(t, lastErr, "exponential amplification should be blocked") + assert.Less(t, level, 20, "attack should be blocked well before 20 doublings (1GB)") + + var sizeErr ContextSizeLimitExceededError + assert.ErrorAs(t, lastErr, &sizeErr) + assert.LessOrEqual(t, sizeErr.Limit, int64(testLimit)) +}
pkg/engine/policycontext/policy_context.go+12 −11 modified@@ -201,37 +201,37 @@ func NewPolicyContext( admissionInfo *kyvernov2.RequestInfo, configuration config.Configuration, ) (*PolicyContext, error) { - enginectx := enginectx.NewContext(jp) + engineCtx := enginectx.NewContextWithMaxSize(jp, configuration.GetMaxContextSize()) if operation != kyvernov1.Delete { - if err := enginectx.AddResource(resource.Object); err != nil { + if err := engineCtx.AddResource(resource.Object); err != nil { return nil, err } } else { - if err := enginectx.AddOldResource(resource.Object); err != nil { + if err := engineCtx.AddOldResource(resource.Object); err != nil { return nil, err } } - if err := enginectx.AddNamespace(resource.GetNamespace()); err != nil { + if err := engineCtx.AddNamespace(resource.GetNamespace()); err != nil { return nil, err } - if err := enginectx.AddImageInfos(&resource, configuration); err != nil { + if err := engineCtx.AddImageInfos(&resource, configuration); err != nil { return nil, err } if admissionInfo != nil { - if err := enginectx.AddUserInfo(*admissionInfo); err != nil { + if err := engineCtx.AddUserInfo(*admissionInfo); err != nil { return nil, err } - if err := enginectx.AddServiceAccount(admissionInfo.AdmissionUserInfo.Username); err != nil { + if err := engineCtx.AddServiceAccount(admissionInfo.AdmissionUserInfo.Username); err != nil { return nil, err } } - if err := enginectx.AddOperation(string(operation)); err != nil { + if err := engineCtx.AddOperation(string(operation)); err != nil { return nil, err } - policyContext := newPolicyContextWithJsonContext(operation, enginectx) + policyContext := newPolicyContextWithJsonContext(operation, engineCtx) if operation != kyvernov1.Delete { policyContext = policyContext.WithNewResource(resource) } else { @@ -251,7 +251,7 @@ func NewPolicyContextFromAdmissionRequest( gvk schema.GroupVersionKind, configuration config.Configuration, ) (*PolicyContext, error) { - engineCtx, err := newJsonContext(jp, request, &admissionInfo) + engineCtx, err := newJsonContext(jp, request, &admissionInfo, configuration.GetMaxContextSize()) if err != nil { return nil, fmt.Errorf("failed to create policy rule context: %w", err) } @@ -277,8 +277,9 @@ func newJsonContext( jp jmespath.Interface, request admissionv1.AdmissionRequest, userRequestInfo *kyvernov2.RequestInfo, + maxContextSize int64, ) (enginectx.Interface, error) { - engineCtx := enginectx.NewContext(jp) + engineCtx := enginectx.NewContextWithMaxSize(jp, maxContextSize) if err := engineCtx.AddRequest(request); err != nil { return nil, fmt.Errorf("failed to load incoming request in context: %w", err) }
pkg/webhooks/handlers/filter_test.go+326 −0 added@@ -0,0 +1,326 @@ +package handlers + +import ( + "context" + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/kyverno/kyverno/pkg/config" + "github.com/stretchr/testify/assert" + admissionv1 "k8s.io/api/admission/v1" + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type mockConfiguration struct { + excluded bool + filtered bool +} + +func (m *mockConfiguration) GetDefaultRegistry() string { return "" } +func (m *mockConfiguration) GetEnableDefaultRegistryMutation() bool { return false } +func (m *mockConfiguration) GetGenerateSuccessEvents() bool { return false } +func (m *mockConfiguration) GetWebhook() config.WebhookConfig { return config.WebhookConfig{} } +func (m *mockConfiguration) GetWebhookAnnotations() map[string]string { return nil } +func (m *mockConfiguration) GetWebhookLabels() map[string]string { return nil } +func (m *mockConfiguration) GetMatchConditions() []admissionregistrationv1.MatchCondition { + return nil +} +func (m *mockConfiguration) Load(*corev1.ConfigMap) {} +func (m *mockConfiguration) OnChanged(func()) {} +func (m *mockConfiguration) GetUpdateRequestThreshold() int64 { + return 0 +} +func (m *mockConfiguration) GetMaxContextSize() int64 { + return config.DefaultMaxContextSize +} + +func (m *mockConfiguration) IsExcluded(username string, groups []string, roles []string, clusterroles []string) bool { + return m.excluded +} + +func (m *mockConfiguration) ToFilter(kind schema.GroupVersionKind, subresource, namespace, name string) bool { + return m.filtered +} + +func newTestAdmissionRequest(uid string, kind metav1.GroupVersionKind, operation admissionv1.Operation, subResource string) AdmissionRequest { + return AdmissionRequest{ + AdmissionRequest: admissionv1.AdmissionRequest{ + UID: "test-uid", + Kind: kind, + Operation: operation, + Resource: metav1.GroupVersionResource{ + Group: kind.Group, + Version: kind.Version, + Resource: "pods", + }, + SubResource: subResource, + RequestKind: &metav1.GroupVersionKind{ + Group: kind.Group, + Version: kind.Version, + Kind: kind.Kind, + }, + RequestResource: &metav1.GroupVersionResource{ + Group: kind.Group, + Version: kind.Version, + Resource: "pods", + }, + }, + } +} + +func Test_WithFilter(t *testing.T) { + tests := []struct { + name string + config config.Configuration + request AdmissionRequest + wantAllowed bool + wantInnerCalled bool + }{{ + name: "excluded by user exclusion", + config: &mockConfiguration{ + excluded: true, + }, + request: newTestAdmissionRequest("test-uid", metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, admissionv1.Create, ""), + wantAllowed: true, + wantInnerCalled: false, + }, { + name: "filtered by resource filter", + config: &mockConfiguration{ + filtered: true, + }, + request: newTestAdmissionRequest("test-uid", metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, admissionv1.Create, ""), + wantAllowed: true, + wantInnerCalled: false, + }, { + name: "filtered kyverno resource - AdmissionReport", + config: &mockConfiguration{}, + request: newTestAdmissionRequest("test-uid", metav1.GroupVersionKind{ + Group: "kyverno.io", + Version: "v1alpha2", + Kind: "AdmissionReport", + }, admissionv1.Create, ""), + wantAllowed: true, + wantInnerCalled: false, + }, { + name: "filtered kyverno resource - ClusterAdmissionReport", + config: &mockConfiguration{}, + request: newTestAdmissionRequest("test-uid", metav1.GroupVersionKind{ + Group: "kyverno.io", + Version: "v1alpha2", + Kind: "ClusterAdmissionReport", + }, admissionv1.Create, ""), + wantAllowed: true, + wantInnerCalled: false, + }, { + name: "filtered kyverno resource - BackgroundScanReport", + config: &mockConfiguration{}, + request: newTestAdmissionRequest("test-uid", metav1.GroupVersionKind{ + Group: "kyverno.io", + Version: "v1alpha2", + Kind: "BackgroundScanReport", + }, admissionv1.Create, ""), + wantAllowed: true, + wantInnerCalled: false, + }, { + name: "filtered kyverno resource - UpdateRequest", + config: &mockConfiguration{}, + request: newTestAdmissionRequest("test-uid", metav1.GroupVersionKind{ + Group: "kyverno.io", + Version: "v1beta1", + Kind: "UpdateRequest", + }, admissionv1.Create, ""), + wantAllowed: true, + wantInnerCalled: false, + }, { + name: "not filtered - regular Pod", + config: &mockConfiguration{}, + request: newTestAdmissionRequest("test-uid", metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, admissionv1.Create, ""), + wantAllowed: false, + wantInnerCalled: true, + }, { + name: "not filtered - ClusterPolicy (not excluded by ExcludeKyvernoResources)", + config: &mockConfiguration{}, + request: newTestAdmissionRequest("test-uid", metav1.GroupVersionKind{ + Group: "kyverno.io", + Version: "v1", + Kind: "ClusterPolicy", + }, admissionv1.Create, ""), + wantAllowed: false, + wantInnerCalled: true, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + innerCalled := false + inner := func(ctx context.Context, logger logr.Logger, request AdmissionRequest, startTime time.Time) AdmissionResponse { + innerCalled = true + return AdmissionResponse{Allowed: false} + } + handler := AdmissionHandler(inner).WithFilter(tt.config) + response := handler(context.TODO(), logr.Discard(), tt.request, time.Now()) + assert.Equal(t, tt.wantAllowed, response.Allowed) + assert.Equal(t, tt.wantInnerCalled, innerCalled) + }) + } +} + +func Test_WithOperationFilter(t *testing.T) { + tests := []struct { + name string + operations []admissionv1.Operation + requestOp admissionv1.Operation + wantAllowed bool + wantInnerCalled bool + }{{ + name: "allowed operation - CREATE", + operations: []admissionv1.Operation{admissionv1.Create}, + requestOp: admissionv1.Create, + wantAllowed: false, + wantInnerCalled: true, + }, { + name: "allowed operation - UPDATE", + operations: []admissionv1.Operation{admissionv1.Update}, + requestOp: admissionv1.Update, + wantAllowed: false, + wantInnerCalled: true, + }, { + name: "allowed operation - DELETE", + operations: []admissionv1.Operation{admissionv1.Delete}, + requestOp: admissionv1.Delete, + wantAllowed: false, + wantInnerCalled: true, + }, { + name: "allowed multiple operations - CREATE and UPDATE", + operations: []admissionv1.Operation{admissionv1.Create, admissionv1.Update}, + requestOp: admissionv1.Update, + wantAllowed: false, + wantInnerCalled: true, + }, { + name: "filtered operation - UPDATE not in allowed list", + operations: []admissionv1.Operation{admissionv1.Create}, + requestOp: admissionv1.Update, + wantAllowed: true, + wantInnerCalled: false, + }, { + name: "filtered operation - DELETE not in allowed list", + operations: []admissionv1.Operation{admissionv1.Create, admissionv1.Update}, + requestOp: admissionv1.Delete, + wantAllowed: true, + wantInnerCalled: false, + }, { + name: "empty operations list filters all", + operations: []admissionv1.Operation{}, + requestOp: admissionv1.Create, + wantAllowed: true, + wantInnerCalled: false, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + innerCalled := false + inner := func(ctx context.Context, logger logr.Logger, request AdmissionRequest, startTime time.Time) AdmissionResponse { + innerCalled = true + return AdmissionResponse{Allowed: false} + } + request := newTestAdmissionRequest("test-uid", metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, tt.requestOp, "") + handler := AdmissionHandler(inner).WithOperationFilter(tt.operations...) + response := handler(context.TODO(), logr.Discard(), request, time.Now()) + assert.Equal(t, tt.wantAllowed, response.Allowed) + assert.Equal(t, tt.wantInnerCalled, innerCalled) + }) + } +} + +func Test_WithSubResourceFilter(t *testing.T) { + tests := []struct { + name string + subresources []string + requestSubRes string + wantAllowed bool + wantInnerCalled bool + }{{ + name: "allowed subresource - status", + subresources: []string{"status"}, + requestSubRes: "status", + wantAllowed: false, + wantInnerCalled: true, + }, { + name: "allowed subresource - scale", + subresources: []string{"scale"}, + requestSubRes: "scale", + wantAllowed: false, + wantInnerCalled: true, + }, { + name: "allowed multiple subresources", + subresources: []string{"status", "scale"}, + requestSubRes: "scale", + wantAllowed: false, + wantInnerCalled: true, + }, { + name: "empty request subresource is always allowed", + subresources: []string{"status"}, + requestSubRes: "", + wantAllowed: false, + wantInnerCalled: true, + }, { + name: "empty subresource list - empty request allowed", + subresources: []string{}, + requestSubRes: "", + wantAllowed: false, + wantInnerCalled: true, + }, { + name: "filtered subresource - scale not in allowed list", + subresources: []string{"status"}, + requestSubRes: "scale", + wantAllowed: true, + wantInnerCalled: false, + }, { + name: "filtered subresource - eviction not in allowed list", + subresources: []string{"status", "scale"}, + requestSubRes: "eviction", + wantAllowed: true, + wantInnerCalled: false, + }, { + name: "empty subresource list filters all non-empty requests", + subresources: []string{}, + requestSubRes: "status", + wantAllowed: true, + wantInnerCalled: false, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + innerCalled := false + inner := func(ctx context.Context, logger logr.Logger, request AdmissionRequest, startTime time.Time) AdmissionResponse { + innerCalled = true + return AdmissionResponse{Allowed: false} + } + request := newTestAdmissionRequest("test-uid", metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, admissionv1.Create, tt.requestSubRes) + handler := AdmissionHandler(inner).WithSubResourceFilter(tt.subresources...) + response := handler(context.TODO(), logr.Discard(), request, time.Now()) + assert.Equal(t, tt.wantAllowed, response.Allowed) + assert.Equal(t, tt.wantInnerCalled, innerCalled) + }) + } +}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-r2rj-wwm5-x6mqghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-23881ghsaADVISORY
- github.com/kyverno/kyverno/commit/7a651be3a8c78dcabfbf4178b8d89026bf3b850fghsax_refsource_MISCWEB
- github.com/kyverno/kyverno/commit/f5617f60920568a301740485472bf704892175b7ghsax_refsource_MISCWEB
- github.com/kyverno/kyverno/security/advisories/GHSA-r2rj-wwm5-x6mqghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.