Skip to content

WordPress: scheme in wordpress__url, hardening, read-only core, and a setup_wordpress scenario - #382

Open
NavidSassan wants to merge 19 commits into
mainfrom
feat/wordpress-url-scheme
Open

NavidSassan wants to merge 19 commits into
mainfrom
feat/wordpress-url-scheme

Conversation

@NavidSassan

@NavidSassan NavidSassan commented Sep 15, 2026

Copy link
Copy Markdown
Member

The wordpress role and its vHost get a round of security fixes, the WordPress core becomes read-only for the web server, wordpress__url carries the scheme, and a new setup_wordpress Molecule scenario covers the whole stack. Running it on Rocky 8, 9 and 10 surfaced two bugs outside the role, which are already merged into main.

What is in here

WordPress (breaking)

  • wordpress__url includes the scheme and is the single source for the site address (home/siteurl, set on every run). The login cookie only gets Secure with an https home, and sites installed by the role had http://. FORCE_SSL_ADMIN and the cron request follow the scheme.
  • X-Forwarded-For is only honoured on requests from wordpress__trusted_proxies, read from the right. X-Forwarded-Host is ignored.
  • Application passwords are off by default (wordpress__application_passwords_enabled), set through a must-use plugin.
  • The core, wp-config.php and wp-content/mu-plugins belong to root; only wp-content belongs to apache (FS_METHOD direct, so plugins and themes still install from the web interface). WP-CLI runs as root only for commands that do not load WordPress, everything else as apache. WordPress no longer updates its core: wordpress-core-minor-update.timer installs minor releases, --tags wordpress:update the rest. setup_wordpress skips the document root chown of apache_httpd.
  • The REST API only answers logged-in users, through the maintained Disable WP REST API plugin. It replaces disable-json-api, which left routes of later installed plugins open, and the role uninstalls that plugin.

WordPress and its vHost

  • The vHost no longer runs PHP below wp-content/uploads.
  • The wp-includes block matches in vHost context; it never did before.
  • The vHost sends Referrer-Policy and nosniff.
  • wp-config.php is root:apache 0640.
  • Passwords reach WP-CLI via --prompt on stdin.
  • The WP-CLI phar is checked against its checksum.
  • Exports go to /backup/wordpress-export.
  • Fixed: the WXR import, the remote_tmp path, become for WP-CLI, and idempotence of the WP-CLI tasks.

fail2ban: a wordpress-login filter and z10-wordpress-login jail (failed login = POST wp-login.php answered 200).

Found by the scenario, merged separately into main: the apache_httpd document-root chown that broke idempotence on fresh hosts, and the missing SELinux label for /usr/bin/mariadbd on RHEL 10.

Points worth a close look

  • Three breaking changes in the inventory: the scheme in wordpress__url, wordpress__trusted_proxies behind a proxy, and wordpress__application_passwords_enabled: true where integrations need it.
  • The web interface can no longer update the core, and it cannot write .htaccess: after a permalink change, the displayed rules go into .htaccess by hand.
  • Disable WP REST API has no settings. A front-end feature that calls the REST API anonymously (some contact forms) needs an exception in code.
  • The regex_replace for the host part is intentional: setup_wordpress runs apache_httpd and selinux before the role's assert. A comment in defaults/main.yml explains it.

Testing

setup_wordpress and fail2ban/install pass converge, verify, idempotence and verify on Rocky 8, 9 and 10; apache_httpd on Rocky 9. setup_wordpress/verify.yml asserts through Apache and PHP-FPM:

  • the address and plugins
  • a login with a Secure cookie, and the HTTPS redirect
  • the headers
  • the referer, wp-includes and upload rules
  • X-Forwarded-For trust
  • application passwords
  • the mode of wp-config.php
  • anonymous REST requests get 401 rest_login_required
  • apache can write wp-content but not the core, wp-config.php or mu-plugins, and get_filesystem_method() is direct
  • wordpress-core-minor-update.service runs, and its timer is enabled
molecule test --scenario-name setup_wordpress
molecule test --scenario-name fail2ban/install

@NavidSassan NavidSassan changed the title feat(roles/wordpress)!: include the scheme in wordpress__url WordPress: scheme in wordpress__url, hardening, and a setup_wordpress scenario Sep 15, 2026
wp_set_auth_cookie() marks the logged_in cookie Secure only if home is
an https URL (wp-includes/pluggable.php), and wp core install
--url=<host> stored http://, so that cookie never got Secure.

wordpress__url now carries the scheme and is the single source for the
site address: WordPress is installed with it, home and siteurl are set
to it on every run, and FORCE_SSL_ADMIN / FORCE_SSL_LOGIN and the cron
request's X-Forwarded-Proto follow it. The host part is derived with
regex_replace instead of urlsplit, so that a bare host from an old
inventory still yields the same vHost and installation directory in
apache_httpd and selinux, which setup_wordpress runs before the role's
assert aborts.
… vHost context

