fix: harden WordPress against WPScan findings

This commit is contained in:
bisco
2026-07-23 19:10:11 +02:00
parent 0762b98fc6
commit 472b8616b4
10 changed files with 74 additions and 6 deletions
+1
View File
@@ -52,6 +52,7 @@ services:
$$_SERVER['HTTPS'] = 'on'; $$_SERVER['HTTPS'] = 'on';
} }
define('DISALLOW_FILE_EDIT', true); define('DISALLOW_FILE_EDIT', true);
define('DISABLE_WP_CRON', true);
define('WP_AUTO_UPDATE_CORE', 'minor'); define('WP_AUTO_UPDATE_CORE', 'minor');
define('WP_POST_REVISIONS', 10); define('WP_POST_REVISIONS', 10);
if (getenv('WP_ENVIRONMENT_TYPE') === 'production') { if (getenv('WP_ENVIRONMENT_TYPE') === 'production') {
+1 -1
View File
@@ -12,7 +12,7 @@ architecture and starts independently from `main`.
## Decision ## 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 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 sanitized theme modifications, while a must-use plugin owns Shows and Gallery custom
post types so structured content is not lost when changing themes. post types so structured content is not lost when changing themes.
+1 -1
View File
@@ -1,7 +1,7 @@
# Architecture # Architecture
NGINX is the only public entry point for `azionelab.org`. It proxies HTTP to the 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 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. nor MariaDB publishes a host port; automated security checks guard this assumption.
+7
View File
@@ -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 test suite, and deploy. Production disables WordPress web-based file modifications, so
image rebuilds are the update path. 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 ## Backup and restore
Create database and WordPress file backups in one maintenance window. The default host 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 owner or mode; run `./scripts/prepare-host-volumes.sh` after changing paths or image
user IDs. user IDs.
- SMTP is not configured; WordPress password-reset email needs an external mail service. - 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. - Admin MFA and network allowlisting are deployment concerns and are not bundled.
- WordPress plugins expand the attack surface; install only reviewed, maintained, - WordPress plugins expand the attack surface; install only reviewed, maintained,
necessary plugins. necessary plugins.
+5 -3
View File
@@ -12,9 +12,11 @@
- File editing is always disabled. Production also disables web-based core, theme, and - File editing is always disabled. Production also disables web-based core, theme, and
plugin changes; patched images are rebuilt and redeployed instead. plugin changes; patched images are rebuilt and redeployed instead.
- XML-RPC and comments are disabled. NGINX blocks PHP execution below uploads, dotfiles, - 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 direct `wp-config.php` requests, the WordPress readme/license files, direct
also denies uploaded PHP files and direct access to sensitive WordPress internals as installation entry points, public mu-plugin directory probing, and external
defense in depth. Public REST user enumeration and author archives are disabled. `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 - Security headers include CSP, same-origin framing, content-type protection, a strict
referrer policy, and a restrictive Permissions Policy. WordPress compatibility still 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. requires inline style/script CSP allowances; do not treat this CSP as an XSS sanitizer.
+20
View File
@@ -18,6 +18,26 @@ location = /xmlrpc.php {
return 403; 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 { location = /wp-login.php {
limit_req zone=login burst=5 nodelay; limit_req zone=login burst=5 nodelay;
proxy_pass http://wordpress_backend; proxy_pass http://wordpress_backend;
+5
View File
@@ -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("/xmlrpc.php")).status()).toBe(403);
expect((await request.get("/.env")).status()).toBe(404); expect((await request.get("/.env")).status()).toBe(404);
expect((await request.get("/wp-config.php")).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-content/uploads/probe.php")).status()).toBe(403);
expect((await request.get("/wp-json/wp/v2/users")).status()).toBe(404); 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); expect((await request.get("/?post_type=azl_show&name=le-cose-che-restano")).status()).toBe(404);
+25
View File
@@ -22,12 +22,37 @@ grep -q 'azionelab-apache-hardening.conf' /workspace/wordpress/Dockerfile || {
exit 1 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 || { grep -q 'wp-content/(uploads|files)' /workspace/wordpress/apache-hardening.conf || {
echo "Apache hardening must block PHP execution below uploads/files." >&2 echo "Apache hardening must block PHP execution below uploads/files." >&2
exit 1 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 || { 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 echo "Structured show content must not be exposed as standalone public routes." >&2
exit 1 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
}
+1 -1
View File
@@ -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 php.ini /usr/local/etc/php/conf.d/azionelab.ini
COPY .htaccess /opt/azionelab/.htaccess COPY .htaccess /opt/azionelab/.htaccess
+8
View File
@@ -10,6 +10,14 @@ TraceEnable Off
Require all denied Require all denied
</LocationMatch> </LocationMatch>
<LocationMatch "^/(readme\.html|license\.txt|wp-admin/install\.php|wp-cron\.php)$">
Require all denied
</LocationMatch>
<LocationMatch "^/wp-content/mu-plugins/">
Require all denied
</LocationMatch>
<LocationMatch "^/wp-admin/includes/"> <LocationMatch "^/wp-admin/includes/">
Require all denied Require all denied
</LocationMatch> </LocationMatch>