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.
This commit is contained in:
4 files changed
+53
-10
No files matched your search
@@ -112,4 +112,4 @@ misconfigurations:
|
|||||||
# --- rss-bridge: port 80 is fixed by the image ----------------------------
|
# --- rss-bridge: port 80 is fixed by the image ----------------------------
|
||||||
- id: AVD-KSV-0117
|
- id: AVD-KSV-0117
|
||||||
paths: ["**/rss-bridge.yaml"]
|
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."
|
||||||
@@ -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
|
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
|
configured for that user. A "cleaner" high uid would leave the app unable
|
||||||
to read its own bridge sources.
|
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
|
- 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
|
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
|
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
|
"runAsGroup" 33
|
||||||
"fsGroup" 33
|
"fsGroup" 33
|
||||||
"fsGroupChangePolicy" "Always"
|
"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") -}}
|
{{- include "rss-bridge.defaultedDict" (dict "defaults" $defaults "given" $given "path" "rssBridge.securityContext.pod") -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
|
|
||||||
|
|||||||
@@ -36,21 +36,27 @@ spec:
|
|||||||
env:
|
env:
|
||||||
{{- range $value := .Values.rssBridge.env }}
|
{{- range $value := .Values.rssBridge.env }}
|
||||||
{{- if $value.value }}
|
{{- if $value.value }}
|
||||||
- name: {{ $value.name }}
|
- name: {{ $value.name | quote }}
|
||||||
value: {{ tpl $value.value $ }}
|
# 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 }}
|
{{- else if $value.valueFrom }}
|
||||||
{{- if $value.valueFrom.secretKeyRef }}
|
{{- if $value.valueFrom.secretKeyRef }}
|
||||||
- name: {{ $value.name }}
|
- name: {{ $value.name | quote }}
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: {{ tpl $value.valueFrom.secretKeyRef.name $ }}
|
name: {{ tpl $value.valueFrom.secretKeyRef.name $ | quote }}
|
||||||
key: {{ tpl $value.valueFrom.secretKeyRef.key $ }}
|
key: {{ tpl $value.valueFrom.secretKeyRef.key $ | quote }}
|
||||||
{{- else if $value.valueFrom.configMapKeyRef }}
|
{{- else if $value.valueFrom.configMapKeyRef }}
|
||||||
- name: {{ $value.name }}
|
- name: {{ $value.name | quote }}
|
||||||
valueFrom:
|
valueFrom:
|
||||||
configMapKeyRef:
|
configMapKeyRef:
|
||||||
name: {{ tpl $value.valueFrom.configMapKeyRef.name $ }}
|
name: {{ tpl $value.valueFrom.configMapKeyRef.name $ | quote }}
|
||||||
key: {{ tpl $value.valueFrom.configMapKeyRef.key $ }}
|
key: {{ tpl $value.valueFrom.configMapKeyRef.key $ | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -54,6 +54,38 @@ rssBridge:
|
|||||||
fsGroupChangePolicy: Always
|
fsGroupChangePolicy: Always
|
||||||
seccompProfile:
|
seccompProfile:
|
||||||
type: RuntimeDefault
|
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:
|
container:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
readOnlyRootFilesystem: true
|
readOnlyRootFilesystem: true
|
||||||
|
|||||||
Reference in new issue
Block a user