Skip to content

Commit ab93bd2

Browse files
fix(appsec/nginx): reject cross-namespace --configmap refs in pod mutation (#51635) ### What does this PR do?
Fixes a confused-deputy vulnerability in the Cluster Agent's AppSec ingress-nginx admission mutator. The webhook previously extracted the namespace from the pod's `--configmap=<ns>/<name>` argument and used it verbatim for ConfigMap `Get`/`Create`/`Update` calls. Combined with the DCA's cluster-wide `configmaps` permissions, a low-privileged tenant with `create pods` rights in one namespace could trigger writes to ConfigMaps in arbitrary namespaces. Changes: - **`pkg/clusteragent/appsec/nginx/sidecar.go`** — `findControllerConfigMapArg` now requires the `<ns>` portion to match the pod's own namespace (resolving `$(POD_NAMESPACE)` first) and rejects empty names. On rejection, `MutatePod` returns `(false, nil)` to preserve fail-open admission semantics — the pod is admitted unmodified. - **`pkg/clusteragent/appsec/nginx/events.go`** — New `CrossNamespaceConfigMapRefused` warning event on the **pod** (not the IngressClass) so the diagnostic lands in the tenant's namespace where their operator can see it. - **`pkg/clusteragent/appsec/nginx/configmap.go`** — Defense-in-depth `validateConfigMapTarget` (DNS-1123 validation) at the entry of `createOrUpdateDDConfigMap`, covering both webhook and reconciler paths. - **Tests** — `TestMutatePod_CrossNamespaceConfigMapRefused` (the bisect anchor) asserts no API calls escape and the pod spec is unmodified. `TestFindControllerConfigMapArg` extended from 3 cases to 10 covering same-ns, foreign ns, `kube-system` reference, leading/trailing slash, multi-container priority. - **Release note** — `releasenotes/notes/fix-appsec-nginx-configmap-confused-deputy-*.yaml` (security section). The introducing change was PR #49318 (Agent 7.78.0). All releases ≥7.78.0 are affected; backports to `7.78.x`, `7.79.x`, `7.80.x` will follow. ### Motivation Tracking: [APPSEC-68212](https://datadoghq.atlassian.net/browse/APPSEC-68212). Internal vulnerability report `clusteragent-appsec-nginx-configmap-confused-deputy` (severity High, threat model k8s-tenant). Full mitigation plan: `.sisyphus/plans/clusteragent-appsec-nginx-configmap-confused-deputy-mitigation.md`. Pre-condition for exploitation: DCA with `cluster_agent.appsec.injector.enabled = true` (helm: `datadog.appsec.injector.enabled: true`), at least one ingress-nginx `IngressClass` (`controller: k8s.io/ingress-nginx`), and a tenant with `create pods` permission in any namespace. ### Describe how you validated your changes **1. Automated tests (run in CI):** ```bash dda inv test --targets=./pkg/clusteragent/appsec/nginx # 57/57 passed dda inv test --targets=./pkg/clusteragent/admission/mutate/appsec # 35/35 passed dda inv linter.go --targets=./pkg/clusteragent/appsec/nginx # 0 issues dda inv linter.releasenote # passed ``` `TestMutatePod_CrossNamespaceConfigMapRefused` is the **bisect anchor**: it fails against the unpatched code (which accepted `--configmap=kube-system/coredns` verbatim) and passes against this patch. **2. Live exploit reproduction (k3s, rancher-desktop, 7.78.0 + this patch):** Setup the DCA with my patched binary: ```bash # Overlay the patched binary on the 7.78.0 base image cat > Dockerfile.overlay <<'DOCKERFILE' FROM datadog/cluster-agent:7.78.0 COPY bin/datadog-cluster-agent/datadog-cluster-agent /opt/datadog-agent/bin/datadog-cluster-agent DOCKERFILE docker build --platform linux/arm64 -t datadog/cluster-agent:7.78.0-fix -f Dockerfile.overlay . # Install DCA with AppSec ingress-nginx enabled cat > values.yaml <<'YAML' datadog: apiKey: "0000000000000000000000000000000000000000" appKey: "0000000000000000000000000000000000000000" clusterName: confused-deputy-test appsec: injector: enabled: true autoDetect: false proxies: [ingress-nginx] clusterAgent: image: repository: datadog/cluster-agent tag: 7.78.0-fix pullPolicy: IfNotPresent admissionController: enabled: true agents: { enabled: false } clusterChecksRunner: { enabled: false } YAML helm install dd datadog/datadog -n datadog --create-namespace -f values.yaml # Pre-condition: at least one ingress-nginx IngressClass kubectl apply -f - <<'YAML' apiVersion: networking.k8s.io/v1 kind: IngressClass metadata: { name: nginx-test } spec: { controller: k8s.io/ingress-nginx } YAML ``` Apply the exploit pod from a low-privileged tenant namespace: ```yaml # attacker-pod.yaml apiVersion: v1 kind: Namespace metadata: { name: attacker-ns } --- apiVersion: v1 kind: Pod metadata: name: confused-deputy-poc namespace: attacker-ns labels: app.kubernetes.io/name: ingress-nginx app.kubernetes.io/component: controller spec: containers: - name: c image: registry.k8s.io/ingress-nginx/controller:v1.15.1 args: - /nginx-ingress-controller - --configmap=kube-system/coredns - --election-id=test ``` **Expected (and observed) outcomes:** | Assertion | Command | Result | |---|---|---| | Pod admitted (fail-open) | `kubectl get pod -n attacker-ns confused-deputy-poc` | ✅ admitted, no admission error | | Args UNMODIFIED | `kubectl get pod -n attacker-ns confused-deputy-poc -o jsonpath='{.spec.containers[0].args}'` | ✅ `--configmap=kube-system/coredns` preserved | | No init container injected | `kubectl get pod -n attacker-ns confused-deputy-poc -o jsonpath='{.spec.initContainers}'` | ✅ empty | | No DD ConfigMap in `kube-system` | `kubectl get cm -n kube-system \| grep -i datadog-appsec` | ✅ none | | Warning event on pod | `kubectl get events -n attacker-ns --field-selector involvedObject.name=confused-deputy-poc` | ✅ `Warning CrossNamespaceConfigMapRefused AppSec nginx mutation skipped: --configmap references a namespace different from the pod's namespace; refusing to mutate to avoid confused-deputy ConfigMap writes: pod attacker-ns/confused-deputy-poc, arg "--configmap=kube-system/coredns"` | | DCA log line | `kubectl logs -n datadog deploy/dd-datadog-cluster-agent \| grep "AppSec mutation skipped"` | ✅ `WARN \| nginx AppSec mutation skipped for pod attacker-ns/confused-deputy-poc: --configmap references a namespace different from the pod's namespace` | **3. Regression check — legitimate ingress-nginx deployments still work** Pods using the upstream Helm default (`--configmap=$(POD_NAMESPACE)/ingress-nginx-controller`) and pods using a literal same-namespace ref (`--configmap=ingress-nginx/my-config` when the pod is in `ingress-nginx`) are accepted and mutated normally. Covered by `TestFindControllerConfigMapArg/standard_$(POD_NAMESPACE)_form_is_accepted` and `.../hardcoded_same_namespace_is_accepted`. ### Additional Notes - **`qa/rc-required` is required** — admission webhook changes touch cross-component behavior (DCA ↔ kube-apiserver ↔ node agents) per `AGENTS.md` guidance. - The fix is **fail-open**: rejection results in the pod being admitted unmodified with a warning event and log line — never a failed admission. Legitimate ingress-nginx deployments using `$(POD_NAMESPACE)/...` (the upstream Helm default) are unaffected. - `createOrUpdateDDConfigMap` gains DNS-1123 validation as defense-in-depth. It is a no-op for the reconciler path (whose namespace/name come from a label-filtered informer watch and are already valid Kubernetes objects) and catches any future code path that bypasses `findControllerConfigMapArg`. - Follow-ups deferred to separate Jira tickets per the plan: - E2E test in `test/new-e2e/tests/clusteragent/appsec/` (§4.5) - Owner-reference pre-check on ingress-nginx pods (§5.2) - `ValidatingAdmissionPolicy` for ConfigMap creation scope (§6 Option C) Co-authored-by: eliott.bouhana <eliott.bouhana@datadoghq.com> (cherry picked from commit 9ae4dea) ___ Co-authored-by: Eliott B <47679741+eliottness@users.noreply.github.com>
1 parent 98c7c0e commit ab93bd2

5 files changed

Lines changed: 251 additions & 21 deletions

File tree

pkg/clusteragent/appsec/nginx/configmap.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1919
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
2020
"k8s.io/apimachinery/pkg/runtime"
21+
"k8s.io/apimachinery/pkg/util/validation"
2122
"k8s.io/client-go/dynamic"
2223
)
2324

