generoi / gds-security-hardening
Security hardening for WordPress that takes no per-site configuration
Package info
github.com/generoi/gds-security-hardening
Type:wordpress-muplugin
pkg:composer/generoi/gds-security-hardening
Requires
- php: >=8.0.0
Requires (Dev)
- laravel/pint: ^1.17
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^9.0
- szepeviktor/phpstan-wordpress: ^2.0
- wp-phpunit/wp-phpunit: ^7.0
- yoast/phpunit-polyfills: ^2.0
This package is auto-updated.
Last update: 2026-08-19 11:34:29 UTC
README
Security hardening for WordPress that takes no per-site configuration.
Installs as an mu-plugin. Every module is unconditional: anything needing a per-site answer — a REST allowlist, an integration user, a CSP — belongs in that site's own mu-plugins, not here.
composer require generoi/gds-security-hardening
What it does
| Module | Control |
|---|---|
XmlRpc |
Exits on XMLRPC_REQUEST before anything registers; filters xmlrpc_enabled off; removes the RSD link and X-Pingback |
Rest |
Disables JSONP; refuses a read overridden into a write; removes REST discovery output |
ApplicationPasswords |
Closes the remote authorization flow; keeps passwords to manage_options |
Headers |
HSTS, nosniff, X-Frame-Options, Referrer-Policy on frontend, admin, login and REST |
Uploads |
Keeps parser-heavy formats (PDF/EPS/SVG/HEIC/TIFF/macro-Office) to editors and above |
UserEnumeration |
Blocks ?author=<id>; makes authentication, login and lost-password responses uniform |
Passwords |
12-character minimum, enforced server side |
Credentials |
A password reset revokes application passwords and destroys sessions |
Roles |
Pins default_role |
Version |
Removes the generator tag; replaces ?ver= with a site-salted token |
ContentSecurityPolicy |
Four no-upkeep directives on admin; a full Strict CSP (nonce + strict-dynamic) on wp-login.php |
Filesystem |
Stops WordPress rewriting .htaccess |
Off by default, opt in with the modules filter:
| Module | Control |
|---|---|
TwoFactor |
Requires two-factor enrolment before any capability but read |
A password reset revokes nothing in core
Worth spelling out because it is surprising. wp_set_password()
(wp-includes/pluggable.php:3099) writes the hash, clears the activation key,
cleans the cache and fires an action. That is all. Core contains no callers of
wp_destroy_all_sessions(), and delete_all_application_passwords() is called
from exactly one place — the REST controller's manual DELETE.
So the single action a site owner takes on suspicion of compromise leaves the
attacker's session cookie live, and their application password live. The
application password survives two-factor too, because it is not a session.
Credentials binds after_password_reset and revokes both. It fires
gds_security_hardening_credentials_revoked so the revocation is not invisible to
whoever runs the integration that just lost its credential.
Deliberately not bound to wp_set_password: a reset is someone locking an
intruder out, a routine profile save is not.
login_errors is not enough to stop user enumeration
Core applies that filter in exactly one place, wp-login.php. Every other login
form in WordPress renders core's own message, and core's message names the
account: "The username bob is not registered on this site"
(wp-includes/user.php:185). WooCommerce's my-account form calls wp_signon()
and throws get_error_message() verbatim with no branch on the code
(includes/class-wc-form-handler.php:1076-1079).
So UserEnumeration normalises at authenticate (priority 100, after every core
authenticator and the spam check) and keeps login_errors as a second pass. It
replaces the message and leaves the code alone on purpose — the code is never
rendered, while plugins legitimately branch on it, and a brute-force limiter
counting incorrect_password separately from invalid_username should keep
working.
A timing oracle survives this: core returns before wp_check_password() when the
account does not exist, so an unknown username answers faster. Closing that means
burning a hash round on every invalid-username attempt — self-inflicted CPU
amplification an attacker can trigger at will. Not worth it.
Three things worth knowing before you change anything
JSONP and nosniff are one control, not two. Core validates the ?_jsonp=
callback against [\w.]+ — dots included — so ?_jsonp=window.opener.approve.click
executes as a method call in your origin. That is a Same Origin Method Execution
primitive on every reachable route, and it is the step CVE-2026-64638 used. With
JSONP off a REST response is application/json, and nosniff is what makes
<script src="/wp-json/..."> fail rather than be sniffed into script.
Only a read promoted to a write is refused. @wordpress/api-fetch ships
httpV1Middleware in its default middleware chain, rewriting every PATCH,
PUT and DELETE into a POST with X-HTTP-Method-Override. Refusing overrides
outright breaks every save of an existing post. See tests/Integration/RestTest.php.
The application-password gate cannot call user_can(). It runs inside
wp_validate_application_password(), hooked on determine_current_user, so the
current user is still being resolved; a capability check re-enters that through
any user_has_cap callback and recurses until PHP runs out of memory. It reads
$user->allcaps, which WP_User::get_role_caps() builds with no filters.
Turning a module on or off
add_filter('gds_security_hardening_modules', fn ($modules) => [ ...$modules, \GeneroWP\SecurityHardening\Modules\TwoFactor::class, ]);
TwoFactor is off by default because it needs the two-factor plugin and locks
every account out of everything but their profile until they enrol — a decision a
site makes, not a package. Without that plugin active it is inert rather than
locking people out of a site with no way to enrol.
The CSP is asymmetric on purpose
wp-admin gets only directives that need no per-site verification, and says
nothing about scripts. It cannot take a nonce policy — core alone prints around a
dozen un-nonced inline scripts per screen (core #59446) and the media library
needs eval (core #62894) — and the nonce-free alternative,
script-src-elem 'self' 'unsafe-inline', blocks the external <script src> that
plugins legitimately use on their own screens. Enforcing that safely needs a
per-site plugin sweep, which is what this package does not do.
wp-login.php gets a full Strict CSP: script-src 'nonce-…' 'strict-dynamic',
no unsafe-inline. Nothing without that request's nonce executes — injected
script tags, inline handlers, javascript: URIs and eval all fail — which is
what stops an injected script reading the password field on submit. The
credential-entry page is worth the stricter policy.
⚠️ The known cost: a plugin printing a raw <script> on the login screen
stops working. limit-login-attempts-reloaded does exactly that when credentials
are submitted, so it needs a patch or needs to go through
wp_print_inline_script_tag(). tests/e2e/login-csp.spec.js drives a real
browser through a successful and a failed login and fails on any CSP violation,
so a site can find out before its users do.
A site that cannot take it removes the module with the filter; a site wanting
more appends through gds_security_hardening_csp_directives.
Widening a control for one site
Never fork this package. Widen from the site's own mu-plugins at a later
priority — for an integration user whose role has no manage_options:
add_filter('wp_is_application_passwords_available_for_user', function ($available, $user) { return $available || $user->user_login === 'some-integration-user'; }, 20, 2);
Core normalises $user to a WP_User and bails on a missing one before any
filter runs, so the object is safe to rely on.
Testing
The suite is integration-shaped on purpose. Every defect this package was written
to avoid was invisible to a unit test: the recursion above needed real
user_has_cap callbacks, the override rejection being discarded needed another
plugin on the same filter, and an earlier version of the upload gate broke public
Gravity Forms uploads through a path no mock would model. Polylang is installed in
the test environment because it registers four rest_pre_dispatch callbacks.
composer install npm install npx wp-env start npm run test:php # phpunit against a real WordPress npm run test:e2e # playwright against the running site
The test environment installs the plugins the controls actually have to work
with — woocommerce, two-factor, limit-login-attempts-reloaded, wordfence
and polylang — and tests/bootstrap.php loads WooCommerce and two-factor into
the phpunit run. Several controls exist because of what those plugins do, so
testing them against doubles would assert our reading of the plugin rather than
the plugin.
That is why the suite needs -d memory_limit=1024M: WooCommerce does not fit in
the default 128M alongside the test harness.
Concretely, these run against the real thing rather than a mock:
POST /wp/v2/usersthroughrest_do_request(), because that route reacheswp_insert_user()and fires none of core's password hooks- WooCommerce's own
woocommerce_save_account_details_errorsand itsvalidate_password_resetcall, with thepassword_1field it actually posts WC_Shortcode_My_Account::reset_password(), so if WooCommerce ever drops theafter_password_resetparity it added in 10.9.0 we find out herecurrent_user_can()against a real unenrolled two-factor user
What the login policy blocks, measured
Core is clean: every route it uses to emit script on wp-login.php carries the
nonce — wp_script_attributes for tags, and wp_print_inline_script_tag() for
localized data (class-wp-scripts.php:247), inline before/after scripts (:612)
and translations (:366, :773).
- two-factor: clean. It ships raw-script copies of
login_header()/login_footer()but only loads them whenlogin_header()is undefined (class-two-factor-core.php:1111), and both entry points to the challenge arelogin_form_*actions that only fire inside wp-login.php, where core's is always defined. - wordfence, polylang: clean on the login screen.
- limit-login-attempts-reloaded: blocked. It echoes a raw
<script>on any POST to wp-login.php — a failed login and the two-factor challenge alike, not just failures. What that script does is wire up its own error display and an admin-ajax URL, so the cost is its supplementary messaging: the login form is core markup and keeps working, core still renders the error server-side, and LLAR's lockout enforcement is server-side and unaffected. The fix is to make it print throughwp_print_inline_script_tag(); patching it via composer-patches is what we do.
Two environment notes, both of which shape the tests rather than the code.
WordPress only loads mu-plugins sitting directly in WPMU_PLUGIN_DIR, so a
package installed into a subdirectory needs something to require it — Bedrock's
autoloader does that in production, and tests/loader.php stands in for it here.
And wp-env's Apache ships AllowOverride None, so .htaccess is ignored and
/wp-json/ never routes; the specs use the ?rest_route= form, and the editor
spec asserts that a save is not refused by us rather than that it returns 200.
Deliberately out of scope
This package covers what a plugin can enforce. The rest of the WordPress hardening guidance lives elsewhere, and is listed here so the gap is explicit rather than forgotten:
wp-config.php—DISALLOW_FILE_EDIT,DISALLOW_FILE_MODS,DISALLOW_UNFILTERED_HTML,FORCE_SSL_ADMIN, and a non-default table prefix. Config, not runtime.- File permissions — 755/644,
wp-config.phpat 440, and keeping the web server user out ofplugins/. Deployment. - Database privileges — the application user should hold
SELECT,INSERT,UPDATE,DELETEand nothing else. NoFILEprivilege, so an injection cannot reach the filesystem. Hosting. - No PHP execution under
wp-content/uploads/— the single highest-leverage control against the arbitrary-file-upload class, and not expressible in PHP. Web server config. - ImageMagick
policy.xml— the PDF coder reaches Ghostscript.Uploadsnarrows who can feed it; only the host policy narrows what it will parse. - Two-factor, WAF, backups, log monitoring, file integrity monitoring — separate tools.
- Automatic updates — deliberately off under Composer-managed WordPress; patch latency is then a release-pipeline property, not a config one.