Files
helm-charts/rss-bridge/templates/rss-bridge.deployment.yaml
jklein 7e6b3beed5 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.
2026-08-23 23:39:49 +02:00

141 lines
6.8 KiB
YAML

apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
spec:
replicas: {{ include "rss-bridge.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.rssBridge.replicas) }}
selector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
template:
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
annotations:
# Hash of the rendered ConfigMap: a changed whitelist changes the pod
# template and thus rolls the pods. Without it a config change stays
# invisible to the running container - whitelist.txt is a subPath
# mount, which the kubelet never updates, and RSS-Bridge reads the file
# once at request bootstrap from the path it was started with.
checksum/config: {{ include (print $.Template.BasePath "/rss-bridge.configmap.yaml") . | sha256sum }}
spec:
automountServiceAccountToken: false
securityContext:
{{- include "rss-bridge.podSecurityContext" .Values.rssBridge.securityContext | nindent 8 }}
containers:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
# Registry written out in full on purpose: an unqualified Docker Hub
# reference puts "rssbridge" in the registry position, which says
# nothing about where the image comes from and is reported as an
# untrusted registry (AVD-KSV-0125). See ci/trivy/data/ksv0125.yaml.
image: "docker.io/rssbridge/rss-bridge:{{ .Chart.AppVersion }}"
imagePullPolicy: IfNotPresent
env:
{{- range $value := .Values.rssBridge.env }}
{{- if $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 | quote }}
valueFrom:
secretKeyRef:
name: {{ tpl $value.valueFrom.secretKeyRef.name $ | quote }}
key: {{ tpl $value.valueFrom.secretKeyRef.key $ | quote }}
{{- else if $value.valueFrom.configMapKeyRef }}
- name: {{ $value.name | quote }}
valueFrom:
configMapKeyRef:
name: {{ tpl $value.valueFrom.configMapKeyRef.name $ | quote }}
key: {{ tpl $value.valueFrom.configMapKeyRef.key $ | quote }}
{{- end }}
{{- end }}
{{- end }}
ports:
# The port NAME is load-bearing and must not be renamed: the
# Service selects it by name (targetPort: rss-bridge-port), and so
# do the probes.
- containerPort: 80
protocol: TCP
name: rss-bridge-port
# Probes, resources and both security contexts go through the
# null-safe helpers (#99): an erased block means "chart defaults
# apply" instead of a vanished probe or an unhardened container.
# Never wrap these in an "if" - a guard hides the erased case
# completely instead of rendering a visible null.
livenessProbe:
{{- include "rss-bridge.livenessProbe" .Values.rssBridge.livenessProbe | nindent 12 }}
readinessProbe:
{{- include "rss-bridge.readinessProbe" .Values.rssBridge.readinessProbe | nindent 12 }}
startupProbe:
{{- include "rss-bridge.startupProbe" .Values.rssBridge.startupProbe | nindent 12 }}
resources:
{{- include "rss-bridge.effectiveResources" .Values.rssBridge.resources | nindent 12 }}
securityContext:
{{- include "rss-bridge.containerSecurityContext" .Values.rssBridge.securityContext | nindent 12 }}
volumeMounts:
- name: whitelist
mountPath: /app/whitelist.txt
subPath: whitelist.txt
readOnly: true
# The four writable paths below are what keeps
# readOnlyRootFilesystem: true possible. They are image facts
# rather than user configuration, so they are not values.
#
# They were found empirically, not guessed: the image was run with
# a writable root filesystem, exercised across every whitelisted
# bridge, and its diff layer read afterwards. Three of the four
# showed up that way. /tmp did NOT - PHP's opcache creates its lock
# file there and unlinks it immediately, so it never appears in a
# filesystem diff and only surfaced when the hardened container
# died at startup with "Unable to create lock file: Read-only file
# system". Stopping at the first writable path found would have
# shipped a chart that cannot start.
- name: cache
# RSS-Bridge's file cache. Not optional: the app checks the
# directory with is_writable() and throws on every feed request
# if it is not - while /?action=health still answers 200, so the
# probes would report a healthy pod that serves no feeds.
mountPath: /app/cache
- name: tmp
# PHP opcache lock file (created and immediately unlinked) and
# PHP's default upload temp dir.
mountPath: /tmp
- name: run
# nginx.pid
mountPath: /run
- name: run-php
# php-fpm's pid file and its fastcgi socket. Nested inside /run
# on purpose: the emptyDir above does not contain the php
# subdirectory the pool config expects, and php-fpm does not
# create it.
mountPath: /run/php
- name: nginx-lib
# nginx creates its body/fastcgi/proxy/scgi/uwsgi temp
# directories here at startup.
mountPath: /var/lib/nginx
volumes:
- name: whitelist
configMap:
name: "{{ .Release.Name }}-{{ .Chart.Name }}-configmap"
- name: cache
emptyDir: {}
- name: tmp
emptyDir: {}
- name: run
emptyDir: {}
- name: run-php
emptyDir: {}
- name: nginx-lib
emptyDir: {}
restartPolicy: Always