From 54078c361fccf92f98d35357244c7c7dfd18f007 Mon Sep 17 00:00:00 2001 From: hw Date: Wed, 30 Sep 2026 00:10:16 -0400 Subject: [PATCH] feat: opt-in basic auth, noindex and PHP memory limit for FrankenPHP Add environment-driven, off-by-default features to the frankenphp-trixie variant: basic auth (password hashed by a new docker-drupal-entrypoint, failing closed when credentials are missing), noindex headers and robots.txt via snippets, and PHP_MEMORY_LIMIT for the web server. Includes README docs and CI tests on both architectures. --- .github/workflows/docker-buildx.yml | 21 ++++ README.md | 45 +++++++- php8/frankenphp-trixie/Caddyfile | 9 +- php8/frankenphp-trixie/Dockerfile | 9 ++ .../basic-auth/disabled.caddy | 2 + .../basic-auth/enabled.caddy | 7 ++ .../docker-drupal-entrypoint | 41 +++++++ php8/frankenphp-trixie/noindex/disabled.caddy | 2 + php8/frankenphp-trixie/noindex/enabled.caddy | 9 ++ tests/docker-compose.frankenphp-optin.yml | 9 ++ tests/verify-frankenphp-optin.sh | 103 ++++++++++++++++++ 11 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 php8/frankenphp-trixie/basic-auth/disabled.caddy create mode 100644 php8/frankenphp-trixie/basic-auth/enabled.caddy create mode 100755 php8/frankenphp-trixie/docker-drupal-entrypoint create mode 100644 php8/frankenphp-trixie/noindex/disabled.caddy create mode 100644 php8/frankenphp-trixie/noindex/enabled.caddy create mode 100644 tests/docker-compose.frankenphp-optin.yml create mode 100755 tests/verify-frankenphp-optin.sh diff --git a/.github/workflows/docker-buildx.yml b/.github/workflows/docker-buildx.yml index 533b546..167c082 100644 --- a/.github/workflows/docker-buildx.yml +++ b/.github/workflows/docker-buildx.yml @@ -213,6 +213,27 @@ jobs: chmod +x tests/verify-drupal.sh ./tests/verify-drupal.sh ${{ matrix.variant }} + - name: Verify FrankenPHP defaults (opt-in features off) + if: matrix.variant == 'frankenphp-trixie' + run: ./tests/verify-frankenphp-optin.sh default + + - name: Verify FrankenPHP opt-in features + if: matrix.variant == 'frankenphp-trixie' + env: + # Special characters on purpose: $, quotes and braces must survive + # hashing and the Caddyfile. + BASIC_AUTH_PASSWORD: 'S3cret$pa"ss{env.HOME}' + run: | + # Recreate the container on the same installed site with the + # variables from the override file set. + docker compose -f tests/docker-compose.yml \ + -f tests/docker-compose.frankenphp-optin.yml up -d --force-recreate + ./tests/verify-frankenphp-optin.sh optin + + - name: Verify FrankenPHP basic auth fails closed + if: matrix.variant == 'frankenphp-trixie' + run: ./tests/verify-frankenphp-optin.sh failclosed "drupal-test:${{ matrix.php_version }}-${{ matrix.variant }}" + - name: Capture logs if: failure() run: | diff --git a/README.md b/README.md index dc92ac8..ce4fd78 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,9 @@ The image ships with a Drupal-tuned Caddyfile that blocks access to sensitive pa ```caddyfile { - frankenphp + frankenphp { + php_ini memory_limit {$PHP_MEMORY_LIMIT:128M} + } order php_server before file_server } @@ -225,6 +227,11 @@ The image ships with a Drupal-tuned Caddyfile that blocks access to sensitive pa encode zstd br gzip root * /app/web + # Opt-in features, off by default (see README). Noindex comes first so the + # 401 from basic auth carries the X-Robots-Tag header too. + import {$NOINDEX_SNIPPET:/etc/frankenphp/noindex/disabled.caddy} + import {$BASIC_AUTH_SNIPPET:/etc/frankenphp/basic-auth/disabled.caddy} + # Block hidden PHP files @hiddenPhp path_regexp \..*/.*.php$ error @hiddenPhp 403 @@ -259,6 +266,42 @@ The image ships with a Drupal-tuned Caddyfile that blocks access to sensitive pa } ``` +#### Basic auth, noindex and PHP memory limit + +Three features are driven by environment variables, so a private staging or demo site can run on the stock Caddyfile. All are off (or unchanged) by default. + +| Variable | Default | Purpose | +| --- | --- | --- | +| `BASIC_AUTH_USER` | unset | User name for HTTP basic auth. | +| `BASIC_AUTH_PASSWORD` | unset | Plaintext password. The entrypoint hashes it at start. | +| `BASIC_AUTH_HASH` | unset | A ready-made hash (from `frankenphp hash-password`) instead of a password. Wins over `BASIC_AUTH_PASSWORD` if both are set. | +| `BASIC_AUTH_SNIPPET` | `/etc/frankenphp/basic-auth/disabled.caddy` | Caddy snippet imported for basic auth. Set automatically to `/etc/frankenphp/basic-auth/enabled.caddy` when the user and a password or hash are given. | +| `NOINDEX_SNIPPET` | `/etc/frankenphp/noindex/disabled.caddy` | Set to `/etc/frankenphp/noindex/enabled.caddy` to send `X-Robots-Tag: noindex, nofollow, noarchive` on every response (the 401 included) and to serve a disallow-all `/robots.txt`. | +| `PHP_MEMORY_LIMIT` | `128M` | `memory_limit` for the web server. `128M` is PHP's built-in default, so nothing changes unless you set it. The CLI keeps `memory_limit = -1`, so drush and composer are unaffected. | + +```yaml +services: + drupal: + image: hussainweb/drupal-base:php8.5-frankenphp-trixie + volumes: + - ./path/to/your/drupal/root:/app + ports: + - "8080:80" + environment: + BASIC_AUTH_USER: demo + BASIC_AUTH_PASSWORD: ${BASIC_AUTH_PASSWORD} + NOINDEX_SNIPPET: /etc/frankenphp/noindex/enabled.caddy + PHP_MEMORY_LIMIT: 512M +``` + +Basic auth protects every path except `/robots.txt`, so crawlers can still read the disallow rule. The password is hashed by `docker-drupal-entrypoint` (a bcrypt hash via `frankenphp hash-password`, with the password on stdin), so you never write a hash containing `$` into a compose file. `BASIC_AUTH_PASSWORD` is unset before the server starts, so it does not reach PHP's environment. The user name must not contain spaces. + +**Fails closed.** If basic auth is requested (`BASIC_AUTH_SNIPPET` points at the enabled file) but the user name or the password and hash are missing, the container exits with an error instead of starting an open site. + +**Downstream entrypoints.** The image sets `ENTRYPOINT ["docker-drupal-entrypoint"]` and re-declares the upstream `CMD`. If your image has its own entrypoint, end it with `exec docker-drupal-entrypoint "$@"` to keep these features (it hands over to `docker-php-entrypoint`). If you replace the entrypoint without doing so, the variables have no effect, and basic auth is never enabled. + +If you mount your own Caddyfile, it only gets these features if it contains the same `import` lines (and the `php_ini` line in the global block). + #### Custom Caddyfile To customize the Caddy configuration, mount your own Caddyfile: diff --git a/php8/frankenphp-trixie/Caddyfile b/php8/frankenphp-trixie/Caddyfile index f1c9146..4f579db 100644 --- a/php8/frankenphp-trixie/Caddyfile +++ b/php8/frankenphp-trixie/Caddyfile @@ -1,5 +1,7 @@ { - frankenphp + frankenphp { + php_ini memory_limit {$PHP_MEMORY_LIMIT:128M} + } order php_server before file_server } @@ -7,6 +9,11 @@ encode zstd br gzip root * /app/web + # Opt-in features, off by default (see README). Noindex comes first so the + # 401 from basic auth carries the X-Robots-Tag header too. + import {$NOINDEX_SNIPPET:/etc/frankenphp/noindex/disabled.caddy} + import {$BASIC_AUTH_SNIPPET:/etc/frankenphp/basic-auth/disabled.caddy} + # Block hidden PHP files @hiddenPhp path_regexp \..*/.*.php$ error @hiddenPhp 403 diff --git a/php8/frankenphp-trixie/Dockerfile b/php8/frankenphp-trixie/Dockerfile index 097ad56..4ba119c 100644 --- a/php8/frankenphp-trixie/Dockerfile +++ b/php8/frankenphp-trixie/Dockerfile @@ -125,6 +125,15 @@ COPY --from=composer:2 /usr/bin/composer /usr/local/bin/ # FrankenPHP configuration # Copy Caddyfile for Drupal configuration COPY Caddyfile /etc/frankenphp/Caddyfile +# Opt-in snippets imported by the Caddyfile; the disabled ones are the default +COPY noindex/ /etc/frankenphp/noindex/ +COPY basic-auth/ /etc/frankenphp/basic-auth/ + +# Prepares basic auth from environment variables, then runs the upstream +# entrypoint. Setting ENTRYPOINT resets CMD, so re-declare the upstream one. +COPY --chmod=755 docker-drupal-entrypoint /usr/local/bin/docker-drupal-entrypoint +ENTRYPOINT ["docker-drupal-entrypoint"] +CMD ["--config", "/etc/frankenphp/Caddyfile", "--adapter", "caddyfile"] WORKDIR /app ENV PATH=${PATH}:/app/vendor/bin diff --git a/php8/frankenphp-trixie/basic-auth/disabled.caddy b/php8/frankenphp-trixie/basic-auth/disabled.caddy new file mode 100644 index 0000000..93a89ae --- /dev/null +++ b/php8/frankenphp-trixie/basic-auth/disabled.caddy @@ -0,0 +1,2 @@ +# Basic auth is disabled (default). Nothing to configure. +# Set BASIC_AUTH_USER and BASIC_AUTH_PASSWORD to enable it. diff --git a/php8/frankenphp-trixie/basic-auth/enabled.caddy b/php8/frankenphp-trixie/basic-auth/enabled.caddy new file mode 100644 index 0000000..8dae170 --- /dev/null +++ b/php8/frankenphp-trixie/basic-auth/enabled.caddy @@ -0,0 +1,7 @@ +# Opt-in: HTTP basic auth on every path except /robots.txt. +# docker-drupal-entrypoint enables this when BASIC_AUTH_USER and +# BASIC_AUTH_PASSWORD (or BASIC_AUTH_HASH) are set. +@drupal_base_basic_auth not path /robots.txt +basic_auth @drupal_base_basic_auth { + {$BASIC_AUTH_USER} {$BASIC_AUTH_HASH} +} diff --git a/php8/frankenphp-trixie/docker-drupal-entrypoint b/php8/frankenphp-trixie/docker-drupal-entrypoint new file mode 100755 index 0000000..13b0537 --- /dev/null +++ b/php8/frankenphp-trixie/docker-drupal-entrypoint @@ -0,0 +1,41 @@ +#!/bin/sh +# Prepares the opt-in FrankenPHP features, then hands over to the upstream +# entrypoint. Downstream entrypoints should end with: +# exec docker-drupal-entrypoint "$@" +set -eu + +enabled=/etc/frankenphp/basic-auth/enabled.caddy + +# Credentials given: turn basic auth on unless a snippet was chosen. +if [ -n "${BASIC_AUTH_USER:-}" ] \ + && { [ -n "${BASIC_AUTH_PASSWORD:-}" ] || [ -n "${BASIC_AUTH_HASH:-}" ]; } \ + && [ -z "${BASIC_AUTH_SNIPPET:-}" ]; then + BASIC_AUTH_SNIPPET=$enabled + export BASIC_AUTH_SNIPPET +fi + +# Basic auth requested: fail closed rather than serve an open site. +if [ "${BASIC_AUTH_SNIPPET:-}" = "$enabled" ]; then + if [ -z "${BASIC_AUTH_USER:-}" ]; then + echo "docker-drupal-entrypoint: basic auth is enabled but BASIC_AUTH_USER is not set." >&2 + exit 1 + fi + if [ -z "${BASIC_AUTH_HASH:-}" ]; then + if [ -z "${BASIC_AUTH_PASSWORD:-}" ]; then + echo "docker-drupal-entrypoint: basic auth is enabled but neither BASIC_AUTH_PASSWORD nor BASIC_AUTH_HASH is set." >&2 + exit 1 + fi + # The password goes in on stdin so it never shows up in a process list. + if ! BASIC_AUTH_HASH=$(printf '%s\n' "$BASIC_AUTH_PASSWORD" | frankenphp hash-password) \ + || [ -z "$BASIC_AUTH_HASH" ]; then + echo "docker-drupal-entrypoint: failed to hash BASIC_AUTH_PASSWORD." >&2 + exit 1 + fi + export BASIC_AUTH_HASH + fi +fi + +# The plaintext is no longer needed; keep it out of PHP's environment. +unset BASIC_AUTH_PASSWORD + +exec docker-php-entrypoint "$@" diff --git a/php8/frankenphp-trixie/noindex/disabled.caddy b/php8/frankenphp-trixie/noindex/disabled.caddy new file mode 100644 index 0000000..38cd473 --- /dev/null +++ b/php8/frankenphp-trixie/noindex/disabled.caddy @@ -0,0 +1,2 @@ +# Noindex is disabled (default). Nothing to configure. +# Set NOINDEX_SNIPPET=/etc/frankenphp/noindex/enabled.caddy to enable it. diff --git a/php8/frankenphp-trixie/noindex/enabled.caddy b/php8/frankenphp-trixie/noindex/enabled.caddy new file mode 100644 index 0000000..92ff603 --- /dev/null +++ b/php8/frankenphp-trixie/noindex/enabled.caddy @@ -0,0 +1,9 @@ +# Opt-in: discourage search engines from indexing the site. +# Enable with NOINDEX_SNIPPET=/etc/frankenphp/noindex/enabled.caddy +header X-Robots-Tag "noindex, nofollow, noarchive" +handle /robots.txt { + header Content-Type "text/plain; charset=utf-8" + respond "User-agent: * +Disallow: / +" 200 +} diff --git a/tests/docker-compose.frankenphp-optin.yml b/tests/docker-compose.frankenphp-optin.yml new file mode 100644 index 0000000..d8bae13 --- /dev/null +++ b/tests/docker-compose.frankenphp-optin.yml @@ -0,0 +1,9 @@ +# Override for the FrankenPHP variant: turns on the opt-in features. +# Layered over tests/docker-compose.yml by the CI step that runs tests/verify-frankenphp-optin.sh optin. +services: + drupal: + environment: + BASIC_AUTH_USER: demo + BASIC_AUTH_PASSWORD: ${BASIC_AUTH_PASSWORD} + NOINDEX_SNIPPET: /etc/frankenphp/noindex/enabled.caddy + PHP_MEMORY_LIMIT: 512M diff --git a/tests/verify-frankenphp-optin.sh b/tests/verify-frankenphp-optin.sh new file mode 100755 index 0000000..f38d7f8 --- /dev/null +++ b/tests/verify-frankenphp-optin.sh @@ -0,0 +1,103 @@ +#!/bin/bash +# Checks the opt-in FrankenPHP features (basic auth, noindex, PHP memory limit). +# +# Usage: verify-frankenphp-optin.sh default|optin|failclosed +# +# default run against the site started with none of the variables set +# optin run against the site started with docker-compose.frankenphp-optin.yml +# (needs BASIC_AUTH_PASSWORD in the environment) +# failclosed run the image directly with incomplete basic auth settings +set -u + +MODE=${1:-} +IMAGE=${2:-} +BASE_URL="http://localhost:8080" +SERVICE="drupal" +FAILED=0 + +pass() { echo "PASSED: $1"; } +fail() { echo "FAILED: $1"; FAILED=1; } + +check() { # description, expected, actual + if [ "$2" = "$3" ]; then pass "$1 ($3)"; else fail "$1 (expected '$2', got '$3')"; fi +} + +check_contains() { # description, needle, haystack + if printf '%s' "$3" | grep -qF -- "$2"; then pass "$1"; else fail "$1 (missing '$2')"; fi +} + +check_not_contains() { # description, needle, haystack + if printf '%s' "$3" | grep -qF -- "$2"; then fail "$1 (found '$2')"; else pass "$1"; fi +} + +wait_for_web() { + for _ in $(seq 1 30); do + code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/" || true) + [ "$code" != "000" ] && return 0 + sleep 2 + done + echo "Web server did not come up" + docker compose logs "$SERVICE" || true + exit 1 +} + +# memory_limit as the web server sees it (a request), not the CLI. +web_memory_limit() { # extra curl args + docker compose exec -T "$SERVICE" sh -c \ + "echo ' /app/web/memlimit.php" + curl -s "$@" "$BASE_URL/memlimit.php" +} + +case "$MODE" in +default) + wait_for_web + check "/ without credentials" 200 "$(curl -s -o /dev/null -w '%{http_code}' -L "$BASE_URL/")" + headers=$(curl -si "$BASE_URL/") + check_not_contains "/ has no X-Robots-Tag" "X-Robots-Tag: noindex" "$headers" + robots=$(curl -s "$BASE_URL/robots.txt") + check_contains "/robots.txt is Drupal's own" "Disallow: /core/" "$robots" + check "web memory_limit is PHP's default" 128M "$(web_memory_limit)" + check "CLI memory_limit" -1 "$(docker compose exec -T "$SERVICE" php -r 'echo ini_get("memory_limit");')" + ;; +optin) + : "${BASIC_AUTH_PASSWORD:?BASIC_AUTH_PASSWORD must be set}" + wait_for_web + check "/ without credentials" 401 "$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/")" + headers=$(curl -si "$BASE_URL/") + check_contains "401 carries X-Robots-Tag" "X-Robots-Tag: noindex, nofollow, noarchive" "$headers" + check "/ with wrong credentials" 401 "$(curl -s -o /dev/null -w '%{http_code}' -u "demo:wrong" "$BASE_URL/")" + check "/ with wrong user" 401 "$(curl -s -o /dev/null -w '%{http_code}' -u "other:${BASIC_AUTH_PASSWORD}" "$BASE_URL/")" + check "/ with right credentials" 200 "$(curl -s -o /dev/null -w '%{http_code}' -L -u "demo:${BASIC_AUTH_PASSWORD}" "$BASE_URL/")" + check "/user/login without credentials" 401 "$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/user/login")" + check "/robots.txt without credentials" 200 "$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/robots.txt")" + robots=$(curl -s "$BASE_URL/robots.txt") + check_contains "/robots.txt disallows all" "Disallow: /" "$robots" + check_not_contains "/robots.txt is not Drupal's" "Disallow: /core/" "$robots" + check_contains "/robots.txt carries X-Robots-Tag" "X-Robots-Tag: noindex" "$(curl -si "$BASE_URL/robots.txt")" + check "web memory_limit" 512M "$(web_memory_limit -u "demo:${BASIC_AUTH_PASSWORD}")" + check "CLI memory_limit" -1 "$(docker compose exec -T "$SERVICE" php -r 'echo ini_get("memory_limit");')" + # PID 1 is the web server after the entrypoint's exec; its own environment + # is what PHP would inherit. + if docker compose exec -T "$SERVICE" sh -c "tr '\0' '\n' < /proc/1/environ | grep -q '^BASIC_AUTH_PASSWORD='"; then + fail "BASIC_AUTH_PASSWORD is unset in the server process" + else + pass "BASIC_AUTH_PASSWORD is unset in the server process" + fi + ;; +failclosed) + [ -n "$IMAGE" ] || { echo "image required"; exit 2; } + enabled=/etc/frankenphp/basic-auth/enabled.caddy + for envs in "BASIC_AUTH_USER=demo" "BASIC_AUTH_PASSWORD=secret"; do + out=$(docker run --rm -e "$envs" -e "BASIC_AUTH_SNIPPET=$enabled" "$IMAGE" 2>&1) + rc=$? + if [ "$rc" -ne 0 ]; then pass "exits non-zero with only $envs (rc=$rc)"; else fail "exits non-zero with only $envs"; fi + check_contains "message for only $envs" "docker-drupal-entrypoint: basic auth is enabled" "$out" + done + ;; +*) + echo "Usage: $0 default|optin|failclosed " + exit 2 + ;; +esac + +exit "$FAILED"