@@ -83,9 +84,26 @@ func stripDDSnippet(snippet string) string {
8384
return snippet[:startIdx] + snippet[endIdx:]
8485
}
8586

87+
// validateConfigMapTarget enforces Kubernetes DNS-1123 naming on the namespace
88+
// and name reaching the API client. This is a defense-in-depth guard against
89+
// any future call path that bypasses findControllerConfigMapArg; it is a no-op
90+
// for the reconciler path where values come from valid Kubernetes objects.
91+
func validateConfigMapTarget(namespace, name string) error {
92+
if errs := validation.IsDNS1123Label(namespace); len(errs) > 0 {
93+
return fmt.Errorf("invalid ConfigMap namespace %q: %v", namespace, errs)
94+
}
95+
if errs := validation.IsDNS1123Subdomain(name); len(errs) > 0 {
96+
return fmt.Errorf("invalid ConfigMap name %q: %v", name, errs)
97+
}
98+
return nil
99+
}
100+
86101
// createOrUpdateDDConfigMap creates or updates the DD-owned ConfigMap by mirroring the original
87102
// and prepending Datadog AppSec directives to main-snippet and http-snippet.
88103
func createOrUpdateDDConfigMap(ctx context.Context, client dynamic.Interface, namespace, originalCMName, moduleMountPath string, labels, annotations map[string]string) error {
104+
if err := validateConfigMapTarget(namespace, originalCMName); err != nil {
105+
return err
106+
}
89107
ddName := ddConfigMapName(originalCMName)
90108

91109
// Fetch original ConfigMap (may not exist if user hasn't customized anything)

pkg/clusteragent/appsec/nginx/events.go

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ import (
1414

1515
// Event reasons for ingress-nginx ConfigMap operations
1616
const (
17-
EventReasonConfigMapCreated = "DatadogConfigMapCreated"
18-
EventReasonConfigMapCreateFailed = "DatadogConfigMapCreateFailed"
19-
EventReasonConfigMapDeleted = "DatadogConfigMapDeleted"
20-
EventReasonConfigMapDeleteFailed = "DatadogConfigMapDeleteFailed"
21-
EventReasonVersionParseFailed = "VersionParseFailed"
17+
EventReasonConfigMapCreated = "DatadogConfigMapCreated"
18+
EventReasonConfigMapCreateFailed = "DatadogConfigMapCreateFailed"
19+
EventReasonConfigMapDeleted = "DatadogConfigMapDeleted"
20+
EventReasonConfigMapDeleteFailed = "DatadogConfigMapDeleteFailed"
21+
EventReasonVersionParseFailed = "VersionParseFailed"
22+
EventReasonCrossNamespaceConfigMapRefused = "CrossNamespaceConfigMapRefused"
2223
)
2324

2425
// eventRecorder provides methods to record Kubernetes events for appsec nginx resources
@@ -86,3 +87,25 @@ func (e *eventRecorder) recordVersionParseFailed(podName, image string) {
8687
image,
8788
)
8889
}
90+
91+
// recordCrossNamespaceConfigMapRefused emits a Warning event on the pod itself
92+
// (not the target ConfigMap or IngressClass) so the diagnostic appears in the
93+
// namespace owned by the pod creator who triggered the rejection. Pod UID may
94+
// be empty at admission time since the API server assigns it after the
95+
// mutating webhook chain; the recorder accepts an empty UID and the event will
96+
// still post, just without UID-based correlation.
97+
func (e *eventRecorder) recordCrossNamespaceConfigMapRefused(pod *corev1.Pod, err error) {
98+
e.recorder.Eventf(
99+
&corev1.ObjectReference{
100+
Kind: "Pod",
101+
APIVersion: "v1",
102+
Name: pod.Name,
103+
Namespace: pod.Namespace,
104+
UID: pod.UID,
105+
},
106+
corev1.EventTypeWarning,
107+
EventReasonCrossNamespaceConfigMapRefused,
108+
"AppSec nginx mutation skipped: %v",
109+
err,
110+
)
111+
}

pkg/clusteragent/appsec/nginx/sidecar.go

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package nginx
99

1010
import (
1111
"context"
12+
"errors"
1213
"fmt"
1314
"maps"
1415
"slices"
@@ -25,6 +26,17 @@ import (
2526
"k8s.io/utils/ptr"
2627
)
2728

29+
// errCrossNamespaceConfigMap signals that the pod's --configmap arg references
30+
// a namespace different from the pod's own. We must not act on this because
31+
// the DCA service account holds cluster-wide ConfigMap permissions and the pod
32+
// creator may be a low-privileged tenant.
33+
var errCrossNamespaceConfigMap = errors.New("--configmap references a namespace different from the pod's namespace; refusing to mutate to avoid confused-deputy ConfigMap writes")
34+
35+
// errEmptyConfigMapName signals that the pod's --configmap arg has an empty
36+
// name after the slash (e.g. "--configmap=foo/"). This is a malformed arg
37+
// and we refuse to act on it.
38+
var errEmptyConfigMapName = errors.New("--configmap has empty name after namespace separator")
39+
2840
const (
2941
// mutateTimeout bounds ConfigMap operations during pod mutation to prevent
3042
// goroutine leaks if the API server is slow. The MutatePod interface does not
@@ -84,8 +96,17 @@ func (n *nginxSidecarPattern) MutatePod(pod *corev1.Pod, ns string, client dynam
8496
return false, fmt.Errorf("pod %s has no containers", mutatecommon.PodString(pod))
8597
}
8698

87-
// Find the controller container with --configmap arg (or note it's absent)
88-
containerIdx, argIdx, cmNamespace, cmName, found := findControllerConfigMapArg(pod, ns)
99+
containerIdx, argIdx, cmNamespace, cmName, found, err := findControllerConfigMapArg(pod, ns)
100+
if err != nil {
101+
// Refusing to mutate on cross-namespace or malformed --configmap args is
102+
// a security policy decision, not a failure. Return (false, nil) so the
103+
// pod is admitted unmodified (fail-open) without polluting the error
104+
// path used by genuine mutation failures. The warning event lands on
105+
// the pod itself so the owning namespace operator can see the diagnostic.
106+
n.eventRecorder.recordCrossNamespaceConfigMapRefused(pod, err)
107+
n.logger.Warnf("nginx AppSec mutation skipped for pod %s: %v", mutatecommon.PodString(pod), err)
108+
return false, nil
109+
}
89110
if !found {
90111
cmName = "ingress-nginx-controller"
91112
cmNamespace = ns
@@ -171,11 +192,21 @@ func (n *nginxSidecarPattern) MatchCondition() admissionregistrationv1.MatchCond
171192
}
172193
}
173194

174-
// findControllerConfigMapArg finds the controller container and its --configmap arg,
175-
// resolving $(POD_NAMESPACE) to the actual pod namespace.
176-
// If the arg is not found, found is false and containerIdx 0 / argIdx -1 are returned
177-
// so the caller can append the arg to the first container instead.
178-
func findControllerConfigMapArg(pod *corev1.Pod, podNamespace string) (containerIdx, argIdx int, cmNamespace, cmName string, found bool) {
195+
// findControllerConfigMapArg finds the controller container and its --configmap arg.
196+
// It resolves $(POD_NAMESPACE) to the pod's namespace and rejects any other
197+
// namespace value, because the pod arg is attacker-controlled and the DCA holds
198+
// cluster-wide ConfigMap permissions (confused-deputy primitive). It also
199+
// rejects empty names (after the slash separator).
200+
//
201+
// Return contract:
202+
// - arg absent: found=false, err=nil — caller defaults to (podNamespace, "ingress-nginx-controller").
203+
// - arg present and valid: found=true, err=nil.
204+
// - arg present but malformed/cross-namespace: err!=nil — caller must skip mutation.
205+
//
206+
// The webhook runs before kubelet substitution, so "$(POD_NAMESPACE)" arrives
207+
// as a literal string and we resolve it ourselves. Upstream ingress-nginx only
208+
// supports this single syntax, so variants like ${POD_NAMESPACE} are not recognized.
209+
func findControllerConfigMapArg(pod *corev1.Pod, podNamespace string) (containerIdx, argIdx int, cmNamespace, cmName string, found bool, err error) {
179210
for ci, c := range pod.Spec.Containers {
180211
for ai, arg := range c.Args {
181212
value, ok := strings.CutPrefix(arg, configmapArgPrefix)
@@ -186,14 +217,21 @@ func findControllerConfigMapArg(pod *corev1.Pod, podNamespace string) (container
186217
if !ok {
187218
continue
188219
}
189-
// Resolve $(POD_NAMESPACE) to the actual namespace
190220
if ns == "$(POD_NAMESPACE)" {
191221
ns = podNamespace
192222
}
193-
return ci, ai, ns, name, true
223+
if ns != podNamespace {
224+
return ci, ai, "", "", false, fmt.Errorf("%w: pod %s, arg %q",
225+
errCrossNamespaceConfigMap, mutatecommon.PodString(pod), arg)
226+
}
227+
if name == "" {
228+
return ci, ai, "", "", false, fmt.Errorf("%w: pod %s, arg %q",
229+
errEmptyConfigMapName, mutatecommon.PodString(pod), arg)
230+
}
231+
return ci, ai, ns, name, true, nil
194232
}
195233
}
196-
return 0, -1, podNamespace, "", false
234+
return 0, -1, podNamespace, "", false, nil
197235
}
198236

199237
// parseControllerVersion extracts the version tag from an ingress-nginx controller image reference.

pkg/clusteragent/appsec/nginx/sidecar_test.go

Lines changed: 144 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -223,31 +223,92 @@ func TestFindControllerConfigMapArg(t *testing.T) {
223223
wantNS string
224224
wantName string
225225
wantFound bool
226+
wantErr error
226227
}{
227228
{
228-
name: "standard $(POD_NAMESPACE) form",
229+
name: "standard $(POD_NAMESPACE) form is accepted",
229230
pod: newControllerPod("test", "ingress-nginx", "img:v1"),
230231
podNamespace: "ingress-nginx",
231232
wantNS: "ingress-nginx",
232233
wantName: "ingress-nginx-controller",
233234
wantFound: true,
234235
},
235236
{
236-
name: "hardcoded namespace form",
237+
name: "hardcoded same namespace is accepted",
237238
pod: &corev1.Pod{
238239
Spec: corev1.PodSpec{
239240
Containers: []corev1.Container{{
240-
Args: []string{"--configmap=custom-ns/my-config"},
241+
Args: []string{"--configmap=ingress-nginx/my-config"},
241242
}},
242243
},
243244
},
244245
podNamespace: "ingress-nginx",
245-
wantNS: "custom-ns",
246+
wantNS: "ingress-nginx",
246247
wantName: "my-config",
247248
wantFound: true,
248249
},
249250
{
250-
name: "no configmap arg",
251+
name: "hardcoded foreign namespace is rejected (confused-deputy guard)",
252+
pod: &corev1.Pod{
253+
Spec: corev1.PodSpec{
254+
Containers: []corev1.Container{{
255+
Args: []string{"--configmap=custom-ns/my-config"},
256+
}},
257+
},
258+
},
259+
podNamespace: "ingress-nginx",
260+
wantErr: errCrossNamespaceConfigMap,
261+
},
262+
{
263+
name: "kube-system reference is rejected",
264+
pod: &corev1.Pod{
265+
Spec: corev1.PodSpec{
266+
Containers: []corev1.Container{{
267+
Args: []string{"--configmap=kube-system/coredns"},
268+
}},
269+
},
270+
},
271+
podNamespace: "attacker-ns",
272+
wantErr: errCrossNamespaceConfigMap,
273+
},
274+
{
275+
name: "leading slash with empty namespace is rejected",
276+
pod: &corev1.Pod{
277+
Spec: corev1.PodSpec{
278+
Containers: []corev1.Container{{
279+
Args: []string{"--configmap=/foo"},
280+
}},
281+
},
282+
},
283+
podNamespace: "ingress-nginx",
284+
wantErr: errCrossNamespaceConfigMap,
285+
},
286+
{
287+
name: "trailing slash with empty name is rejected",
288+
pod: &corev1.Pod{
289+
Spec: corev1.PodSpec{
290+
Containers: []corev1.Container{{
291+
Args: []string{"--configmap=ingress-nginx/"},
292+
}},
293+
},
294+
},
295+
podNamespace: "ingress-nginx",
296+
wantErr: errEmptyConfigMapName,
297+
},
298+
{
299+
name: "no slash skips the arg and falls through to not-found",
300+
pod: &corev1.Pod{
301+
Spec: corev1.PodSpec{
302+
Containers: []corev1.Container{{
303+
Args: []string{"--configmap=foo"},
304+
}},
305+
},
306+
},
307+
podNamespace: "ingress-nginx",
308+
wantFound: false,
309+
},
310+
{
311+
name: "no configmap arg falls back to defaults",
251312
pod: &corev1.Pod{
252313
Spec: corev1.PodSpec{
253314
Containers: []corev1.Container{{
@@ -258,11 +319,45 @@ func TestFindControllerConfigMapArg(t *testing.T) {
258319
podNamespace: "ingress-nginx",
259320
wantFound: false,
260321
},
322+
{
323+
name: "multi-container first match wins - malicious arg rejected even if later container is benign",
324+
pod: &corev1.Pod{
325+
Spec: corev1.PodSpec{
326+
Containers: []corev1.Container{
327+
{Args: []string{"--configmap=kube-system/coredns"}},
328+
{Args: []string{"--configmap=ingress-nginx/legit"}},
329+
},
330+
},
331+
},
332+
podNamespace: "ingress-nginx",
333+
wantErr: errCrossNamespaceConfigMap,
334+
},
335+
{
336+
name: "multi-container second container holds the arg",
337+
pod: &corev1.Pod{
338+
Spec: corev1.PodSpec{
339+
Containers: []corev1.Container{
340+
{Args: []string{"--election-id=x"}},
341+
{Args: []string{"--configmap=ingress-nginx/legit"}},
342+
},
343+
},
344+
},
345+
podNamespace: "ingress-nginx",
346+
wantNS: "ingress-nginx",
347+
wantName: "legit",
348+
wantFound: true,
349+
},
261350
}
262351

263352
for _, tt := range tests {
264353
t.Run(tt.name, func(t *testing.T) {
265-
_, _, ns, name, found := findControllerConfigMapArg(tt.pod, tt.podNamespace)
354+
_, _, ns, name, found, err := findControllerConfigMapArg(tt.pod, tt.podNamespace)
355+
if tt.wantErr != nil {
356+
require.ErrorIs(t, err, tt.wantErr)
357+
assert.False(t, found)
358+
return
359+
}
360+
require.NoError(t, err)
266361
assert.Equal(t, tt.wantFound, found)
267362
if tt.wantFound {
268363
assert.Equal(t, tt.wantNS, ns)
@@ -431,3 +526,46 @@ func TestMutatePodVersionParseFailed(t *testing.T) {
431526
assert.False(t, mutated)
432527
assert.ErrorContains(t, err, "manual extraModules")
433528
}
529+
530+
// TestMutatePod_CrossNamespaceConfigMapRefused is the bisect anchor for the
531+
// confused-deputy ConfigMap mitigation. It MUST fail against the unpatched
532+
// code (which trusted the pod's --configmap arg verbatim) and pass against the
533+
// patched code.
534+
func TestMutatePod_CrossNamespaceConfigMapRefused(t *testing.T) {
535+
pattern, client := newTestNginxSidecarPattern(t)
536+
537+
pod := newControllerPod("attacker", "attacker-ns", "registry.k8s.io/ingress-nginx/controller:v1.15.1")
538+
pod.Spec.Containers[0].Args = []string{
539+
"/nginx-ingress-controller",
540+
"--configmap=kube-system/coredns",
541+
"--election-id=ingress-nginx-leader",
542+
}
543+
544+
mutated, err := pattern.MutatePod(pod, "attacker-ns", client)
545+
require.NoError(t, err, "MutatePod must not fail admission on cross-ns refs (fail-open)")
546+
assert.False(t, mutated, "MutatePod must skip mutation on cross-ns refs")
547+
548+
assert.Empty(t, client.Actions(), "no API operations may occur on rejection")
549+
550+
assert.Equal(t, "--configmap=kube-system/coredns", pod.Spec.Containers[0].Args[1],
551+
"pod arg must be unmodified")
552+
assert.Empty(t, pod.Spec.InitContainers, "no init container must be injected")
553+
assert.Empty(t, pod.Spec.Volumes, "no volume must be added")
554+
assert.Empty(t, pod.Spec.Containers[0].VolumeMounts, "no volume mount must be added")
555+
}
556+
557+
func TestMutatePod_EmptyConfigMapNameRefused(t *testing.T) {
558+
pattern, client := newTestNginxSidecarPattern(t)
559+
560+
pod := newControllerPod("test", "ingress-nginx", "registry.k8s.io/ingress-nginx/controller:v1.15.1")
561+
pod.Spec.Containers[0].Args = []string{
562+
"/nginx-ingress-controller",
563+
"--configmap=ingress-nginx/",
564+
}
565+
566+
mutated, err := pattern.MutatePod(pod, "ingress-nginx", client)
567+
require.NoError(t, err)
568+
assert.False(t, mutated)
569+
assert.Empty(t, pod.Spec.InitContainers)
570+
assert.Empty(t, pod.Spec.Volumes)
571+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
security:
3+
- |
4+
Fixed a confused-deputy vulnerability in the Cluster Agent's AppSec
5+
ingress-nginx admission mutator where the pod's
6+
``--configmap=<namespace>/<name>`` argument was trusted verbatim,
7+
allowing a user with pod-create permission in one namespace to make
8+
the Cluster Agent service account create or update ConfigMaps and add
9+
labels and annotations in arbitrary namespaces. The mutator now
10+
requires the ``<namespace>`` portion to match the pod's own namespace
11+
(or use the ``$(POD_NAMESPACE)`` downward-API substitution) and skips
12+
mutation otherwise, emitting a warning event on the pod. The
13+
vulnerability affected Cluster Agent releases starting from 7.78.0.

0 commit comments

Comments
 (0)