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';
}
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') {
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+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
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.
+5 -3
View File
@@ -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.
+20
View File
@@ -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;
+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("/.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);
+25
View File
@@ -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
}
+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 .htaccess /opt/azionelab/.htaccess
+8
View File
@@ -10,6 +10,14 @@ TraceEnable Off
Require all denied
</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/">
Require all denied
</LocationMatch>