diff --git a/docker-compose.yml b/docker-compose.yml index 3ed080d..f07e41d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,6 +52,7 @@ services: $$_SERVER['HTTPS'] = 'on'; } define('DISALLOW_FILE_EDIT', true); + define('DISABLE_WP_CRON', true); define('WP_AUTO_UPDATE_CORE', 'minor'); define('WP_POST_REVISIONS', 10); if (getenv('WP_ENVIRONMENT_TYPE') === 'production') { diff --git a/docs/adr/0001-wordpress-single-page.md b/docs/adr/0001-wordpress-single-page.md index c4e29d0..6dbcdaf 100644 --- a/docs/adr/0001-wordpress-single-page.md +++ b/docs/adr/0001-wordpress-single-page.md @@ -12,7 +12,7 @@ architecture and starts independently from `main`. ## Decision -Use pinned WordPress 7.0/PHP 8.3 with MariaDB 11.8 LTS. Build a dependency-free custom +Use pinned WordPress 7.0.2/PHP 8.3 with MariaDB 11.8 LTS. Build a dependency-free custom classic theme for exact semantic markup and responsive design. Store homepage fields as sanitized theme modifications, while a must-use plugin owns Shows and Gallery custom post types so structured content is not lost when changing themes. diff --git a/docs/architecture.md b/docs/architecture.md index 7b7b9ac..23be5de 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,7 +1,7 @@ # Architecture NGINX is the only public entry point for `azionelab.org`. It proxies HTTP to the -official WordPress 7.0/PHP 8.3 Apache image over the private `web` network. WordPress +official WordPress 7.0.2/PHP 8.3 Apache image over the private `web` network. WordPress connects to MariaDB 11.8 LTS over a separate internal `data` network. Neither WordPress nor MariaDB publishes a host port; automated security checks guard this assumption. diff --git a/docs/operations.md b/docs/operations.md index 5fc6430..2fa4e01 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -23,6 +23,11 @@ Review security releases routinely, update pins in a task branch, rebuild, run t test suite, and deploy. Production disables WordPress web-based file modifications, so image rebuilds are the update path. +External `wp-cron.php` requests are blocked to reduce public attack surface. If future +content scheduling, maintenance jobs, or plugin features require WordPress cron, run it +from a controlled host/container cron against the private WordPress service instead of +leaving the public trigger enabled. + ## Backup and restore Create database and WordPress file backups in one maintenance window. The default host @@ -42,6 +47,8 @@ the database and file volume together, restart, and verify the homepage, media, owner or mode; run `./scripts/prepare-host-volumes.sh` after changing paths or image user IDs. - SMTP is not configured; WordPress password-reset email needs an external mail service. +- WordPress cron is not publicly triggerable; scheduled jobs need an operator-managed + cron runner if that feature becomes necessary. - Admin MFA and network allowlisting are deployment concerns and are not bundled. - WordPress plugins expand the attack surface; install only reviewed, maintained, necessary plugins. diff --git a/docs/security.md b/docs/security.md index 350eeb9..a91730a 100644 --- a/docs/security.md +++ b/docs/security.md @@ -12,9 +12,11 @@ - File editing is always disabled. Production also disables web-based core, theme, and plugin changes; patched images are rebuilt and redeployed instead. - XML-RPC and comments are disabled. NGINX blocks PHP execution below uploads, dotfiles, - and direct `wp-config.php` requests, and rate-limits login/public requests. Apache - also denies uploaded PHP files and direct access to sensitive WordPress internals as - defense in depth. Public REST user enumeration and author archives are disabled. + direct `wp-config.php` requests, the WordPress readme/license files, direct + installation entry points, public mu-plugin directory probing, and external + `wp-cron.php` requests. It also rate-limits login/public requests. Apache also denies + uploaded PHP files and direct access to sensitive WordPress internals as defense in + depth. Public REST user enumeration and author archives are disabled. - Security headers include CSP, same-origin framing, content-type protection, a strict referrer policy, and a restrictive Permissions Policy. WordPress compatibility still requires inline style/script CSP allowances; do not treat this CSP as an XSS sanitizer. diff --git a/nginx/proxy-routes.conf b/nginx/proxy-routes.conf index 2016d2e..99a8502 100644 --- a/nginx/proxy-routes.conf +++ b/nginx/proxy-routes.conf @@ -18,6 +18,26 @@ location = /xmlrpc.php { return 403; } +location = /wp-cron.php { + return 403; +} + +location = /readme.html { + return 404; +} + +location = /license.txt { + return 404; +} + +location = /wp-admin/install.php { + return 404; +} + +location ^~ /wp-content/mu-plugins/ { + return 404; +} + location = /wp-login.php { limit_req zone=login burst=5 nodelay; proxy_pass http://wordpress_backend; diff --git a/tests/functional/tests/portal.spec.ts b/tests/functional/tests/portal.spec.ts index 0ee8085..c1c9fa4 100644 --- a/tests/functional/tests/portal.spec.ts +++ b/tests/functional/tests/portal.spec.ts @@ -121,6 +121,11 @@ test("protects the edge and exposes the WordPress admin", async ({ page, request expect((await request.get("/xmlrpc.php")).status()).toBe(403); expect((await request.get("/.env")).status()).toBe(404); expect((await request.get("/wp-config.php")).status()).toBe(404); + expect((await request.get("/readme.html")).status()).toBe(404); + expect((await request.get("/license.txt")).status()).toBe(404); + expect((await request.get("/wp-admin/install.php")).status()).toBe(404); + expect((await request.get("/wp-content/mu-plugins/")).status()).toBe(404); + expect((await request.get("/wp-cron.php")).status()).toBe(403); expect((await request.get("/wp-content/uploads/probe.php")).status()).toBe(403); expect((await request.get("/wp-json/wp/v2/users")).status()).toBe(404); expect((await request.get("/?post_type=azl_show&name=le-cose-che-restano")).status()).toBe(404); diff --git a/tests/security/check-compose.sh b/tests/security/check-compose.sh index 4a3164b..f8e2102 100755 --- a/tests/security/check-compose.sh +++ b/tests/security/check-compose.sh @@ -22,12 +22,37 @@ grep -q 'azionelab-apache-hardening.conf' /workspace/wordpress/Dockerfile || { exit 1 } +grep -q '^FROM wordpress:7\.0\.2-php8\.3-apache$' /workspace/wordpress/Dockerfile || { + echo "The WordPress image must use the patched 7.0.2 PHP 8.3 Apache tag." >&2 + exit 1 +} + grep -q 'wp-content/(uploads|files)' /workspace/wordpress/apache-hardening.conf || { echo "Apache hardening must block PHP execution below uploads/files." >&2 exit 1 } +grep -q 'wp-content/mu-plugins' /workspace/wordpress/apache-hardening.conf || { + echo "Apache hardening must block direct mu-plugin probing." >&2 + exit 1 +} + grep -q "'publicly_queryable'[[:space:]]*=>[[:space:]]*false" /workspace/wordpress/mu-plugins/azionelab-content.php || { echo "Structured show content must not be exposed as standalone public routes." >&2 exit 1 } + +grep -q "location = /wp-cron.php" /workspace/nginx/proxy-routes.conf || { + echo "NGINX must block external wp-cron.php requests." >&2 + exit 1 +} + +grep -q "location = /readme.html" /workspace/nginx/proxy-routes.conf || { + echo "NGINX must hide the WordPress readme.html file." >&2 + exit 1 +} + +grep -q "location ^~ /wp-content/mu-plugins/" /workspace/nginx/proxy-routes.conf || { + echo "NGINX must hide the mu-plugins directory from public probing." >&2 + exit 1 +} diff --git a/wordpress/Dockerfile b/wordpress/Dockerfile index c9cc755..62c620c 100644 --- a/wordpress/Dockerfile +++ b/wordpress/Dockerfile @@ -1,4 +1,4 @@ -FROM wordpress:7.0.0-php8.3-apache +FROM wordpress:7.0.2-php8.3-apache COPY php.ini /usr/local/etc/php/conf.d/azionelab.ini COPY .htaccess /opt/azionelab/.htaccess diff --git a/wordpress/apache-hardening.conf b/wordpress/apache-hardening.conf index c6d6674..0c50bc7 100644 --- a/wordpress/apache-hardening.conf +++ b/wordpress/apache-hardening.conf @@ -10,6 +10,14 @@ TraceEnable Off Require all denied + + Require all denied + + + + Require all denied + + Require all denied