From 7e6b3beed5e7b28e7665b0b636193bfce572fc8c Mon Sep 17 00:00:00 2001 From: jklein Date: Sun, 23 Aug 2026 23:39:49 +0200 Subject: [PATCH] rss-bridge: pin the unprivileged-port sysctl, quote env values Follow-up to the template-chart hardening fixes in #133, which landed while this branch was open. The env block now quotes names, values and keyRef fields. Without it a value like "5" renders as a YAML number and the API server rejects the whole manifest. RSS-Bridge takes numeric settings that way (RSSBRIDGE_http_timeout), so this was reachable with the first env entry anyone adds. The port-80 question is settled by measurement rather than by the "it works in k3d" reasoning #133 warns about. Same pod spec in k3d (k3s v1.35.5), only ip_unprivileged_port_start changed: 1024, capabilities.drop [ALL] nginx: bind() to 0.0.0.0:80 failed (13: Permission denied) 1024, plus NET_BIND_SERVICE identical failure - for a non-root process the capability lands in the permitted but not the effective set, so it buys nothing 0 binds, HTTP 200 So the chart cannot buy safety with a capability the way samba does, and port 80 is not negotiable either: nginx is configured for it inside the image and the entrypoint's only lever is a sed against a read-only /etc/nginx. What it can do is stop depending on the node default - the pod securityContext now pins the sysctl to 0, so the bind no longer relies on cluster configuration. Verified: the chart-installed pod reports 0 and answers 200 through the Service. Worth recording how that failure looks without probes: the entrypoint backgrounds nginx and then execs php-fpm, so a failed bind leaves a container reporting Running with no web server at all. Both test pods above were Running. The probes on /?action=health are what turn it into a pod that never becomes Ready. trivy config still reports 0 to act on; the KSV-0117 statement is updated to the measured reasoning. --- ci/trivy/trivyignore.yaml | 2 +- rss-bridge/templates/_helpers.tpl | 7 +++- .../templates/rss-bridge.deployment.yaml | 22 ++++++++----- rss-bridge/values.yaml | 32 +++++++++++++++++++ 4 files changed, 53 insertions(+), 10 deletions(-) diff --git a/ci/trivy/trivyignore.yaml b/ci/trivy/trivyignore.yaml index 15dd3a7..554579c 100644 --- a/ci/trivy/trivyignore.yaml +++ b/ci/trivy/trivyignore.yaml @@ -112,4 +112,4 @@ misconfigurations: # --- rss-bridge: port 80 is fixed by the image ---------------------------- - id: AVD-KSV-0117 paths: ["**/rss-bridge.yaml"] - statement: "nginx inside the image listens on 80, and the only way the entrypoint offers to change it (HTTP_PORT) is a sed against /etc/nginx/sites-enabled/default, which a read-only root filesystem rejects - so the alternative to port 80 would be giving up readOnlyRootFilesystem. The premise of the check does not apply here either: the container binds 80 as uid 33 with ALL capabilities dropped and no NET_BIND_SERVICE, verified in k3d. Renaming is also not free - the Service selects the port by name (rss-bridge-port), which is what keeps the takeover of the existing installation a no-op." + statement: "nginx inside the image listens on 80, and the only lever the entrypoint offers (HTTP_PORT) is a sed against /etc/nginx/sites-enabled/default, which a read-only root filesystem rejects - so the alternative to port 80 is giving up readOnlyRootFilesystem. The chart does not merely rely on the kubelet's ip_unprivileged_port_start=0 default, it pins the sysctl in the pod securityContext, so the bind does not depend on node configuration. Measured in k3d with only that value changed: at 1024 nginx fails with 'bind() to 0.0.0.0:80 failed (13: Permission denied)', and adding NET_BIND_SERVICE does NOT fix it (for a non-root process the capability lands in the permitted but not the effective set) - which is why this chart adds no capability, unlike samba. Justified in rss-bridge/values.yaml above securityContext.pod.sysctls." diff --git a/rss-bridge/templates/_helpers.tpl b/rss-bridge/templates/_helpers.tpl index 9da8868..bab5acb 100644 --- a/rss-bridge/templates/_helpers.tpl +++ b/rss-bridge/templates/_helpers.tpl @@ -198,6 +198,10 @@ Two chart-specific facts a rebuilt default must not lose: everything in it owned by www-data, and both nginx and php-fpm are configured for that user. A "cleaner" high uid would leave the app unable to read its own bridge sources. + - the ip_unprivileged_port_start sysctl is what lets a non-root nginx bind + port 80, and this chart has no choice about that port - see the comment on + it in values.yaml. Dropping it from a rebuilt default does not fail at + render time; it fails on the first node whose default differs. - the probes address the port BY NAME (rss-bridge-port) and use the app's own health action. A default that drops the query string would probe the 1.5 MB front page instead of a 49-byte JSON response, and a default that @@ -211,7 +215,8 @@ Two chart-specific facts a rebuilt default must not lose: "runAsGroup" 33 "fsGroup" 33 "fsGroupChangePolicy" "Always" - "seccompProfile" (dict "type" "RuntimeDefault") -}} + "seccompProfile" (dict "type" "RuntimeDefault") + "sysctls" (list (dict "name" "net.ipv4.ip_unprivileged_port_start" "value" "0")) -}} {{- include "rss-bridge.defaultedDict" (dict "defaults" $defaults "given" $given "path" "rssBridge.securityContext.pod") -}} {{- end -}} diff --git a/rss-bridge/templates/rss-bridge.deployment.yaml b/rss-bridge/templates/rss-bridge.deployment.yaml index 7a6fdd9..6a4de4f 100644 --- a/rss-bridge/templates/rss-bridge.deployment.yaml +++ b/rss-bridge/templates/rss-bridge.deployment.yaml @@ -36,21 +36,27 @@ spec: env: {{- range $value := .Values.rssBridge.env }} {{- if $value.value }} - - name: {{ $value.name }} - value: {{ tpl $value.value $ }} + - name: {{ $value.name | quote }} + # quote is required, not cosmetic: a value like "5" or "true" + # renders unquoted as a YAML number/bool, and the API server then + # rejects the whole manifest with "cannot unmarshal number into + # Go struct field EnvVar.value of type string". RSS-Bridge takes + # numeric settings this way (RSSBRIDGE_http_timeout), so this is + # reachable with the very first env entry someone adds. + value: {{ tpl $value.value $ | quote }} {{- else if $value.valueFrom }} {{- if $value.valueFrom.secretKeyRef }} - - name: {{ $value.name }} + - name: {{ $value.name | quote }} valueFrom: secretKeyRef: - name: {{ tpl $value.valueFrom.secretKeyRef.name $ }} - key: {{ tpl $value.valueFrom.secretKeyRef.key $ }} + name: {{ tpl $value.valueFrom.secretKeyRef.name $ | quote }} + key: {{ tpl $value.valueFrom.secretKeyRef.key $ | quote }} {{- else if $value.valueFrom.configMapKeyRef }} - - name: {{ $value.name }} + - name: {{ $value.name | quote }} valueFrom: configMapKeyRef: - name: {{ tpl $value.valueFrom.configMapKeyRef.name $ }} - key: {{ tpl $value.valueFrom.configMapKeyRef.key $ }} + name: {{ tpl $value.valueFrom.configMapKeyRef.name $ | quote }} + key: {{ tpl $value.valueFrom.configMapKeyRef.key $ | quote }} {{- end }} {{- end }} {{- end }} diff --git a/rss-bridge/values.yaml b/rss-bridge/values.yaml index 7eb23a2..03fd006 100644 --- a/rss-bridge/values.yaml +++ b/rss-bridge/values.yaml @@ -54,6 +54,38 @@ rssBridge: fsGroupChangePolicy: Always seccompProfile: type: RuntimeDefault + # Why this chart listens on the privileged port 80, and why that is safe. + # + # nginx is configured for port 80 inside the image. The only lever the + # entrypoint offers is HTTP_PORT, which runs a sed against + # /etc/nginx/sites-enabled/default - impossible with a read-only root + # filesystem. So the choice is port 80 or readOnlyRootFilesystem, and + # port 80 is the cheaper of the two. (The Service selects the port by + # NAME, so the number is invisible to it either way.) + # + # Kubernetes sets net.ipv4.ip_unprivileged_port_start=0 in the pod + # network namespace, so binding 80 needs no capability at all. But that + # is a runtime default, not a guarantee, and relying on it silently is + # exactly the trap the template-chart warns about - so the chart pins the + # value instead of inheriting it. + # + # Measured in k3d (k3s v1.35.5), same pod spec, only this value changed: + # sysctl 1024, capabilities.drop [ALL] -> nginx: bind() to + # 0.0.0.0:80 failed (13: Permission denied) + # sysctl 1024, plus NET_BIND_SERVICE added -> identical failure. + # Adding the capability does NOT help: for a non-root process it + # lands in the permitted set but not the effective/ambient one, so + # it buys nothing here. That is why this chart does not add it. + # sysctl 0 (this default) -> binds, HTTP 200. + # + # Note how that failure looks without probes: the entrypoint starts nginx + # in the background and then execs php-fpm, so a failed bind leaves a + # container that reports Running with no web server at all. The probes on + # /?action=health turn that silent failure into a pod that never becomes + # Ready. + sysctls: + - name: net.ipv4.ip_unprivileged_port_start + value: "0" container: allowPrivilegeEscalation: false readOnlyRootFilesystem: true