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