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