Skip to content

feat(chart): add optional readOnlyRootFilesystem hardening (empirically verified) - #177

Open
danielqb wants to merge 1 commit into
eftechcombr:mainfrom
danielqb:clean/read-only-root-filesystem
Open

feat(chart): add optional readOnlyRootFilesystem hardening (empirically verified)#177
danielqb wants to merge 1 commit into
eftechcombr:mainfrom
danielqb:clean/read-only-root-filesystem

Conversation

@danielqb

Copy link
Copy Markdown

Problem

None of the containers (php-fpm, nginx, mariadb, redis) could run with a
read-only root filesystem, a standard hardening control (reduces the
blast radius of a container compromise: an attacker can't write a
webshell or modify binaries even with code execution).

Approach

Rather than guessing which paths need to stay writable, each image was
tested directly with docker run --read-only (and, for the final pass,
combined with the exact runAsUser/capabilities this chart already sets)
to get ground truth instead of assumptions:

  • eftechcombr/glpi:php-fpm-11.0.8 - starts clean with ZERO extra writable
    paths.
  • eftechcombr/glpi:nginx-11.0.8 - fails without /tmp writable
    (mkdir() "/tmp/proxy_temp" failed (Read-only file system)); works
    with only /tmp added.
  • mariadb:11.4 - fails without /tmp (InnoDB temp files) and /run/mysqld
    (unix socket bind); works with those two plus the existing
    /var/lib/mysql PVC. Verified with an actual SELECT 1 after startup.
  • redis:7.0-alpine - runs and SAVE succeeds even fully read-only when the
    image's own VOLUME-declared /data gets an implicit writable mount from
    the Docker daemon, but Kubernetes/CRI runtimes don't reliably replicate
    that behavior, so an explicit emptyDir is safer than relying on it.

Final combined validation (uid matching this chart's securityContext,
--cap-drop ALL, --read-only, plus only the specific tmpfs paths listed
above): all four images started/ran cleanly, and mariadb/redis were
exercised with real queries (SELECT 1, SET/GET) to confirm they weren't
just booting but functioning.

Fix

  • Added readOnlyRootFilesystem: false (opt-in, not default-on) to
    glpi.securityContext, mariadb.securityContext, and redis.securityContext
    in values.yaml, each documented with exactly which paths the chart
    mounts when enabled and why.
  • When glpi.securityContext.readOnlyRootFilesystem is true: mounts an
    emptyDir at /tmp on php-fpm, nginx, all 5 init Jobs, and the cronjob
    (they all share this securityContext).
  • When mariadb.securityContext.readOnlyRootFilesystem is true: mounts
    emptyDirs at /tmp and /run/mysqld on the MariaDB StatefulSet, and /tmp
    on the mariadb-timezone Job.
  • When redis.securityContext.readOnlyRootFilesystem is true: mounts an
    emptyDir at /data (Redis has no PVC in this chart either way - it's
    used purely as an ephemeral object cache, so this changes nothing
    about data durability).
  • All additions are conditional and add zero volumes/mounts when the
    corresponding flag stays false (the default), so this is a strictly
    additive, opt-in change.

Testing

  • helm lint: 0 failures
  • helm template (default): 0 occurrences of the new tmp/data
    volumes/mounts anywhere in the rendered output
  • helm template with all three readOnlyRootFilesystem flags set to true:
    correct emptyDir mounts appear on exactly the expected 11 workloads
    (2 Deployments=php-fpm+nginx, 1 Deployment=redis, 6 Jobs=5 glpi jobs +
    mariadb-timezone, 1 CronJob, 1 StatefulSet=mariadb); mariadb gets both
    /tmp and /run/mysqld, redis gets /data
  • docker run --read-only --cap-drop ALL --user against all
    four images individually: all start/run correctly with only the
    documented paths writable; mariadb and redis verified with live
    queries, not just process liveness

…ly verified)

## Problem

None of the containers (php-fpm, nginx, mariadb, redis) could run with a
read-only root filesystem, a standard hardening control (reduces the
blast radius of a container compromise: an attacker can't write a
webshell or modify binaries even with code execution).

## Approach

Rather than guessing which paths need to stay writable, each image was
tested directly with `docker run --read-only` (and, for the final pass,
combined with the exact runAsUser/capabilities this chart already sets)
to get ground truth instead of assumptions:

- eftechcombr/glpi:php-fpm-11.0.8 - starts clean with ZERO extra writable
  paths.
- eftechcombr/glpi:nginx-11.0.8 - fails without /tmp writable
  (`mkdir() "/tmp/proxy_temp" failed (Read-only file system)`); works
  with only /tmp added.
- mariadb:11.4 - fails without /tmp (InnoDB temp files) and /run/mysqld
  (unix socket bind); works with those two plus the existing
  /var/lib/mysql PVC. Verified with an actual `SELECT 1` after startup.
- redis:7.0-alpine - runs and SAVE succeeds even fully read-only when the
  image's own VOLUME-declared /data gets an implicit writable mount from
  the Docker daemon, but Kubernetes/CRI runtimes don't reliably replicate
  that behavior, so an explicit emptyDir is safer than relying on it.

Final combined validation (uid matching this chart's securityContext,
--cap-drop ALL, --read-only, plus only the specific tmpfs paths listed
above): all four images started/ran cleanly, and mariadb/redis were
exercised with real queries (SELECT 1, SET/GET) to confirm they weren't
just booting but functioning.

## Fix

- Added `readOnlyRootFilesystem: false` (opt-in, not default-on) to
  glpi.securityContext, mariadb.securityContext, and redis.securityContext
  in values.yaml, each documented with exactly which paths the chart
  mounts when enabled and why.
- When glpi.securityContext.readOnlyRootFilesystem is true: mounts an
  emptyDir at /tmp on php-fpm, nginx, all 5 init Jobs, and the cronjob
  (they all share this securityContext).
- When mariadb.securityContext.readOnlyRootFilesystem is true: mounts
  emptyDirs at /tmp and /run/mysqld on the MariaDB StatefulSet, and /tmp
  on the mariadb-timezone Job.
- When redis.securityContext.readOnlyRootFilesystem is true: mounts an
  emptyDir at /data (Redis has no PVC in this chart either way - it's
  used purely as an ephemeral object cache, so this changes nothing
  about data durability).
- All additions are conditional and add zero volumes/mounts when the
  corresponding flag stays false (the default), so this is a strictly
  additive, opt-in change.

## Testing

- helm lint: 0 failures
- helm template (default): 0 occurrences of the new tmp/data
  volumes/mounts anywhere in the rendered output
- helm template with all three readOnlyRootFilesystem flags set to true:
  correct emptyDir mounts appear on exactly the expected 11 workloads
  (2 Deployments=php-fpm+nginx, 1 Deployment=redis, 6 Jobs=5 glpi jobs +
  mariadb-timezone, 1 CronJob, 1 StatefulSet=mariadb); mariadb gets both
  /tmp and /run/mysqld, redis gets /data
- docker run --read-only --cap-drop ALL --user <matching-uid> against all
  four images individually: all start/run correctly with only the
  documented paths writable; mariadb and redis verified with live
  queries, not just process liveness

@eduardofraga eduardofraga left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, please remove redis and mariadb because was changed by sub-chart from helmforge. Next, move files from kubernetes/glpi to helm/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants