diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 51b4086b6..7a63b6484 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -135,6 +135,10 @@ jobs: context: . file: docker/Dockerfile.prod platforms: linux/amd64,linux/arm64 + # Always re-resolve the floating `php:8.5-*` base tags so a + # PHP security release lands on the next publish instead of + # being masked by a cached base layer. + pull: true push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/AGENTS.md b/AGENTS.md index e5e331e2f..0fde01985 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5051,6 +5051,6 @@ the spec, target a 1920px viewport, not 1440px. | Translate raw `PDOException` connect errors into operator-friendly messages on the wizard's database step | `sbpp_install_translate_pdo_error()` in `web/install/includes/helpers.php` (#1335 m4). Pattern-matches the four error codes a non-technical operator is most likely to hit — 1045 (access denied), 2002 (host unreachable), 1049 (unknown database), 1044 (denied for user on database) — and emits a friendlier translation; falls back to the raw message for unrecognised codes so debugging stays possible. Pre-fix the wizard surfaced `SQLSTATE[HY000] [1045] Access denied for user 'sourcebans'@'192.168.96.5' (using password: YES)` verbatim, which is gibberish to non-DBAs and includes the panel-as-seen-by-DB internal IP (minor information disclosure). Regression test: `web/tests/integration/InstallGuardTest.php::testPdoErrorTranslationCoversCommonCodes`. | | Run a stack in parallel with another worktree | Worktree-local `docker-compose.override.yml` (see "Parallel stacks") | | Local dev stack details | `docker/README.md` | -| Build / extend the production Docker image (multi-stage build, hardened runtime, entrypoint state machine, healthcheck) | `docker/Dockerfile.prod` (multi-stage: `builder` runs `composer install --no-dev` against `web/`; `runtime` carries pdo_mysql + intl + zip + mbstring + gmp ONLY — no nodejs / npm / git / dev-prepend) + `docker/php/prod-entrypoint.sh` (pure POSIX shell state machine: `*_FILE` secret resolution → DATABASE_URL parse → defaults → Apache config (PORT + mod_remoteip from `SBPP_TRUSTED_PROXIES`) → wait-for-DB → render config.php (only when missing) → first-boot install (schema + data + seed admin from `INITIAL_ADMIN_*` env vars) → headless updater migrations → strip install/ + updater/ from writable layer → ensure writable cache/templates_c/demos → `exec apache2-foreground`) + `docker/php/prod-php.ini` (production OPcache: `validate_timestamps=0`, `display_errors=Off`, `log_errors=On`, errors → `/dev/stderr`, UTC default, `expose_php=Off`) + `docker/apache/sbpp-prod.conf` (denies dotfiles + vendor/ + configs/ + includes/ + install/ + updater/ + cache/ + templates_c/ + config.php + composer.{json,lock}; `RemoteIPHeader X-Forwarded-For` for the trusted-proxy chain) + `web/health.php` (DB-aware unauthenticated healthcheck — `init.php` bootstraps the panel; `$GLOBALS['PDO']->query('SELECT 1')` returns 200 OK or 503 + plain-text reason; Cache-Control: no-store + X-Robots-Tag: noindex). The production image MUST NOT define `SBPP_DEV_KEEP_INSTALL`; the entrypoint's `strip_install_dirs` step is what makes the panel-runtime guard pass instead (#1381). | +| Build / extend the production Docker image (multi-stage build, hardened runtime, entrypoint state machine, healthcheck) | `docker/Dockerfile.prod` (multi-stage: `builder` runs `composer install --no-dev` against `web/`; `runtime` carries pdo_mysql + intl + zip + mbstring + gmp ONLY — no nodejs / npm / git / dev-prepend; a `PHP_MIN_VERSION` build-arg check fails the build when the floating `php:8.5-apache` base is below the last PHP security floor, currently 8.5.12 for CVE-2026-109618 / CVE-2026-109619 — bump it on every PHP security release, and `docker-image.yml` passes `pull: true` so a cached base can't mask the update) + `docker/php/prod-entrypoint.sh` (pure POSIX shell state machine: `*_FILE` secret resolution → DATABASE_URL parse → defaults → Apache config (PORT + mod_remoteip from `SBPP_TRUSTED_PROXIES`) → wait-for-DB → render config.php (only when missing) → first-boot install (schema + data + seed admin from `INITIAL_ADMIN_*` env vars) → headless updater migrations → strip install/ + updater/ from writable layer → ensure writable cache/templates_c/demos → `exec apache2-foreground`) + `docker/php/prod-php.ini` (production OPcache: `validate_timestamps=0`, `display_errors=Off`, `log_errors=On`, errors → `/dev/stderr`, UTC default, `expose_php=Off`, `session.upload_progress.enabled=0` + `fatal_error_backtraces=0` as CVE-2026-109619 hardening — keep these in the ini file, `ini_set()` runs too late) + `docker/apache/sbpp-prod.conf` (denies dotfiles + vendor/ + configs/ + includes/ + install/ + updater/ + cache/ + templates_c/ + config.php + composer.{json,lock}; `RemoteIPHeader X-Forwarded-For` for the trusted-proxy chain) + `web/health.php` (DB-aware unauthenticated healthcheck — `init.php` bootstraps the panel; `$GLOBALS['PDO']->query('SELECT 1')` returns 200 OK or 503 + plain-text reason; Cache-Control: no-store + X-Robots-Tag: noindex). The production image MUST NOT define `SBPP_DEV_KEEP_INSTALL`; the entrypoint's `strip_install_dirs` step is what makes the panel-runtime guard pass instead (#1381). | | Deploy / configure the production Docker stack (compose, env vars, reverse-proxy) | `docker-compose.prod.yml` (pulls `ghcr.io/srcdslab/sourcebans-pp:${SBPP_IMAGE_TAG:-latest}` — NOT a build context; DB port NOT exposed by default; commented `caddy:` service block for opt-in TLS) + `.env.example.prod` (every supported env var grouped by required / recommended / first-boot / optional / advanced; documents the `*_FILE` Docker-secret pattern and the `SBPP_CONFIG_PATH` Docker-secret-mount pattern; uses `${VAR:?...}` compose syntax for required vars so a fresh-deploy operator who forgot `SB_SECRET_KEY` / `DB_PASS` / `DB_ROOT_PASS` gets a useful container-startup error) + `docker/caddy/Caddyfile.example` (one-line `reverse_proxy web:80` + zstd/gzip encode + static-asset cache headers). Three persistent volumes: `dbdata` + `demos` (MUST persist — DB rows + uploaded ban-evidence); `cache` + `smarty` (CAN be ephemeral — rebuild on demand). Operators run `docker compose -f docker-compose.prod.yml up -d` from a directory carrying both files; upgrades are `docker compose pull && up -d` (image is immutable, entrypoint runs idempotent migrations on every boot, named volumes survive the swap) (#1381). | | Honour `SBPP_CONFIG_PATH` so config.php can live outside the panel root (Docker-secret mount) | `sbpp_resolve_config_path()` in `web/init-recovery.php` is the single source of truth; `web/init.php` calls it to resolve the require-site. `web/install/already-installed.php`'s `sbpp_install_is_already_installed()` re-implements the env-var read inline (per its self-contained no-Composer docblock) so the wizard-side and runtime-side guards agree on the install-state sentinel path. Pre-#1381 both halves hard-coded the panel-root path; with a Docker-secret-mounted config the runtime would 302-to-/install/ while the wizard would happily start over. Regression tests: `testResolveConfigPathHonorsEnvVar` + `testWizardGuardHonorsConfigPathEnvVar` in `web/tests/integration/InstallGuardTest.php`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f47c68ce..5877356b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ Legend: ? = Other stuff ``` +(21/10/26): Version 2.1.23-srcdslab +----------------------- +``` +01. ! Security: the production Docker image refuses to build on PHP + below 8.5.12 (CVE-2026-109618, CVE-2026-109619, unauthenticated + RCE) and always pulls a fresh php:8.5 base image +02. ! Security: production php.ini disables session upload progress and + fatal error backtraces (CVE-2026-109619 hardening) +03. + Install wizard warns when PHP is older than 8.5.12 +``` + (20/10/26): Version 2.1.22-srcdslab ----------------------- ``` diff --git a/docker/Dockerfile.prod b/docker/Dockerfile.prod index 1929befeb..5abb6cc8a 100644 --- a/docker/Dockerfile.prod +++ b/docker/Dockerfile.prod @@ -91,6 +91,16 @@ FROM php:8.5-apache AS runtime ENV DEBIAN_FRONTEND=noninteractive \ APACHE_DOCUMENT_ROOT=/var/www/html/web +# Refuse to build on a PHP older than the last security floor. The +# `php:8.5-apache` tag floats, so a stale local cache or a mirror that +# lags behind Docker Hub would otherwise publish a vulnerable runtime. +# 8.5.12 fixes CVE-2026-109618 + CVE-2026-109619 (unauthenticated RCE, +# worst under mod_php, which is what this image runs). Bump the floor +# on the next PHP security release. +ARG PHP_MIN_VERSION=8.5.12 +RUN php -r 'exit(version_compare(PHP_VERSION, getenv("PHP_MIN_VERSION"), ">=") ? 0 : 1);' \ + || { echo "PHP $(php -r 'echo PHP_VERSION;') is below the security floor ${PHP_MIN_VERSION}; pull a newer php:8.5-apache base image." >&2; exit 1; } + # Runtime extensions only. The dev image carries `default-mysql-client`, # `nodejs`, `npm`, `git`, `gettext-base`, `unzip` — all developer-side # helpers the prod image has no business shipping. `pdo_mysql` is the diff --git a/docker/php/prod-php.ini b/docker/php/prod-php.ini index 144d5c51d..4bbd9843c 100644 --- a/docker/php/prod-php.ini +++ b/docker/php/prod-php.ini @@ -53,6 +53,14 @@ session.cookie_samesite = "Lax" session.use_strict_mode = 1 session.gc_maxlifetime = 86400 +; Hardening for CVE-2026-109619 (stale global state RCE). The panel +; never reads upload progress, and fatal-error backtraces are a +; confirmed attack path on PHP 8.5. Both must be set here, before +; request parsing; ini_set() at runtime is too late. The real fix is +; PHP >= 8.5.12, which Dockerfile.prod enforces at build time. +session.upload_progress.enabled = 0 +fatal_error_backtraces = 0 + ; --------------------------------------------------------------------------- ; OPcache (production knobs) ; --------------------------------------------------------------------------- diff --git a/docs/src/content/docs/getting-started/prerequisites.mdx b/docs/src/content/docs/getting-started/prerequisites.mdx index 5f7f0abbc..04143dc61 100644 --- a/docs/src/content/docs/getting-started/prerequisites.mdx +++ b/docs/src/content/docs/getting-started/prerequisites.mdx @@ -85,6 +85,10 @@ some CJK characters fail to insert and the plugin logs a SQL error. - **PHP 8.5 or newer.** This is checked by the install wizard's "System check" step. +- **PHP 8.5.12 or newer for production.** Older 8.5 releases have two + critical remote code execution bugs (CVE-2026-109618 and + CVE-2026-109619). The wizard warns below 8.5.12. If your distro + backported the fix, the warning is safe to ignore. - **64 MB memory limit minimum.** The wizard doesn't enforce this, but the panel can run out of memory on large ban-list pages below 64 MB. diff --git a/docs/src/content/docs/getting-started/quickstart-docker.mdx b/docs/src/content/docs/getting-started/quickstart-docker.mdx index 6cae216c1..71cffcc07 100644 --- a/docs/src/content/docs/getting-started/quickstart-docker.mdx +++ b/docs/src/content/docs/getting-started/quickstart-docker.mdx @@ -281,6 +281,12 @@ against the existing database. The migrations are idempotent: every script in `web/updater/data/.php` is forward-only and safe to re-run. The `dbdata` and `demos` volumes survive the swap unchanged. +PHP security fixes reach you the same way: each published image is +built on the newest `php:8.5-apache` base, and the build fails below +PHP 8.5.12 (the fix for CVE-2026-109618 and CVE-2026-109619). Pull a +fresh image after a PHP security release. Check the running version +with `docker compose -f docker-compose.prod.yml exec web php -v`. + Image tags: | Tag | What it points at | diff --git a/web/install/pages/page.3.php b/web/install/pages/page.3.php index 88580ccef..b783732e9 100644 --- a/web/install/pages/page.3.php +++ b/web/install/pages/page.3.php @@ -55,14 +55,22 @@ // Runtime gate for self-hosters below the PHP 8.5 floor. The analyser // environment is locked to 8.5+, so this branch is not exercised there. $phpVersionOk = version_compare(PHP_VERSION, '8.5', '>='); +// 8.5.0-8.5.11 still run the panel but carry CVE-2026-109618 + +// CVE-2026-109619 (unauthenticated RCE). Warn rather than block: the +// operator may be on a distro build with the fix backported. +$phpPatched = version_compare(PHP_VERSION, '8.5.12', '>='); $phpRows[] = [ 'label' => 'PHP version', - 'required' => '8.5 or newer', - 'status' => $phpVersionOk ? 'ok' : 'err', - 'detail' => PHP_VERSION, + 'required' => '8.5.12 or newer', + 'status' => !$phpVersionOk ? 'err' : ($phpPatched ? 'ok' : 'warn'), + 'detail' => $phpVersionOk && !$phpPatched + ? PHP_VERSION . ' (security update needed)' + : PHP_VERSION, ]; if (!$phpVersionOk) { $errors++; +} elseif (!$phpPatched) { + $warnings++; } $fileUploads = (bool) ini_get('file_uploads');