From 779f102f012f39d0e61ed81d944bab789cd2baa3 Mon Sep 17 00:00:00 2001 From: Justin Carter Date: Thu, 28 May 2026 11:26:07 +0800 Subject: [PATCH] LDEV-5882 non-root user and read-only rootfs support for Lucee 6.2+ --- .../workflows/test-readonly-filesystem.yml | 79 +++------- Dockerfile | 29 ++++ Dockerfile.nginx | 20 +++ README.md | 56 ++++++- config/tomcat/11.0/web.xml | 2 + supporting/docker-entrypoint.sh | 20 +++ supporting/prewarm.sh | 10 ++ tests/test-readonly-filesystem.sh | 144 ++++++++++++++++++ 8 files changed, 297 insertions(+), 63 deletions(-) create mode 100755 supporting/docker-entrypoint.sh create mode 100755 tests/test-readonly-filesystem.sh diff --git a/.github/workflows/test-readonly-filesystem.yml b/.github/workflows/test-readonly-filesystem.yml index e9985e2..897bc51 100644 --- a/.github/workflows/test-readonly-filesystem.yml +++ b/.github/workflows/test-readonly-filesystem.yml @@ -1,74 +1,41 @@ -name: Test Read-Only Filesystem Support +name: Test Read-Only Filesystem and Non-Root User Support -# This workflow tests Lucee Docker images with read-only root filesystem enabled -# for security compliance (Kubernetes security best practices) +# Manual-only workflow that pulls a published Lucee image from Docker Hub and +# runs tests/test-readonly-filesystem.sh against it to verify the non-root user +# and read-only root filesystem features (Lucee 6.2+). Does NOT run automatically +# on push or pull request. +# +# For testing local (unpublished) image changes during development, run +# tests/test-readonly-filesystem.sh directly against a locally-built image instead. on: - # Allows manual triggering from Actions tab workflow_dispatch: inputs: - LUCEE_VERSION: - description: 'Lucee version to test (e.g., 7.0.0.395)' - required: false - default: '7.0.0.395' - type: string - LUCEE_MINOR: - description: 'Lucee minor version (e.g., 7.0)' - required: false - default: '7.0' - type: string - # Can be triggered by other workflows - workflow_call: - inputs: - LUCEE_VERSION: + IMAGE_TAG: + description: 'Lucee image tag to test (e.g. lucee/lucee:7.0.3.43 or lucee/lucee:7.0-nginx)' required: true type: string - LUCEE_MINOR: + VARIANT: + description: 'Image variant (must match the IMAGE_TAG)' required: true - type: string + type: choice + options: + - tomcat + - nginx + default: tomcat jobs: - test-readonly-filesystem: + smoke: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.9.0 - - - name: Placeholder - Build test image - run: | - echo "TODO: Build Docker image without prewarm" - echo "LUCEE_VERSION: ${{ inputs.LUCEE_VERSION || '7.0.0.395' }}" - echo "LUCEE_MINOR: ${{ inputs.LUCEE_MINOR || '7.0' }}" - echo "" - echo "This will:" - echo " 1. Build image with USER directive (non-root)" - echo " 2. Skip prewarm step" - echo " 3. Test with --read-only flag" - echo " 4. Verify volume mounts work" - echo " 5. Check Lucee starts and responds" - - - name: Placeholder - Test read-only filesystem - run: | - echo "TODO: Test with read-only root filesystem" - echo "" - echo "Tests will include:" - echo " - Verify container runs as non-root (uid=1000)" - echo " - Verify root filesystem is read-only" - echo " - Verify writable volumes work" - echo " - Verify Lucee initializes correctly" - echo " - Verify HTTP requests work" - echo " - Measure startup time" + - name: Pull image + run: docker pull ${{ inputs.IMAGE_TAG }} - - name: Placeholder - Security validation + - name: Run smoke test run: | - echo "TODO: Run security checks" - echo "" - echo "Security checks will include:" - echo " - Verify no writes to root filesystem" - echo " - Verify running as non-root user" - echo " - Verify no new privileges" - echo " - Check for vulnerable permissions" + chmod +x tests/test-readonly-filesystem.sh + tests/test-readonly-filesystem.sh ${{ inputs.IMAGE_TAG }} ${{ inputs.VARIANT }} diff --git a/Dockerfile b/Dockerfile index ac6bb6c..269cdf2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,6 +36,10 @@ RUN rm -rf /usr/local/tomcat/webapps/* # -Xmx set maximum Java heap size ENV LUCEE_JAVA_OPTS "-Xms64m -Xmx512m" +# Replace web.xml init-params with env vars (honored by Lucee 6.2+) +ENV LUCEE_SERVER_DIR=/opt/lucee/server +ENV LUCEE_WEB_DIR=/opt/lucee/web + # Download Lucee JAR RUN mkdir -p /usr/local/tomcat/lucee ADD ${LUCEE_JAR_URL} /usr/local/tomcat/lucee/lucee.jar @@ -83,7 +87,32 @@ RUN mkdir -p /var/www COPY www/ /var/www/ ONBUILD RUN rm -rf /var/www/* +# Non-root user and read-only rootfs support (Lucee 6.2+ / Tomcat 11.x+ only) +RUN MAJOR_VERSION=$(echo ${TOMCAT_VERSION} | awk -F. '{print $1}') && \ + if [ "$MAJOR_VERSION" -ge 11 ]; then \ + groupadd -r -g 999 lucee && \ + useradd -r -u 999 -g lucee -s /bin/false -M lucee && \ + mkdir -p /opt/lucee/server-runtime && \ + chown -R lucee:lucee \ + /opt/lucee \ + /usr/local/tomcat/logs \ + /usr/local/tomcat/temp \ + /usr/local/tomcat/work \ + /var/www && \ + chmod 644 /usr/local/tomcat/lucee/lucee.jar; \ + fi + +# Declare VOLUMEs so writable paths work under --read-only without --tmpfs +VOLUME ["/usr/local/tomcat/logs", "/usr/local/tomcat/temp", "/usr/local/tomcat/work", "/opt/lucee/server-runtime", "/tmp"] + # Lucee first time startup; explodes lucee and installs bundles/extensions COPY supporting/prewarm.sh /usr/local/tomcat/bin/ RUN chmod +x /usr/local/tomcat/bin/prewarm.sh RUN /usr/local/tomcat/bin/prewarm.sh ${LUCEE_MINOR} + +# Entrypoint handles LUCEE_RUNTIME_DIR seeding for read-only rootfs support +COPY supporting/docker-entrypoint.sh /usr/local/tomcat/bin/ +RUN chmod +x /usr/local/tomcat/bin/docker-entrypoint.sh +ENTRYPOINT ["/usr/local/tomcat/bin/docker-entrypoint.sh"] +# Setting ENTRYPOINT above clears the Tomcat base image's CMD; restore it +CMD ["catalina.sh", "run"] diff --git a/Dockerfile.nginx b/Dockerfile.nginx index 6ad610f..de93ee6 100644 --- a/Dockerfile.nginx +++ b/Dockerfile.nginx @@ -14,6 +14,23 @@ RUN set -x && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* +# Chown nginx writable dirs to lucee user (Lucee 6.2+ base images only) +RUN if id lucee >/dev/null 2>&1; then \ + chown -R lucee:lucee \ + /var/lib/nginx \ + /var/log/nginx \ + /var/log/supervisor \ + /run; \ + fi + +# Allow non-root nginx to bind privileged ports (Lucee 6.2+ base images only) +RUN if id lucee >/dev/null 2>&1; then \ + apt-get update && \ + apt-get install -y --no-install-recommends libcap2-bin && \ + setcap 'cap_net_bind_service=+ep' /usr/sbin/nginx && \ + rm -rf /var/lib/apt/lists/*; \ + fi + # Copy default nginx config files COPY nginx/nginx.conf /etc/nginx/ COPY nginx/default.conf /etc/nginx/conf.d/ @@ -26,6 +43,9 @@ RUN mkdir -p /var/www COPY www/ /var/www/ ONBUILD RUN rm -rf /var/www/* +# Declare nginx-writable VOLUMEs for --read-only support +VOLUME ["/var/lib/nginx", "/var/log/nginx", "/var/log/supervisor", "/run"] + # Expose HTTP and HTTPS ports EXPOSE 80 443 diff --git a/README.md b/README.md index b5a251c..caf98f7 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,17 @@ ## Supported tags and respective Dockerfile links -### Latest Stable Release - 6.2.0.321 - Tomcat 10.1 with Java 21 (recommended) +### Latest Stable Release - 7.0.3.43 - Tomcat 11.0 with Java 21 (recommended) -- lucee/lucee:6.2.0.321-nginx-tomcat10.1-jre21-temurin-jammy -- lucee/lucee:6.2.0.321-tomcat10.1-jre21-temurin-jammy -- lucee/lucee:6.2.0.321 -- lucee/lucee:6.2.0.321-light-tomcat10.1-jre21-temurin-jammy -- lucee/lucee:6.2.0.321-light +- `7.0.3.43-tomcat11.0-jre21-temurin-noble`, `7.0.3.43`, **`7.0`** ([Dockerfile](https://github.com/lucee/lucee-dockerfiles/blob/master/Dockerfile)) +- `7.0.3.43-nginx-tomcat11.0-jre21-temurin-noble`, `7.0.3.43-nginx`, **`7.0-nginx`** ([Dockerfile.nginx](https://github.com/lucee/lucee-dockerfiles/blob/master/Dockerfile.nginx)) -Tomcat 10.1 uses the Jakarta namespace, Tomcat 9 used the javax namespace, so you might need to update some libraries like urlrewrite +### Long-Term Support (LTS) Release - 6.2.6.19 - Tomcat 11.0 with Java 21 + +- `6.2.6.19-tomcat11.0-jre21-temurin-noble`, `6.2.6.19`, **`6.2`** ([Dockerfile](https://github.com/lucee/lucee-dockerfiles/blob/master/Dockerfile)) +- `6.2.6.19-nginx-tomcat11.0-jre21-temurin-noble`, `6.2.6.19-nginx`, **`6.2-nginx`** ([Dockerfile.nginx](https://github.com/lucee/lucee-dockerfiles/blob/master/Dockerfile.nginx)) + +Tomcat 11.0 and 10.1 use the Jakarta namespace, Tomcat 9 used the javax namespace, so you might need to update some libraries like urlrewrite. ### Previous stable release (LTS) @@ -117,6 +119,45 @@ The default configuration serves a single application for any hostname on the li Lucee 6 by default runs in single mode (only one configuration and Administrator), if you prefer to run it in multi mode you need to to set the flag "mode" to "multi" in the of the .CFConfig.json file. +### Non-Root User and Read-Only Root Filesystem (Lucee 6.2+) + +Lucee 6.2 and later images include opt-in support for running as a non-root user and with a read-only root filesystem. Earlier Lucee minors (5.x, 6.0, 6.1) are unchanged and continue to run as root with a writable filesystem. + +**To enable the non-root user**, opt in using any one of: + +- `USER lucee` in your downstream Dockerfile +- `--user lucee` (Docker CLI) +- `user: "lucee"` (docker-compose) +- `securityContext.runAsUser: 999` (Kubernetes) +- `"user": "lucee"` (AWS ECS task definition) + +The image creates a `lucee` user with uid 999. + +**To enable a read-only root filesystem**, set `LUCEE_RUNTIME_DIR=/opt/lucee/server-runtime` so the entrypoint can seed a writable copy of the Lucee server context at container start. Without this, Lucee will fail to perform any operation that needs to write to its server context. Then opt in using any one of: + +- `--read-only` (Docker CLI) +- `read_only: true` (docker-compose) +- `securityContext.readOnlyRootFilesystem: true` (Kubernetes) +- `"readonlyRootFilesystem": true` (AWS ECS task definition) + +The image declares writable runtime paths (`/usr/local/tomcat/{logs,temp,work}`, `/opt/lucee/server-runtime`, `/tmp`, plus nginx-specific paths in the `-nginx` images) as anonymous volumes so `docker run --read-only` works out of the box. **Note that some orchestrators (notably Kubernetes and AWS ECS on Fargate) require writable mounts to be declared explicitly in your deployment manifest** (e.g. `emptyDir` for Kubernetes, or `volumes` + `mountPoints` entries in an ECS task definition; ephemeral storage is fine). + +On dev or CI machines, anonymous volumes outlive the container that created them and can accumulate over time — run containers with `--rm` to clean up on exit, run `docker volume prune` periodically, or override the paths with named volumes in your compose file (and use `docker compose down -v` to remove them). For production deployments, named volumes or bind mounts are preferred over anonymous volumes for paths you want to persist across container restarts, such as log directories. + +Example with docker-compose: + +```yaml +services: + lucee: + image: lucee/lucee:7.0 + user: "lucee" + read_only: true + environment: + - LUCEE_RUNTIME_DIR=/opt/lucee/server-runtime + ports: + - "8888:8888" +``` + ## Using this image ### Accessing the service @@ -160,6 +201,7 @@ Following some helpful Environment variables you can use with the Lucee docker i - `LUCEE_ADMIN_PASSWORD`: The password for the Lucee Administrator - `LUCEE_VERSION`: If set Lucee will run this version independent of the version installed. - `LUCEE_JAVA_OPTS`: Additional JVM parameters for Tomcat. Used by /usr/local/tomcat/bin/setenv.sh. Default: "-Xms64m -Xmx512m". +- `LUCEE_RUNTIME_DIR`: When set, the entrypoint seeds a writable copy of the Lucee server context here at container start, and re-points Lucee to use it. Intended for use with a read-only root filesystem. (Lucee 6.2+) For all possible enviroment variables supported by Lucee, see [here](https://github.com/lucee/lucee-docs/blob/master/docs/recipes/environment-variables-system-properties.md). diff --git a/config/tomcat/11.0/web.xml b/config/tomcat/11.0/web.xml index 512f17e..c87b32a 100644 --- a/config/tomcat/11.0/web.xml +++ b/config/tomcat/11.0/web.xml @@ -4747,6 +4747,7 @@ CFMLServlet CFML runtime Engine lucee.loader.servlet.jakarta.CFMLServlet + 1 diff --git a/supporting/docker-entrypoint.sh b/supporting/docker-entrypoint.sh new file mode 100755 index 0000000..6e3bec6 --- /dev/null +++ b/supporting/docker-entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -e + +LUCEE_SERVER_DIR="${LUCEE_SERVER_DIR:-/opt/lucee/server}" +LUCEE_RUNTIME_DIR="${LUCEE_RUNTIME_DIR:-}" + +if [ -n "$LUCEE_RUNTIME_DIR" ] && [ "$LUCEE_RUNTIME_DIR" != "$LUCEE_SERVER_DIR" ]; then + START=$(date +%s%N) + mkdir -p "$LUCEE_RUNTIME_DIR/lucee-server" + cp -a "$LUCEE_SERVER_DIR/lucee-server/." "$LUCEE_RUNTIME_DIR/lucee-server/" + rm -rf "$LUCEE_RUNTIME_DIR/lucee-server/felix-cache" + END=$(date +%s%N) + # Write to stderr (unbuffered) rather than stdout (block-buffered when + # connected to a pipe) so the message survives the subsequent `exec` + # that replaces this shell with Tomcat. + echo "Seeded LUCEE_RUNTIME_DIR ($LUCEE_RUNTIME_DIR) from LUCEE_SERVER_DIR ($LUCEE_SERVER_DIR) in $(( (END - START) / 1000000 ))ms" >&2 + export LUCEE_SERVER_DIR="$LUCEE_RUNTIME_DIR" +fi + +exec "$@" diff --git a/supporting/prewarm.sh b/supporting/prewarm.sh index d194976..228349f 100644 --- a/supporting/prewarm.sh +++ b/supporting/prewarm.sh @@ -29,3 +29,13 @@ else /usr/local/tomcat/bin/catalina.sh run fi + +# ensure lucee user can read/write all Lucee and Tomcat files at runtime +if id lucee >/dev/null 2>&1; then + chown -R lucee:lucee \ + /opt/lucee \ + /usr/local/tomcat/logs \ + /usr/local/tomcat/temp \ + /usr/local/tomcat/work \ + /var/www +fi diff --git a/tests/test-readonly-filesystem.sh b/tests/test-readonly-filesystem.sh new file mode 100755 index 0000000..0c9225d --- /dev/null +++ b/tests/test-readonly-filesystem.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Smoke test for a built Lucee image. +# +# Verifies the Lucee 6.2+ non-root user and read-only root filesystem +# features against a locally-built image tag, plus a baseline regression +# check that the image still runs with default settings. +# +# Requires: docker, curl. Assumes the test page from www/ is in the image. +# +# Usage: ./test-readonly-filesystem.sh +# +# e.g. ./test-readonly-filesystem.sh lucee/lucee:6.2.6.19-test tomcat +# ./test-readonly-filesystem.sh lucee/lucee:6.2.6.19-nginx-test nginx +# ./test-readonly-filesystem.sh lucee/lucee:7.0.3.43-test tomcat +# ./test-readonly-filesystem.sh lucee/lucee:7.0.3.43-nginx-test nginx +# +# Three scenarios are run against the image: +# 1. Baseline - `docker run` with no opt-in. Regression check that +# defaults still work; expects HTTP 200 from the test page. +# 2. Non-root - adds `--user lucee`. Expects HTTP 200 and `id -u` +# inside the container to be non-zero. +# 3. Read-only rootfs - adds `--read-only` plus +# `-e LUCEE_RUNTIME_DIR=/opt/lucee/server-runtime`. Expects HTTP 200 +# and the entrypoint's seed log line in `docker logs`. +# +# Exits 0 if all checks pass, non-zero otherwise. Cleans up its own +# container on exit. + +set -uo pipefail + +IMAGE="${1:-}" +VARIANT="${2:-}" + +if [[ -z "$IMAGE" || -z "$VARIANT" ]]; then + echo "Usage: $0 " + exit 2 +fi + +case "$VARIANT" in + tomcat) HTTP_PORT=8888; HOST_PORT=18888 ;; + nginx) HTTP_PORT=80; HOST_PORT=18080 ;; + *) echo "variant must be 'tomcat' or 'nginx'"; exit 2 ;; +esac + +CONTAINER_NAME="lucee-smoke-$$" +PASS=0 +FAIL=0 + +cleanup() { + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +report_pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } +report_fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } + +wait_for_http() { + # Poll HTTP for up to 90s. Returns 0 on first 200, 1 on timeout. + local url="$1" + local deadline=$((SECONDS + 90)) + while (( SECONDS < deadline )); do + if [[ "$(curl -sS -o /dev/null -w '%{http_code}' "$url" 2>/dev/null)" == "200" ]]; then + return 0 + fi + sleep 2 + done + return 1 +} + +dump_logs_on_fail() { + echo " --- last 30 log lines ---" + docker logs --tail 30 "$CONTAINER_NAME" 2>&1 | sed 's/^/ /' + echo " --- end logs ---" +} + +run_scenario() { + local label="$1"; shift + echo " Scenario: $label" + cleanup + docker run -d --name "$CONTAINER_NAME" -p "$HOST_PORT:$HTTP_PORT" "$@" "$IMAGE" >/dev/null +} + +echo "===== $IMAGE ($VARIANT) =====" + +# --- Scenario 1: baseline (regression test for default behavior) --- +run_scenario "baseline (defaults, no opt-in)" +if wait_for_http "http://localhost:$HOST_PORT/"; then + report_pass "baseline responds 200" +else + report_fail "baseline did not respond within 90s" + dump_logs_on_fail +fi + +# --- Scenario 2: non-root user only --- +run_scenario "non-root user (--user lucee)" --user lucee +if wait_for_http "http://localhost:$HOST_PORT/"; then + report_pass "non-root responds 200" + + uid_observed=$(docker exec "$CONTAINER_NAME" id -u 2>/dev/null || echo error) + user_observed=$(docker exec "$CONTAINER_NAME" id -un 2>/dev/null || echo error) + if [[ "$uid_observed" != "0" && "$uid_observed" != "error" ]]; then + report_pass "container running as non-root (uid=$uid_observed user=$user_observed)" + else + report_fail "container is still running as root (uid=$uid_observed)" + fi +else + report_fail "non-root did not respond within 90s" + dump_logs_on_fail +fi + +# --- Scenario 3: full read-only rootfs + non-root + LUCEE_RUNTIME_DIR --- +run_scenario "--read-only + --user lucee + LUCEE_RUNTIME_DIR" \ + --user lucee \ + --read-only \ + -e LUCEE_RUNTIME_DIR=/opt/lucee/server-runtime +if wait_for_http "http://localhost:$HOST_PORT/"; then + report_pass "read-only rootfs responds 200" +else + report_fail "read-only rootfs did not respond within 90s" + dump_logs_on_fail +fi + +# Check for the seed log line (regardless of HTTP outcome, because logs are still useful). +# Retry briefly to absorb a small race we've observed where Docker's log driver +# hasn't surfaced the entrypoint's pre-exec stderr write by the time the HTTP +# check returns. If the line is genuinely absent it will still report FAIL after +# all retries. +seed_line="" +for _ in 1 2 3 4 5; do + seed_line=$(docker logs "$CONTAINER_NAME" 2>&1 | grep "Seeded LUCEE_RUNTIME_DIR" | head -1) + [[ -n "$seed_line" ]] && break + sleep 1 +done +if [[ -n "$seed_line" ]]; then + report_pass "seed log line present" + echo " > $seed_line" +else + report_fail "seed log line missing" +fi + +cleanup + +echo "===== $IMAGE: $PASS passed, $FAIL failed =====" +[[ "$FAIL" -eq 0 ]]