[Bug]: cnpg.io/podPatch is decoded into the already-built Pod: patching allowPrivilegeEscalation makes the container privileged, and an inserted container inherits the one it displaced
维护者通常 1 天内回复
还没有人认领这个 Issue。
评估
- 难度
- 4/5
- 预计耗时
- 1-2 天
- 新手友好度
- 55/100
调研方向
Start in pkg/specs/pods.go at buildInstance, where the patched JSON is unmarshaled into the existing Pod, and compare that result with decoding into a new corev1.Pod. Read GetSecurityContext in pkg/specs/containers.go and use the reproduction described in the issue as a guide. Done means applying a pod patch produces exactly the patched Pod, including independent security context values and containers without inherited fields.
由索引模型根据 Issue 内容生成。
描述
Is there an existing issue already for this bug?
- I have searched for an existing issue, and could not find anything. I believe this is a new bug.
I have read the troubleshooting guide
- I have read the troubleshooting guide and I think this is a new bug.
I am running a supported version of CloudNativePG
- I am running a supported version of CloudNativePG.
Version
1.30.1 (operator image ghcr.io/cloudnative-pg/cloudnative-pg:1.30.1, Helm chart cloudnative-pg-0.29.1); reproduced on main at 21e24f6 as well
What version of Kubernetes are you using?
1.35.7
What is your Kubernetes environment?
Cloud: Azure AKS
How did you install the operator?
Helm
What happened?
cnpg.io/podPatch can produce an instance Pod that differs from the patch. A field written by the patch can change a field the patch did not touch, or be lost. An added container can also inherit fields from the container that was at its index before.
buildInstance (pkg/specs/pods.go) serializes the built Pod, applies the JSON patch, and then unmarshals the result back into that same *corev1.Pod:
if err = json.Unmarshal(serializedObject, pod); err != nil {
encoding/json reuses what is already in the target: existing slice elements and non-nil pointers are decoded into, not replaced. Two things in the built Pod make that visible.
-
Shared pointers in the default security context.
GetSecurityContext(pkg/specs/containers.go) points two fields at each local:RunAsNonRootandReadOnlyRootFilesystemboth point totrueValue;PrivilegedandAllowPrivilegeEscalationboth point tofalseValue.
Decoding a patched value of one of the pair writes through the shared pointer, so the other reads the same value. Whichever key comes later in the patched JSON wins for both.
-
Slice elements are decoded in place. A container added at an index that is already occupied, e.g.
/spec/initContainers/0in front ofbootstrap-controller, is decoded into the existing element. It keeps every field its own JSON does not set, such asvolumeMountsandresources. That element's security context pointers are also the shared ones from point 1.
Reproduction on main (21e24f6). This test, put in pkg/specs, compares the NewInstance result with the same patch's JSON decoded into a new corev1.Pod:
pkg/specs/podpatch_repro_test.go
package specs
import (
"context"
"encoding/json"
"testing"
jsonpatch "github.com/evanphx/json-patch/v5"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
"github.com/cloudnative-pg/cloudnative-pg/pkg/utils"
)
// The pod NewInstance returns for a podPatch, beside the pod the same patch
// gives when its JSON is decoded into a new Pod.
func patchedBoth(t *testing.T, patch string) (got, want *corev1.Pod) {
t.Helper()
cluster := apiv1.Cluster{
ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: "default", Annotations: map[string]string{utils.PodPatchAnnotationName: patch}},
Status: apiv1.ClusterStatus{Image: "postgres:18"},
}
got, err := NewInstance(context.Background(), cluster, 1)
if err != nil {
t.Fatal(err)
}
cluster.Annotations = nil
plain, err := NewInstance(context.Background(), cluster, 1)
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(plain)
p, err := jsonpatch.DecodePatch([]byte(patch))
if err != nil {
t.Fatal(err)
}
if raw, err = p.Apply(raw); err != nil {
t.Fatal(err)
}
want = &corev1.Pod{}
if err := json.Unmarshal(raw, want); err != nil {
t.Fatal(err)
}
return got, want
}
func b(p *bool) any {
if p == nil {
return nil
}
return *p
}
func TestPodPatchRepro(t *testing.T) {
t.Run("allowPrivilegeEscalation", func(t *testing.T) {
got, want := patchedBoth(t, `[{"op":"replace","path":"/spec/containers/0/securityContext/allowPrivilegeEscalation","value":true}]`)
g, w := got.Spec.Containers[0].SecurityContext, want.Spec.Containers[0].SecurityContext
t.Logf("postgres container: privileged got=%v want=%v; allowPrivilegeEscalation got=%v want=%v", b(g.Privileged), b(w.Privileged), b(g.AllowPrivilegeEscalation), b(w.AllowPrivilegeEscalation))
})
t.Run("runAsNonRoot", func(t *testing.T) {
got, want := patchedBoth(t, `[{"op":"replace","path":"/spec/containers/0/securityContext/runAsNonRoot","value":false}]`)
g, w := got.Spec.Containers[0].SecurityContext, want.Spec.Containers[0].SecurityContext
t.Logf("postgres container: runAsNonRoot got=%v want=%v; readOnlyRootFilesystem got=%v want=%v", b(g.RunAsNonRoot), b(w.RunAsNonRoot), b(g.ReadOnlyRootFilesystem), b(w.ReadOnlyRootFilesystem))
})
t.Run("initContainer added first", func(t *testing.T) {
got, want := patchedBoth(t, `[{"op":"add","path":"/spec/initContainers/0","value":{"name":"first","image":"busybox:1.37","command":["true"],"securityContext":{"runAsNonRoot":false,"runAsUser":0,"readOnlyRootFilesystem":true,"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]}}}}]`)
g, w := got.Spec.InitContainers[0], want.Spec.InitContainers[0]
t.Logf("initContainers: got %d (%s, %s) want %d (%s, %s)", len(got.Spec.InitContainers), got.Spec.InitContainers[0].Name, got.Spec.InitContainers[1].Name, len(want.Spec.InitContainers), want.Spec.InitContainers[0].Name, want.Spec.InitContainers[1].Name)
t.Logf("\"first\": readOnlyRootFilesystem got=%v want=%v; runAsNonRoot got=%v want=%v", b(g.SecurityContext.ReadOnlyRootFilesystem), b(w.SecurityContext.ReadOnlyRootFilesystem), b(g.SecurityContext.RunAsNonRoot), b(w.SecurityContext.RunAsNonRoot))
t.Logf("\"first\": volumeMounts got=%d want=%d; env got=%d want=%d; resources.requests got=%v want=%v", len(g.VolumeMounts), len(w.VolumeMounts), len(g.Env), len(w.Env), g.Resources.Requests, w.Resources.Requests)
})
}
$ go test ./pkg/specs/ -run '^TestPodPatchRepro$' -v
=== RUN TestPodPatchRepro
=== RUN TestPodPatchRepro/allowPrivilegeEscalation
podpatch_repro_test.go:59: postgres container: privileged got=true want=false; allowPrivilegeEscalation got=true want=true
=== RUN TestPodPatchRepro/runAsNonRoot
podpatch_repro_test.go:64: postgres container: runAsNonRoot got=true want=false; readOnlyRootFilesystem got=true want=true
=== RUN TestPodPatchRepro/initContainer_added_first
podpatch_repro_test.go:69: initContainers: got 2 (first, bootstrap-controller) want 2 (first, bootstrap-controller)
podpatch_repro_test.go:70: "first": readOnlyRootFilesystem got=true want=true; runAsNonRoot got=true want=false
podpatch_repro_test.go:71: "first": volumeMounts got=4 want=0; env got=0 want=0; resources.requests got=map[] want=map[]
--- PASS: TestPodPatchRepro (0.00s)
ok github.com/cloudnative-pg/cloudnative-pg/pkg/specs 0.674s
- Replacing only
/spec/containers/0/securityContext/allowPrivilegeEscalationwithtruemakes thepostgrescontainerprivileged: true. - Replacing only
runAsNonRootwithfalseis lost: it readstrue, becausereadOnlyRootFilesystem: trueis decoded after it through the same pointer. - An init container added at index 0 inherits
bootstrap-controller's 4 volume mounts, and its ownrunAsNonRoot: falseis lost.
How we hit it: we add an init container through podPatch (it sets the clock of a Kata pod VM, with runAsNonRoot: false, runAsUser: 0, readOnlyRootFilesystem: true, only SYS_TIME added), at /spec/initContainers/0. The created Pod had that container with readOnlyRootFilesystem: false, and our ValidatingAdmissionPolicy, which requires a read-only root filesystem, rejected the Pod. The databases whose Clusters got the patch had no instance Pod until we changed it. Appending the container (/spec/initContainers/-) avoids point 2 but not point 1.
This is not a security boundary issue, as far as we can tell: whoever can set cnpg.io/podPatch can already write any security context. But the Pod is not the one written, and Pod Security Admission or an admission policy may reject it, or let a more privileged container through than the patch asked for.
Expected: the instance Pod is the built Pod with the JSON patch applied, nothing else. Decoding the patched JSON into a new Pod would give that:
patched := &corev1.Pod{}
if err = json.Unmarshal(serializedObject, patched); err != nil {
return nil, fmt.Errorf("while deserializing pod to JSON: %w", err)
}
pod = patched
Separately, GetSecurityContext giving each field its own pointer (e.g. ptr.To(true)) would keep anyone who mutates the returned struct from hitting point 1.
Cluster resource
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: c
annotations:
cnpg.io/podPatch: '[{"op":"replace","path":"/spec/containers/0/securityContext/allowPrivilegeEscalation","value":true}]'
spec:
instances: 1
storage:
size: 1Gi
Relevant log output
Not applicable: the Pod is built differently from the patch before it reaches the API server (see the reproduction above).
In our case the API server then rejected it: our ValidatingAdmissionPolicy requires `readOnlyRootFilesystem: true` on every container of these pods, and the init container added by the patch came out with `false`.
Code of Conduct
- I agree to follow this project's Code of Conduct
- 主要语言
- Go
- 星标
- 9.5k
- 派生
- 811
- 平均合并
- 1 天 22 小时
- 30 天内合并 PR
- 94
环境准备
在浏览器里用你自己的 GitHub 账号启动这个项目的开发容器。
- 提供 Dockerfile 或 Docker Compose 文件
- 没有 Pull Request 模板
- 阅读贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
cloudnative-pg/cloudnative-pg 的其他 Issue
-
[Docs]: Document the egress traffic that instance pods need when a NetworkPolicy restricts egress未关闭
难度 2/5 1-3 小时 新手友好度 78/100
cloudnative-pg/cloudnative-pg#11750 · 1 个 reaction ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 72/100
cloudnative-pg/cloudnative-pg#11749 · 1 个 reaction ·
维护者通常 1 天内回复
-
[Docs]: Chatbot leftover and stale data on 1.30 supported_releases page; /documentation/current redirects to devel可能已有人在做 @gbartolini 于 25 天前认领。 未关闭documentation :book: triage
难度 2/5 1-3 小时 新手友好度 65/100
cloudnative-pg/cloudnative-pg#11658 · 1 条评论 · 1 个 reaction · 已指派 1 人 ·
维护者通常 1 天内回复
-
Managed role stays pending-reconciliation when a membership has several grantor rows可能已有人在做 @MatheuslFavaretto 于 23 天前认领。 未关闭
难度 2/5 1-3 小时 新手友好度 76/100
cloudnative-pg/cloudnative-pg#11624 · 2 条评论 · 1 个 reaction ·
维护者通常 1 天内回复
-
[Bug]: enablePDB=false deletes independently managed PDBs with matching names可能已有人在做 @WoahitsJoe 于 39 天前认领。 未关闭
难度 2/5 1-3 小时 新手友好度 84/100
cloudnative-pg/cloudnative-pg#11600 · 1 个 reaction ·
维护者通常 1 天内回复
查看 cloudnative-pg/cloudnative-pg 的全部 Issue
相似的 Issue
-
难度 1/5 1 小时以内 新手友好度 72/100
open-feature/flagd-schemas#237 ·
-
enhancement
难度 2/5 1-3 小时 新手友好度 72/100
superplanehq/superplane#9234 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 65/100
betterleaks/betterleaks#469 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 82/100
prime-radiant-inc/evener#5901 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 69/100