In vHost context the URL path starts with a slash, so the rules quoted
from the .htaccess hardening guide never matched and wp-includes/*.php
could be called directly. Verified with httpd 2.4.62 on Rocky 9.
An upload flaw in any plugin turned into code execution, because the
PHP-FPM handler applied below uploads as well. Verified with httpd
2.4.62 and php-fpm 8.0 on Rocky 9, including .PHP, .phtml and //.
…ordPress vHost

"Header always set", because PHP-FPM responses carry their headers in
the table only "always" edits; WordPress sends both itself on some admin
requests, which would duplicate them otherwise.
…sers

The recurring chmod 644 left the database password and the salts
readable for every local user. wp config set and blockinfile keep the
0640 (verified with WP-CLI 2.12).
…sted_proxies

Any client could set REMOTE_ADDR and HTTP_HOST. X-Forwarded-For is now
walked from the right on requests from a listed proxy and validated as
an IP; X-Forwarded-Host is dropped, since the vHost only accepts the
ServerName as Host anyway. X-Forwarded-Proto stays unrestricted, a
client faking it only changes what it is served itself.
WordPress answers a failed login with the form again (200) and a
successful one with a redirect (302). Verified with fail2ban-regex
1.1.0 on Rocky 9 against combined, linuxfabrikio and vhost_common lines.
wp-cli publishes wp-cli.phar.sha512 next to the phar; get_url replaces a
differing file and aborts on a mismatch.
--dbpass and --admin_password were readable in the process list and
appeared in the task names. WP-CLI's --prompt reads them from stdin, and
--quiet keeps them out of the output, because WP-CLI prints the
completed command line, password included, after prompting (verified
with WP-CLI 2.12).
/tmp left the export, with unpublished content and the users' e-mail
addresses, readable for other local users.
WordPress has no wp-config.php constant for this, so the role deploys a
must-use plugin that filters wp_is_application_passwords_available
(verified against WordPress in a container).
…rdPress

The tasks read the path from a loop variable without a loop, so the
condition was always false and the import never ran.
The WP-CLI tasks set become_user without become, so with a root
connection they ran as root and WP-CLI refused ("YIKES! It looks like
you're running this as root"). They now become apache like the
nextcloud role does.

`wp config set` reports "Updated" even for an unchanged value, so the
constants are read with `wp config get --format=json` first and only
set when they differ. Plugin and theme installs report a change only
when WP-CLI installs or activates something. Verified with WP-CLI 2.12.
verify.yml asserts through Apache httpd and PHP-FPM: the address from
wordpress__url, the configured plugins and theme, a login with a
Secure cookie and the HTTPS redirect, the security headers, the
referer, wp-includes and upload rules of the vHost, X-Forwarded-For
trust, application passwords and the mode of wp-config.php. Passes
converge, verify and idempotence on Rocky 8, 9 and 10.
…il2ban scenario

The jail needs Apache logs the scenario host does not have, so the
deployed filter is fed sample lines through fail2ban-regex. Passes on
Rocky 8, 9 and 10 (fail2ban 1.1.0).
The setup_wordpress scenario passes converge, verify and idempotence on
Rocky 8, 9 and 10.
@NavidSassan
NavidSassan force-pushed the feat/wordpress-url-scheme branch from 36a28c5 to 2fe7c6e Compare September 15, 2026 13:05
… API

Disable REST API (disable-json-api 1.8, last updated 2023-09-14) stores
the routes that exist when its settings are created as blocked for
anonymous visitors and falls back to its global default_allow (true)
for every other route. Routes that plugins installed later register
therefore stayed reachable without a login: in a container,
/wp/v2/users answered 401 while such a route answered 200.

Disable WP REST API (2.6.9, maintained) returns rest_login_required from
rest_authentication_errors for every anonymous REST request, which
WordPress evaluates before dispatching any route. The role uninstalls
the old plugin. setup_wordpress passes on Rocky 9 and asserts the 401.
The core, wp-config.php and wp-content/mu-plugins belong to root, the
rest of wp-content to apache, so a vulnerable plugin can no longer plant
code in the core. FS_METHOD direct keeps plugin and theme installs from
the web interface working, since WordPress otherwise asks for FTP
credentials when its files belong to another user.

WP-CLI runs as root only for commands annotated `@when before_wp_load`
(core download, config, core verify-checksums). Everything that loads
WordPress runs as apache, because loading it executes wp-content code
(plugins, mu-plugins, drop-ins) even with --skip-plugins.

WP_AUTO_UPDATE_CORE is false; wordpress-core-minor-update.timer installs the
latest minor release instead. setup_wordpress skips the document root
chown of apache_httpd.

Verified with the setup_wordpress scenario on Rocky 8, 9 and 10.
@NavidSassan NavidSassan changed the title WordPress: scheme in wordpress__url, hardening, and a setup_wordpress scenario WordPress: scheme in wordpress__url, hardening, read-only core, and a setup_wordpress scenario Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant